Add SelfFreezoneClient wrapper for Self components
- Created SelfFreezoneClient in Self components
- Wraps SDK FreezoneScriptClient for Self-specific operations
- Implements send_verification_email method
- Uses Rhai script template for email verification
- Includes template variable substitution
- Added serde-wasm-bindgen dependency
Usage:
let client = SelfFreezoneClient::builder()
.supervisor_url("http://localhost:8080")
.secret("my-secret")
.build()?;
client.send_verification_email(
"user@example.com",
"123456",
"https://verify.com/abc"
).await?;
This commit is contained in:
409
components/src/login.rs
Normal file
409
components/src/login.rs
Normal file
@@ -0,0 +1,409 @@
|
||||
use yew::prelude::*;
|
||||
use web_sys::HtmlInputElement;
|
||||
use wasm_bindgen::JsCast;
|
||||
use serde_json;
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
use crate::vault::{Vault, VaultError};
|
||||
use crate::crypto::KeyPair;
|
||||
use base64::{Engine as _, engine::general_purpose};
|
||||
|
||||
#[derive(Clone, PartialEq, Properties)]
|
||||
pub struct LoginConfig {
|
||||
pub server_url: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Properties)]
|
||||
pub struct LoginProps {
|
||||
pub config: LoginConfig,
|
||||
pub on_login_success: Callback<String>, // Callback with JWT token
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub enum LoginStatus {
|
||||
NotStarted,
|
||||
Processing,
|
||||
Success,
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
pub struct Login {
|
||||
password: String,
|
||||
private_key: String,
|
||||
status: LoginStatus,
|
||||
show_password: bool,
|
||||
show_private_key: bool,
|
||||
has_stored_key: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum LoginMsg {
|
||||
UpdatePassword(String),
|
||||
UpdatePrivateKey(String),
|
||||
TogglePasswordVisibility,
|
||||
TogglePrivateKeyVisibility,
|
||||
SubmitLogin,
|
||||
LoginSuccess(String), // JWT token
|
||||
LoginFailed(String),
|
||||
}
|
||||
|
||||
impl Component for Login {
|
||||
type Message = LoginMsg;
|
||||
type Properties = LoginProps;
|
||||
|
||||
fn create(_ctx: &Context<Self>) -> Self {
|
||||
let has_stored_key = Vault::has_stored_key();
|
||||
Self {
|
||||
password: String::new(),
|
||||
private_key: String::new(),
|
||||
status: LoginStatus::NotStarted,
|
||||
show_password: false,
|
||||
show_private_key: false,
|
||||
has_stored_key,
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
match msg {
|
||||
LoginMsg::UpdatePassword(password) => {
|
||||
self.password = password;
|
||||
true
|
||||
}
|
||||
LoginMsg::UpdatePrivateKey(private_key) => {
|
||||
self.private_key = private_key;
|
||||
true
|
||||
}
|
||||
LoginMsg::TogglePasswordVisibility => {
|
||||
self.show_password = !self.show_password;
|
||||
true
|
||||
}
|
||||
LoginMsg::TogglePrivateKeyVisibility => {
|
||||
self.show_private_key = !self.show_private_key;
|
||||
true
|
||||
}
|
||||
LoginMsg::SubmitLogin => {
|
||||
if self.validate_form() {
|
||||
self.submit_login(ctx);
|
||||
}
|
||||
true
|
||||
}
|
||||
LoginMsg::LoginSuccess(jwt_token) => {
|
||||
self.status = LoginStatus::Success;
|
||||
|
||||
// Store JWT token in localStorage for future use
|
||||
if let Some(window) = web_sys::window() {
|
||||
if let Ok(Some(storage)) = window.local_storage() {
|
||||
let _ = storage.set_item("jwt_token", &jwt_token);
|
||||
web_sys::console::log_1(&"JWT token stored in localStorage".into());
|
||||
}
|
||||
}
|
||||
|
||||
ctx.props().on_login_success.emit(jwt_token);
|
||||
true
|
||||
}
|
||||
LoginMsg::LoginFailed(error) => {
|
||||
self.status = LoginStatus::Failed(error);
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = ctx.link();
|
||||
|
||||
html! {
|
||||
<div>
|
||||
<div style="padding: 2rem;">
|
||||
<h4 class="mb-4">{"Sign In"}</h4>
|
||||
|
||||
{self.render_status_notification()}
|
||||
|
||||
<form onsubmit={link.callback(|e: SubmitEvent| {
|
||||
e.prevent_default();
|
||||
LoginMsg::SubmitLogin
|
||||
})}>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">
|
||||
{if self.has_stored_key {
|
||||
"Password (to decrypt stored key)"
|
||||
} else {
|
||||
"Password"
|
||||
}}
|
||||
</label>
|
||||
<div class="input-group">
|
||||
<input type={if self.show_password { "text" } else { "password" }}
|
||||
class="form-control"
|
||||
value={self.password.clone()}
|
||||
oninput={link.callback(|e: InputEvent| {
|
||||
let input: HtmlInputElement = e.target_unchecked_into();
|
||||
LoginMsg::UpdatePassword(input.value())
|
||||
})}
|
||||
placeholder={if self.has_stored_key {
|
||||
"Enter password to decrypt your key"
|
||||
} else {
|
||||
"Enter your password"
|
||||
}}
|
||||
required=true />
|
||||
<button type="button" class="btn btn-outline-secondary"
|
||||
onclick={link.callback(|_| LoginMsg::TogglePasswordVisibility)}>
|
||||
<i class={if self.show_password { "bi bi-eye-slash" } else { "bi bi-eye" }}></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{if !self.has_stored_key {
|
||||
html! {
|
||||
<div class="mb-4">
|
||||
<label class="form-label">{"Private Key"}</label>
|
||||
<div class="input-group">
|
||||
<input type={if self.show_private_key { "text" } else { "password" }}
|
||||
class="form-control font-monospace"
|
||||
value={self.private_key.clone()}
|
||||
oninput={link.callback(|e: InputEvent| {
|
||||
let input: HtmlInputElement = e.target_unchecked_into();
|
||||
LoginMsg::UpdatePrivateKey(input.value())
|
||||
})}
|
||||
placeholder="Enter your private key"
|
||||
required=true />
|
||||
<button type="button" class="btn btn-outline-secondary"
|
||||
onclick={link.callback(|_| LoginMsg::TogglePrivateKeyVisibility)}>
|
||||
<i class={if self.show_private_key { "bi bi-eye-slash" } else { "bi bi-eye" }}></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-text">
|
||||
{"Your private key will be encrypted and stored securely in your browser after login."}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
|
||||
<div class="d-grid">
|
||||
<button type="submit" class="btn btn-primary btn-lg"
|
||||
disabled={!self.validate_form() || matches!(self.status, LoginStatus::Processing)}>
|
||||
{if matches!(self.status, LoginStatus::Processing) {
|
||||
html! {
|
||||
<>
|
||||
<span class="spinner-border spinner-border-sm me-2"></span>
|
||||
{"Signing In..."}
|
||||
</>
|
||||
}
|
||||
} else {
|
||||
html! { "Sign In" }
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="mt-4">
|
||||
<p class="text-muted small">
|
||||
{"Forgot your password? "}
|
||||
<a href="#" class="text-decoration-none">{"Reset it here"}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Login {
|
||||
fn validate_form(&self) -> bool {
|
||||
let password_valid = !self.password.trim().is_empty();
|
||||
|
||||
if self.has_stored_key {
|
||||
password_valid
|
||||
} else {
|
||||
password_valid && !self.private_key.trim().is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
fn submit_login(&mut self, ctx: &Context<Self>) {
|
||||
self.status = LoginStatus::Processing;
|
||||
|
||||
let password = self.password.clone();
|
||||
let private_key = self.private_key.clone();
|
||||
let has_stored_key = self.has_stored_key;
|
||||
let server_url = ctx.props().config.server_url.clone();
|
||||
let link = ctx.link().clone();
|
||||
|
||||
spawn_local(async move {
|
||||
let (final_private_key, public_key) = if has_stored_key {
|
||||
// Decrypt stored keypair
|
||||
match Vault::retrieve_keypair(&password) {
|
||||
Ok((stored_private_key, stored_public_key)) => {
|
||||
web_sys::console::log_1(&"Successfully retrieved keypair from vault".into());
|
||||
(stored_private_key, stored_public_key)
|
||||
}
|
||||
Err(e) => {
|
||||
link.send_message(LoginMsg::LoginFailed(format!("Failed to decrypt stored key: {}", e)));
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Derive public key from private key and store both encrypted
|
||||
let keypair = match KeyPair::from_private_key(&private_key) {
|
||||
Ok(keypair) => keypair,
|
||||
Err(e) => {
|
||||
link.send_message(LoginMsg::LoginFailed(format!("Invalid private key: {}", e)));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
match Vault::store_keypair(&private_key, &keypair.public_key, &password) {
|
||||
Ok(_) => {
|
||||
web_sys::console::log_1(&"Keypair stored securely in vault".into());
|
||||
}
|
||||
Err(e) => {
|
||||
web_sys::console::log_1(&format!("Warning: Failed to store keypair in vault: {}", e).into());
|
||||
}
|
||||
}
|
||||
(private_key, keypair.public_key)
|
||||
};
|
||||
|
||||
// Authenticate with identity server using public key
|
||||
match Self::authenticate_with_server(&server_url, &public_key, &final_private_key).await {
|
||||
Ok(jwt_token) => {
|
||||
web_sys::console::log_1(&format!("Authentication successful, received JWT token").into());
|
||||
link.send_message(LoginMsg::LoginSuccess(jwt_token));
|
||||
}
|
||||
Err(e) => {
|
||||
link.send_message(LoginMsg::LoginFailed(format!("Authentication failed: {}", e)));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fn authenticate_with_server(server_url: &str, public_key: &str, private_key: &str) -> Result<String, String> {
|
||||
use web_sys::{Request, RequestInit, RequestMode, Response};
|
||||
use wasm_bindgen::JsValue;
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
|
||||
// Create authentication challenge
|
||||
let challenge = format!("auth_challenge_{}", js_sys::Date::now());
|
||||
|
||||
// Sign the challenge with private key
|
||||
let signature = Self::create_auth_signature(&challenge, private_key, public_key)?;
|
||||
|
||||
// Prepare authentication request
|
||||
let auth_request = serde_json::json!({
|
||||
"grant_type": "client_credentials",
|
||||
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
|
||||
"client_assertion": signature,
|
||||
"public_key": public_key,
|
||||
"challenge": challenge,
|
||||
"scope": "openid profile email"
|
||||
});
|
||||
|
||||
let mut opts = RequestInit::new();
|
||||
opts.method("POST");
|
||||
opts.mode(RequestMode::Cors);
|
||||
|
||||
let headers = js_sys::Object::new();
|
||||
js_sys::Reflect::set(&headers, &"Content-Type".into(), &"application/json".into()).unwrap();
|
||||
opts.headers(&headers);
|
||||
opts.body(Some(&JsValue::from_str(&auth_request.to_string())));
|
||||
|
||||
let url = format!("{}/oauth/token", server_url);
|
||||
let request = Request::new_with_str_and_init(&url, &opts)
|
||||
.map_err(|_| "Failed to create request")?;
|
||||
|
||||
let window = web_sys::window().ok_or("No window object")?;
|
||||
let resp_value = JsFuture::from(window.fetch_with_request(&request))
|
||||
.await
|
||||
.map_err(|_| "Network request failed")?;
|
||||
|
||||
let resp: Response = resp_value.dyn_into()
|
||||
.map_err(|_| "Failed to cast response")?;
|
||||
|
||||
if !resp.ok() {
|
||||
// Get error response body for debugging
|
||||
let error_json = JsFuture::from(resp.json().map_err(|_| "Failed to get error response JSON")?)
|
||||
.await
|
||||
.map_err(|_| "Failed to parse error JSON response")?;
|
||||
|
||||
let error_text = js_sys::JSON::stringify(&error_json)
|
||||
.map_err(|_| "Failed to stringify error response")?
|
||||
.as_string()
|
||||
.ok_or("Failed to convert error response to string")?;
|
||||
|
||||
return Err(format!("Authentication failed with status: {}\n\nrequest response: {}", resp.status(), error_text));
|
||||
}
|
||||
|
||||
let json = JsFuture::from(resp.json().map_err(|_| "Failed to get response JSON")?)
|
||||
.await
|
||||
.map_err(|_| "Failed to parse JSON response")?;
|
||||
|
||||
let response_text = js_sys::JSON::stringify(&json)
|
||||
.map_err(|_| "Failed to stringify response")?
|
||||
.as_string()
|
||||
.ok_or("Failed to convert response to string")?;
|
||||
|
||||
let response: serde_json::Value = serde_json::from_str(&response_text)
|
||||
.map_err(|_| "Failed to parse response JSON")?;
|
||||
|
||||
response["access_token"]
|
||||
.as_str()
|
||||
.map(|s| s.to_string())
|
||||
.ok_or("No access token in response".to_string())
|
||||
}
|
||||
|
||||
fn create_auth_signature(challenge: &str, private_key: &str, public_key: &str) -> Result<String, String> {
|
||||
use sha2::{Sha256, Digest};
|
||||
|
||||
// Create JWT header
|
||||
let header = serde_json::json!({
|
||||
"alg": "HS256",
|
||||
"typ": "JWT"
|
||||
});
|
||||
|
||||
// Create JWT payload with challenge - subject should be public key
|
||||
let payload = serde_json::json!({
|
||||
"iss": "self-sovereign-identity",
|
||||
"sub": public_key, // Subject is the public key (user identifier)
|
||||
"aud": "identity-server",
|
||||
"exp": (js_sys::Date::now() / 1000.0) as u64 + 3600, // 1 hour expiry
|
||||
"iat": (js_sys::Date::now() / 1000.0) as u64,
|
||||
"scope": "openid profile email",
|
||||
"challenge": challenge
|
||||
});
|
||||
|
||||
let header_b64 = general_purpose::STANDARD.encode(header.to_string());
|
||||
let payload_b64 = general_purpose::STANDARD.encode(payload.to_string());
|
||||
let unsigned_token = format!("{}.{}", header_b64, payload_b64);
|
||||
|
||||
// Sign with private key (simplified HMAC)
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(unsigned_token.as_bytes());
|
||||
hasher.update(private_key.as_bytes());
|
||||
let signature = hasher.finalize();
|
||||
let signature_b64 = general_purpose::STANDARD.encode(signature);
|
||||
|
||||
Ok(format!("{}.{}", unsigned_token, signature_b64))
|
||||
}
|
||||
|
||||
fn render_status_notification(&self) -> Html {
|
||||
match &self.status {
|
||||
LoginStatus::NotStarted => html! {},
|
||||
LoginStatus::Processing => html! {
|
||||
<div class="alert alert-info mb-3">
|
||||
<i class="bi bi-hourglass-split me-2"></i>
|
||||
{"Signing you in..."}
|
||||
</div>
|
||||
},
|
||||
LoginStatus::Success => html! {
|
||||
<div class="alert alert-success mb-3">
|
||||
<i class="bi bi-check-circle-fill me-2"></i>
|
||||
{"Successfully signed in! Redirecting..."}
|
||||
</div>
|
||||
},
|
||||
LoginStatus::Failed(error) => html! {
|
||||
<div class="alert alert-danger mb-3">
|
||||
<i class="bi bi-exclamation-triangle-fill me-2"></i>
|
||||
{error}
|
||||
</div>
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user