mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-06-03 13:46:24 +00:00
feat(oauth): discoverable passkey authentication
Lewis: May this revision serve well! <lu5a@proton.me>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT did, password_hash FROM users WHERE handle = $1 OR email = $1",
|
||||
"query": "SELECT did, password_hash FROM users WHERE handle = $1 OR did = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -24,5 +24,5 @@
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "c4621f6a8a1ab78a6355b09fdfc2bf8999d276564e93015792ec07cb05e79038"
|
||||
"hash": "053c971024b0d29a441c3597d760b3e21db2383442c3e6f09de4eb49ea437e7c"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT id, did, email, password_hash, password_required, two_factor_enabled,\n preferred_comms_channel as \"preferred_comms_channel!: CommsChannel\",\n deactivated_at, takedown_ref,\n email_verified, discord_verified, telegram_verified, signal_verified,\n account_type as \"account_type!: AccountType\"\n FROM users\n WHERE handle = $1 OR email = $1\n ",
|
||||
"query": "\n SELECT id, did, email, password_hash, password_required, two_factor_enabled,\n preferred_comms_channel as \"preferred_comms_channel!: CommsChannel\",\n deactivated_at, takedown_ref,\n email_verified, discord_verified, telegram_verified, signal_verified,\n account_type as \"account_type!: AccountType\"\n FROM users\n WHERE handle = $1 OR did = $1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -118,5 +118,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "7061e8763ef7d91ff152ed0124f99e1820172fd06916d225ca6c5137a507b8fa"
|
||||
"hash": "060c285c93a05252aab7d474df0186e7b5083fafedc582b8eac9916983e8fc2d"
|
||||
}
|
||||
14
.sqlx/query-3155ef4f35698a3fe6aa38d5d976fd51b7f6a0381c81c4907dad61d2f37992bd.json
generated
Normal file
14
.sqlx/query-3155ef4f35698a3fe6aa38d5d976fd51b7f6a0381c81c4907dad61d2f37992bd.json
generated
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM webauthn_challenges WHERE did = $1 AND challenge_type = 'discoverable'",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "3155ef4f35698a3fe6aa38d5d976fd51b7f6a0381c81c4907dad61d2f37992bd"
|
||||
}
|
||||
22
.sqlx/query-6969c478a0922bac4b79902313a0e28c94d6b8d6b16035474dd8f484e6171d60.json
generated
Normal file
22
.sqlx/query-6969c478a0922bac4b79902313a0e28c94d6b8d6b16035474dd8f484e6171d60.json
generated
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT state_json FROM webauthn_challenges\n WHERE did = $1 AND challenge_type = 'discoverable' AND expires_at > NOW()\n ORDER BY created_at DESC LIMIT 1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "state_json",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "6969c478a0922bac4b79902313a0e28c94d6b8d6b16035474dd8f484e6171d60"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT\n u.id, u.did, u.handle, u.password_hash, u.email, u.deactivated_at, u.takedown_ref,\n u.email_verified, u.discord_verified, u.telegram_verified, u.signal_verified,\n u.allow_legacy_login, u.migrated_to_pds,\n u.preferred_comms_channel as \"preferred_comms_channel: CommsChannel\",\n k.key_bytes, k.encryption_version,\n (SELECT verified FROM user_totp WHERE did = u.did) as totp_enabled,\n COALESCE((SELECT (value_json)::boolean FROM account_preferences WHERE user_id = u.id AND name = 'email_auth_factor' ORDER BY created_at DESC LIMIT 1), false) as \"email_2fa_enabled!\"\n FROM users u\n JOIN user_keys k ON u.id = k.user_id\n WHERE u.handle = $1 OR u.email = $1 OR u.did = $1",
|
||||
"query": "SELECT\n u.id, u.did, u.handle, u.password_hash, u.email, u.deactivated_at, u.takedown_ref,\n u.email_verified, u.discord_verified, u.telegram_verified, u.signal_verified,\n u.allow_legacy_login, u.migrated_to_pds,\n u.preferred_comms_channel as \"preferred_comms_channel: CommsChannel\",\n k.key_bytes, k.encryption_version,\n (SELECT verified FROM user_totp WHERE did = u.did) as totp_enabled,\n COALESCE((SELECT (value_json)::boolean FROM account_preferences WHERE user_id = u.id AND name = 'email_auth_factor' ORDER BY created_at DESC LIMIT 1), false) as \"email_2fa_enabled!\"\n FROM users u\n JOIN user_keys k ON u.id = k.user_id\n WHERE u.handle = $1 OR u.did = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -132,5 +132,5 @@
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "a960b981a146a0e422ef53601dfc31e29cf777aa194227c48c6ebc6905ea3249"
|
||||
"hash": "aafc2a7e51200ca1e7071c63c13698bf34ef8b66758ca9ebab4ea706ffb62914"
|
||||
}
|
||||
18
.sqlx/query-c6e3388fc39983f1787917606ba3194c72322d2d1ec54402c262194791a2b06a.json
generated
Normal file
18
.sqlx/query-c6e3388fc39983f1787917606ba3194c72322d2d1ec54402c262194791a2b06a.json
generated
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO webauthn_challenges (id, did, challenge, challenge_type, state_json, expires_at)\n VALUES ($1, $2, $3, 'discoverable', $4, $5)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Text",
|
||||
"Bytea",
|
||||
"Text",
|
||||
"Timestamptz"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "c6e3388fc39983f1787917606ba3194c72322d2d1ec54402c262194791a2b06a"
|
||||
}
|
||||
45
Cargo.lock
generated
45
Cargo.lock
generated
@@ -7405,7 +7405,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-api"
|
||||
version = "0.5.0"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -7456,7 +7456,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-auth"
|
||||
version = "0.5.0"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base32",
|
||||
@@ -7479,7 +7479,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-cache"
|
||||
version = "0.5.0"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
@@ -7493,7 +7493,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-comms"
|
||||
version = "0.5.0"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
@@ -7511,7 +7511,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-config"
|
||||
version = "0.5.0"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"confique",
|
||||
"serde",
|
||||
@@ -7519,7 +7519,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-crypto"
|
||||
version = "0.5.0"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"base64 0.22.1",
|
||||
@@ -7535,7 +7535,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-db"
|
||||
version = "0.5.0"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -7552,7 +7552,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-db-traits"
|
||||
version = "0.5.0"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
@@ -7568,7 +7568,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-infra"
|
||||
version = "0.5.0"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
@@ -7579,7 +7579,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-lexicon"
|
||||
version = "0.5.0"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"hickory-resolver",
|
||||
@@ -7597,7 +7597,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-oauth"
|
||||
version = "0.5.0"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -7620,7 +7620,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-oauth-server"
|
||||
version = "0.5.0"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
@@ -7653,7 +7653,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-pds"
|
||||
version = "0.5.0"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"anyhow",
|
||||
@@ -7738,13 +7738,14 @@ dependencies = [
|
||||
"urlencoding",
|
||||
"uuid",
|
||||
"webauthn-rs",
|
||||
"webauthn-rs-proto",
|
||||
"wiremock",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-repo"
|
||||
version = "0.5.0"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cid",
|
||||
@@ -7756,7 +7757,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-ripple"
|
||||
version = "0.5.0"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"backon",
|
||||
@@ -7781,7 +7782,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-scopes"
|
||||
version = "0.5.0"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"futures",
|
||||
@@ -7797,7 +7798,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-server"
|
||||
version = "0.5.0"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"clap",
|
||||
@@ -7818,7 +7819,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-signal"
|
||||
version = "0.5.0"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -7841,7 +7842,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-storage"
|
||||
version = "0.5.0"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"aws-config",
|
||||
@@ -7858,7 +7859,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-store"
|
||||
version = "0.5.0"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
@@ -7904,7 +7905,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-sync"
|
||||
version = "0.5.0"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -7926,7 +7927,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-types"
|
||||
version = "0.5.0"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"cid",
|
||||
|
||||
@@ -26,7 +26,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.5.0"
|
||||
version = "0.5.1"
|
||||
edition = "2024"
|
||||
license = "AGPL-3.0-or-later"
|
||||
|
||||
@@ -126,7 +126,7 @@ tracing = "0.1"
|
||||
tracing-subscriber = "0.3"
|
||||
urlencoding = "2.1"
|
||||
uuid = { version = "1.19", features = ["v4", "v5", "v7", "fast-rng", "serde"] }
|
||||
webauthn-rs = { version = "0.5", features = ["danger-allow-state-serialisation", "danger-user-presence-only-security-keys"] }
|
||||
webauthn-rs = { version = "0.5", features = ["danger-allow-state-serialisation", "danger-user-presence-only-security-keys", "conditional-ui"] }
|
||||
webauthn-rs-proto = "0.5"
|
||||
zip = { version = "7.0", default-features = false, features = ["deflate"] }
|
||||
|
||||
|
||||
@@ -144,12 +144,12 @@ pub trait UserRepository: Send + Sync {
|
||||
|
||||
async fn get_by_email(&self, email: &str) -> Result<Option<UserForVerification>, DbError>;
|
||||
|
||||
async fn get_login_check_by_handle_or_email(
|
||||
async fn get_login_check_by_identifier(
|
||||
&self,
|
||||
identifier: &str,
|
||||
) -> Result<Option<UserLoginCheck>, DbError>;
|
||||
|
||||
async fn get_login_info_by_handle_or_email(
|
||||
async fn get_login_info_by_identifier(
|
||||
&self,
|
||||
identifier: &str,
|
||||
) -> Result<Option<UserLoginInfo>, DbError>;
|
||||
@@ -358,6 +358,19 @@ pub trait UserRepository: Send + Sync {
|
||||
challenge_type: WebauthnChallengeType,
|
||||
) -> Result<(), DbError>;
|
||||
|
||||
async fn save_discoverable_challenge(
|
||||
&self,
|
||||
request_key: &str,
|
||||
state_json: &str,
|
||||
) -> Result<Uuid, DbError>;
|
||||
|
||||
async fn load_discoverable_challenge(
|
||||
&self,
|
||||
request_key: &str,
|
||||
) -> Result<Option<String>, DbError>;
|
||||
|
||||
async fn delete_discoverable_challenge(&self, request_key: &str) -> Result<(), DbError>;
|
||||
|
||||
async fn get_totp_record(&self, did: &Did) -> Result<Option<TotpRecord>, DbError>;
|
||||
|
||||
async fn get_totp_record_state(&self, did: &Did) -> Result<Option<TotpRecordState>, DbError>;
|
||||
|
||||
@@ -1102,6 +1102,59 @@ impl UserRepository for PostgresUserRepository {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn save_discoverable_challenge(
|
||||
&self,
|
||||
request_key: &str,
|
||||
state_json: &str,
|
||||
) -> Result<Uuid, DbError> {
|
||||
let id = Uuid::new_v4();
|
||||
let challenge = id.as_bytes().to_vec();
|
||||
let expires_at = chrono::Utc::now() + chrono::Duration::minutes(5);
|
||||
sqlx::query!(
|
||||
r#"INSERT INTO webauthn_challenges (id, did, challenge, challenge_type, state_json, expires_at)
|
||||
VALUES ($1, $2, $3, 'discoverable', $4, $5)"#,
|
||||
id,
|
||||
request_key,
|
||||
challenge,
|
||||
state_json,
|
||||
expires_at,
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
async fn load_discoverable_challenge(
|
||||
&self,
|
||||
request_key: &str,
|
||||
) -> Result<Option<String>, DbError> {
|
||||
let row = sqlx::query_scalar!(
|
||||
r#"SELECT state_json FROM webauthn_challenges
|
||||
WHERE did = $1 AND challenge_type = 'discoverable' AND expires_at > NOW()
|
||||
ORDER BY created_at DESC LIMIT 1"#,
|
||||
request_key,
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
async fn delete_discoverable_challenge(&self, request_key: &str) -> Result<(), DbError> {
|
||||
sqlx::query!(
|
||||
"DELETE FROM webauthn_challenges WHERE did = $1 AND challenge_type = 'discoverable'",
|
||||
request_key,
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_totp_record(&self, did: &Did) -> Result<Option<TotpRecord>, DbError> {
|
||||
let row = sqlx::query!(
|
||||
"SELECT secret_encrypted, encryption_version, verified FROM user_totp WHERE did = $1",
|
||||
@@ -1330,12 +1383,12 @@ impl UserRepository for PostgresUserRepository {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_login_check_by_handle_or_email(
|
||||
async fn get_login_check_by_identifier(
|
||||
&self,
|
||||
identifier: &str,
|
||||
) -> Result<Option<UserLoginCheck>, DbError> {
|
||||
sqlx::query!(
|
||||
"SELECT did, password_hash FROM users WHERE handle = $1 OR email = $1",
|
||||
"SELECT did, password_hash FROM users WHERE handle = $1 OR did = $1",
|
||||
identifier
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
@@ -1349,7 +1402,7 @@ impl UserRepository for PostgresUserRepository {
|
||||
})
|
||||
}
|
||||
|
||||
async fn get_login_info_by_handle_or_email(
|
||||
async fn get_login_info_by_identifier(
|
||||
&self,
|
||||
identifier: &str,
|
||||
) -> Result<Option<UserLoginInfo>, DbError> {
|
||||
@@ -1361,7 +1414,7 @@ impl UserRepository for PostgresUserRepository {
|
||||
email_verified, discord_verified, telegram_verified, signal_verified,
|
||||
account_type as "account_type!: AccountType"
|
||||
FROM users
|
||||
WHERE handle = $1 OR email = $1
|
||||
WHERE handle = $1 OR did = $1
|
||||
"#,
|
||||
identifier
|
||||
)
|
||||
@@ -1524,7 +1577,7 @@ impl UserRepository for PostgresUserRepository {
|
||||
COALESCE((SELECT (value_json)::boolean FROM account_preferences WHERE user_id = u.id AND name = 'email_auth_factor' ORDER BY created_at DESC LIMIT 1), false) as "email_2fa_enabled!"
|
||||
FROM users u
|
||||
JOIN user_keys k ON u.id = k.user_id
|
||||
WHERE u.handle = $1 OR u.email = $1 OR u.did = $1"#,
|
||||
WHERE u.handle = $1 OR u.did = $1"#,
|
||||
identifier
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
|
||||
@@ -322,7 +322,9 @@ fn validate_blob_ref(
|
||||
|
||||
if let Some(ref accept) = lex_blob.accept {
|
||||
let mime_type = obj.get("mimeType").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let matched = accept.iter().any(|pattern| mime_type_matches_accept_pattern(mime_type, pattern));
|
||||
let matched = accept
|
||||
.iter()
|
||||
.any(|pattern| mime_type_matches_accept_pattern(mime_type, pattern));
|
||||
if !mime_type.is_empty() && !matched {
|
||||
return Err(LexValidationError::field(
|
||||
path,
|
||||
|
||||
@@ -108,7 +108,7 @@ pub async fn authorize_get(
|
||||
match state
|
||||
.repos
|
||||
.user
|
||||
.get_login_check_by_handle_or_email(normalized.as_str())
|
||||
.get_login_check_by_identifier(normalized.as_str())
|
||||
.await
|
||||
{
|
||||
Ok(Some(user)) => {
|
||||
@@ -401,7 +401,7 @@ pub async fn authorize_post(
|
||||
let user = match state
|
||||
.repos
|
||||
.user
|
||||
.get_login_info_by_handle_or_email(normalized_username.as_str())
|
||||
.get_login_info_by_identifier(normalized_username.as_str())
|
||||
.await
|
||||
{
|
||||
Ok(Some(u)) => u,
|
||||
@@ -410,7 +410,7 @@ pub async fn authorize_post(
|
||||
&form.password,
|
||||
"$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/X4.VTtYw1ZzQKZqmK",
|
||||
);
|
||||
return show_login_error("Invalid handle/email or password.", json_response);
|
||||
return show_login_error("Invalid identifier or password.", json_response);
|
||||
}
|
||||
Err(_) => return show_login_error("An error occurred. Please try again.", json_response),
|
||||
};
|
||||
@@ -486,7 +486,7 @@ pub async fn authorize_post(
|
||||
None => false,
|
||||
};
|
||||
if !password_valid {
|
||||
return show_login_error("Invalid handle/email or password.", json_response);
|
||||
return show_login_error("Invalid identifier or password.", json_response);
|
||||
}
|
||||
let is_verified = user.channel_verification.has_any_verified();
|
||||
if !is_verified {
|
||||
|
||||
@@ -22,7 +22,7 @@ pub async fn check_user_has_passkeys(
|
||||
let user = state
|
||||
.repos
|
||||
.user
|
||||
.get_login_check_by_handle_or_email(bare_identifier.as_str())
|
||||
.get_login_check_by_identifier(bare_identifier.as_str())
|
||||
.await;
|
||||
|
||||
let has_passkeys = match user {
|
||||
@@ -55,7 +55,7 @@ pub async fn check_user_security_status(
|
||||
let user = state
|
||||
.repos
|
||||
.user
|
||||
.get_login_check_by_handle_or_email(normalized_identifier.as_str())
|
||||
.get_login_check_by_identifier(normalized_identifier.as_str())
|
||||
.await;
|
||||
|
||||
let (has_passkeys, has_totp, has_password, is_delegated, did): (
|
||||
@@ -99,7 +99,7 @@ pub async fn check_user_security_status(
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PasskeyStartInput {
|
||||
pub request_uri: String,
|
||||
pub identifier: String,
|
||||
pub identifier: Option<String>,
|
||||
pub delegated_did: Option<String>,
|
||||
}
|
||||
|
||||
@@ -160,14 +160,91 @@ pub async fn passkey_start(
|
||||
.into_response();
|
||||
}
|
||||
|
||||
match form.identifier.filter(|s| !s.trim().is_empty()) {
|
||||
Some(identifier) => {
|
||||
passkey_start_named(
|
||||
state,
|
||||
identifier,
|
||||
form.delegated_did,
|
||||
request_data,
|
||||
passkey_start_request_id,
|
||||
)
|
||||
.await
|
||||
}
|
||||
None => passkey_start_discoverable(state, passkey_start_request_id).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn passkey_start_discoverable(
|
||||
state: AppState,
|
||||
request_id: RequestId,
|
||||
) -> Response {
|
||||
let (rcr, auth_state) = match state.webauthn_config.start_discoverable_authentication() {
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "Failed to start discoverable passkey authentication");
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"error": "server_error",
|
||||
"error_description": "Failed to start authentication."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let state_json = match serde_json::to_string(&auth_state) {
|
||||
Ok(j) => j,
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "Failed to serialize authentication state");
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"error": "server_error",
|
||||
"error_description": "An error occurred."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = state
|
||||
.repos
|
||||
.user
|
||||
.save_discoverable_challenge(request_id.as_str(), &state_json)
|
||||
.await
|
||||
{
|
||||
tracing::error!(error = %e, "Failed to save discoverable authentication state");
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"error": "server_error",
|
||||
"error_description": "An error occurred."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let options = serde_json::to_value(&rcr).unwrap_or(serde_json::json!({}));
|
||||
Json(PasskeyStartResponse { options }).into_response()
|
||||
}
|
||||
|
||||
async fn passkey_start_named(
|
||||
state: AppState,
|
||||
identifier: String,
|
||||
delegated_did: Option<String>,
|
||||
request_data: tranquil_pds::oauth::RequestData,
|
||||
passkey_start_request_id: RequestId,
|
||||
) -> Response {
|
||||
let hostname_for_handles = tranquil_config::get().server.hostname_without_port();
|
||||
let normalized_username =
|
||||
NormalizedLoginIdentifier::normalize(&form.identifier, hostname_for_handles);
|
||||
NormalizedLoginIdentifier::normalize(&identifier, hostname_for_handles);
|
||||
|
||||
let user = match state
|
||||
.repos
|
||||
.user
|
||||
.get_login_info_by_handle_or_email(normalized_username.as_str())
|
||||
.get_login_info_by_identifier(normalized_username.as_str())
|
||||
.await
|
||||
{
|
||||
Ok(Some(u)) => u,
|
||||
@@ -325,7 +402,7 @@ pub async fn passkey_start(
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let delegation_from_param = match &form.delegated_did {
|
||||
let delegation_from_param = match &delegated_did {
|
||||
Some(delegated_did_str) => match delegated_did_str.parse::<tranquil_types::Did>() {
|
||||
Ok(delegated_did) if delegated_did != user.did => {
|
||||
match state
|
||||
@@ -471,85 +548,6 @@ pub async fn passkey_finish(
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let did_str = match request_data.did {
|
||||
Some(d) => d,
|
||||
None => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "invalid_request",
|
||||
"error_description": "No passkey authentication in progress."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let did: tranquil_types::Did = match did_str.parse() {
|
||||
Ok(d) => d,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "invalid_request",
|
||||
"error_description": "Invalid DID format."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let controller_did: Option<tranquil_types::Did> = request_data
|
||||
.controller_did
|
||||
.as_ref()
|
||||
.and_then(|s| s.parse().ok());
|
||||
let passkey_owner_did = controller_did.as_ref().unwrap_or(&did);
|
||||
|
||||
let auth_state_json = match state
|
||||
.repos
|
||||
.user
|
||||
.load_webauthn_challenge(passkey_owner_did, WebauthnChallengeType::Authentication)
|
||||
.await
|
||||
{
|
||||
Ok(Some(s)) => s,
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "invalid_request",
|
||||
"error_description": "No passkey authentication in progress or challenge expired."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "Failed to load authentication state");
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"error": "server_error",
|
||||
"error_description": "An error occurred."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let auth_state: webauthn_rs::prelude::SecurityKeyAuthentication =
|
||||
match serde_json::from_str(&auth_state_json) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "Failed to deserialize authentication state");
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"error": "server_error",
|
||||
"error_description": "An error occurred."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let credential: webauthn_rs::prelude::PublicKeyCredential =
|
||||
match serde_json::from_value(form.credential) {
|
||||
Ok(c) => c,
|
||||
@@ -566,33 +564,35 @@ pub async fn passkey_finish(
|
||||
}
|
||||
};
|
||||
|
||||
let auth_result = match state
|
||||
.webauthn_config
|
||||
.finish_authentication(&credential, &auth_state)
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, did = %did, "Failed to verify passkey authentication");
|
||||
return (
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(serde_json::json!({
|
||||
"error": "access_denied",
|
||||
"error_description": "Passkey verification failed."
|
||||
})),
|
||||
let (did, auth_result) = match request_data.did.clone() {
|
||||
Some(did) => match passkey_finish_named(&state, did, &request_data, &credential).await {
|
||||
Ok(result) => result,
|
||||
Err(response) => return response,
|
||||
},
|
||||
None => {
|
||||
let result = match passkey_finish_discoverable(
|
||||
&state,
|
||||
&credential,
|
||||
&passkey_finish_request_id,
|
||||
)
|
||||
.into_response();
|
||||
.await
|
||||
{
|
||||
Ok(result) => result,
|
||||
Err(response) => return response,
|
||||
};
|
||||
if state
|
||||
.repos
|
||||
.oauth
|
||||
.set_authorization_did(&passkey_finish_request_id, &result.0, None)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return OAuthError::ServerError("An error occurred.".into()).into_response();
|
||||
}
|
||||
result
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = state
|
||||
.repos
|
||||
.user
|
||||
.delete_webauthn_challenge(passkey_owner_did, WebauthnChallengeType::Authentication)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(error = %e, "Failed to delete authentication state");
|
||||
}
|
||||
|
||||
if auth_result.needs_update() {
|
||||
let cred_id_bytes = auth_result.cred_id().as_slice();
|
||||
match state
|
||||
@@ -691,6 +691,187 @@ pub async fn passkey_finish(
|
||||
.into_response()
|
||||
}
|
||||
|
||||
async fn passkey_finish_named(
|
||||
state: &AppState,
|
||||
did: tranquil_types::Did,
|
||||
request_data: &tranquil_pds::oauth::RequestData,
|
||||
credential: &webauthn_rs::prelude::PublicKeyCredential,
|
||||
) -> Result<
|
||||
(
|
||||
tranquil_types::Did,
|
||||
webauthn_rs::prelude::AuthenticationResult,
|
||||
),
|
||||
Response,
|
||||
> {
|
||||
let passkey_owner_did = request_data.controller_did.as_ref().unwrap_or(&did);
|
||||
|
||||
let auth_state_json = state
|
||||
.repos
|
||||
.user
|
||||
.load_webauthn_challenge(passkey_owner_did, WebauthnChallengeType::Authentication)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "Failed to load authentication state");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": "server_error", "error_description": "An error occurred."})),
|
||||
).into_response()
|
||||
})?
|
||||
.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "invalid_request",
|
||||
"error_description": "No passkey authentication in progress or challenge expired."
|
||||
})),
|
||||
).into_response()
|
||||
})?;
|
||||
|
||||
let auth_state: webauthn_rs::prelude::SecurityKeyAuthentication =
|
||||
serde_json::from_str(&auth_state_json).map_err(|e| {
|
||||
tracing::error!(error = %e, "Failed to deserialize authentication state");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": "server_error", "error_description": "An error occurred."})),
|
||||
).into_response()
|
||||
})?;
|
||||
|
||||
let auth_result = state
|
||||
.webauthn_config
|
||||
.finish_authentication(credential, &auth_state)
|
||||
.map_err(|e| {
|
||||
tracing::warn!(error = %e, did = %did, "Failed to verify passkey authentication");
|
||||
(
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(serde_json::json!({
|
||||
"error": "access_denied",
|
||||
"error_description": "Passkey verification failed."
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
})?;
|
||||
|
||||
let _ = state
|
||||
.repos
|
||||
.user
|
||||
.delete_webauthn_challenge(passkey_owner_did, WebauthnChallengeType::Authentication)
|
||||
.await;
|
||||
|
||||
Ok((did, auth_result))
|
||||
}
|
||||
|
||||
async fn passkey_finish_discoverable(
|
||||
state: &AppState,
|
||||
credential: &webauthn_rs::prelude::PublicKeyCredential,
|
||||
request_id: &RequestId,
|
||||
) -> Result<
|
||||
(
|
||||
tranquil_types::Did,
|
||||
webauthn_rs::prelude::AuthenticationResult,
|
||||
),
|
||||
Response,
|
||||
> {
|
||||
let auth_state_json = state
|
||||
.repos
|
||||
.user
|
||||
.load_discoverable_challenge(request_id.as_str())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "Failed to load discoverable authentication state");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": "server_error", "error_description": "An error occurred."})),
|
||||
).into_response()
|
||||
})?
|
||||
.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "invalid_request",
|
||||
"error_description": "No passkey authentication in progress or challenge expired."
|
||||
})),
|
||||
).into_response()
|
||||
})?;
|
||||
|
||||
let auth_state: webauthn_rs::prelude::DiscoverableAuthentication =
|
||||
serde_json::from_str(&auth_state_json).map_err(|e| {
|
||||
tracing::error!(error = %e, "Failed to deserialize discoverable authentication state");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": "server_error", "error_description": "An error occurred."})),
|
||||
).into_response()
|
||||
})?;
|
||||
|
||||
let (_user_uuid, cred_id) = state
|
||||
.webauthn_config
|
||||
.identify_discoverable_authentication(credential)
|
||||
.map_err(|e| {
|
||||
tracing::warn!(error = %e, "Failed to identify discoverable credential");
|
||||
(
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(serde_json::json!({
|
||||
"error": "access_denied",
|
||||
"error_description": "Passkey verification failed."
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
})?;
|
||||
|
||||
let stored_passkey = state
|
||||
.repos
|
||||
.user
|
||||
.get_passkey_by_credential_id(cred_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "Failed to look up passkey by credential ID");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": "server_error", "error_description": "An error occurred."})),
|
||||
).into_response()
|
||||
})?
|
||||
.ok_or_else(|| {
|
||||
tracing::warn!("Discoverable credential not found in database");
|
||||
(
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(serde_json::json!({
|
||||
"error": "access_denied",
|
||||
"error_description": "Passkey not recognized."
|
||||
})),
|
||||
).into_response()
|
||||
})?;
|
||||
|
||||
let discoverable_key: webauthn_rs::prelude::DiscoverableKey =
|
||||
serde_json::from_slice(&stored_passkey.public_key).map_err(|e| {
|
||||
tracing::error!(error = %e, "Failed to deserialize stored passkey as DiscoverableKey");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": "server_error", "error_description": "An error occurred."})),
|
||||
).into_response()
|
||||
})?;
|
||||
|
||||
let auth_result = state
|
||||
.webauthn_config
|
||||
.finish_discoverable_authentication(credential, auth_state, &[discoverable_key])
|
||||
.map_err(|e| {
|
||||
tracing::warn!(error = %e, did = %stored_passkey.did, "Failed to verify discoverable passkey authentication");
|
||||
(
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(serde_json::json!({
|
||||
"error": "access_denied",
|
||||
"error_description": "Passkey verification failed."
|
||||
})),
|
||||
).into_response()
|
||||
})?;
|
||||
|
||||
let _ = state
|
||||
.repos
|
||||
.user
|
||||
.delete_discoverable_challenge(request_id.as_str())
|
||||
.await;
|
||||
|
||||
Ok((stored_passkey.did, auth_result))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AuthorizePasskeyQuery {
|
||||
pub request_uri: String,
|
||||
|
||||
@@ -78,6 +78,7 @@ tracing = { workspace = true }
|
||||
urlencoding = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
webauthn-rs = { workspace = true }
|
||||
webauthn-rs-proto = { workspace = true }
|
||||
zip = { workspace = true }
|
||||
aws-config = { workspace = true, optional = true }
|
||||
aws-sdk-s3 = { workspace = true, optional = true }
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
use uuid::Uuid;
|
||||
use webauthn_rs::prelude::*;
|
||||
use webauthn_rs_proto::{
|
||||
AuthenticatorSelectionCriteria, ResidentKeyRequirement, UserVerificationPolicy,
|
||||
};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum WebauthnError {
|
||||
@@ -57,6 +60,15 @@ impl WebAuthnConfig {
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.map(|(mut ccr, state)| {
|
||||
let sel = ccr
|
||||
.public_key
|
||||
.authenticator_selection
|
||||
.get_or_insert_with(AuthenticatorSelectionCriteria::default);
|
||||
sel.resident_key = Some(ResidentKeyRequirement::Required);
|
||||
sel.require_resident_key = true;
|
||||
(ccr, state)
|
||||
})
|
||||
.map_err(|e| WebauthnError::RegistrationFailed(e.to_string()))
|
||||
}
|
||||
|
||||
@@ -88,4 +100,49 @@ impl WebAuthnConfig {
|
||||
.finish_securitykey_authentication(auth, state)
|
||||
.map_err(|e| WebauthnError::AuthenticationFailed(e.to_string()))
|
||||
}
|
||||
|
||||
pub fn start_discoverable_authentication(
|
||||
&self,
|
||||
) -> Result<(RequestChallengeResponse, DiscoverableAuthentication), WebauthnError> {
|
||||
let (mut rcr, state) = self
|
||||
.webauthn
|
||||
.start_discoverable_authentication()
|
||||
.map_err(|e| WebauthnError::AuthenticationFailed(e.to_string()))?;
|
||||
|
||||
rcr.mediation = None;
|
||||
rcr.public_key.user_verification = UserVerificationPolicy::Discouraged_DO_NOT_USE;
|
||||
|
||||
let mut state_json = serde_json::to_value(&state)
|
||||
.map_err(|e| WebauthnError::AuthenticationFailed(e.to_string()))?;
|
||||
let ast = state_json
|
||||
.get_mut("ast")
|
||||
.ok_or_else(|| WebauthnError::AuthenticationFailed(
|
||||
"webauthn-rs DiscoverableAuthentication missing 'ast' field, library version incompatible".into(),
|
||||
))?;
|
||||
ast["policy"] = serde_json::json!("discouraged");
|
||||
let patched: DiscoverableAuthentication = serde_json::from_value(state_json)
|
||||
.map_err(|e| WebauthnError::AuthenticationFailed(e.to_string()))?;
|
||||
|
||||
Ok((rcr, patched))
|
||||
}
|
||||
|
||||
pub fn identify_discoverable_authentication<'a>(
|
||||
&self,
|
||||
credential: &'a PublicKeyCredential,
|
||||
) -> Result<(Uuid, &'a [u8]), WebauthnError> {
|
||||
self.webauthn
|
||||
.identify_discoverable_authentication(credential)
|
||||
.map_err(|e| WebauthnError::AuthenticationFailed(e.to_string()))
|
||||
}
|
||||
|
||||
pub fn finish_discoverable_authentication(
|
||||
&self,
|
||||
credential: &PublicKeyCredential,
|
||||
state: DiscoverableAuthentication,
|
||||
creds: &[DiscoverableKey],
|
||||
) -> Result<AuthenticationResult, WebauthnError> {
|
||||
self.webauthn
|
||||
.finish_discoverable_authentication(credential, state, creds)
|
||||
.map_err(|e| WebauthnError::AuthenticationFailed(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3487,13 +3487,13 @@ impl<S: StorageIO + 'static> tranquil_db_traits::UserRepository for MetastoreCli
|
||||
recv(rx).await
|
||||
}
|
||||
|
||||
async fn get_login_check_by_handle_or_email(
|
||||
async fn get_login_check_by_identifier(
|
||||
&self,
|
||||
identifier: &str,
|
||||
) -> Result<Option<UserLoginCheck>, DbError> {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
self.pool.send(MetastoreRequest::User(
|
||||
UserRequest::GetLoginCheckByHandleOrEmail {
|
||||
UserRequest::GetLoginCheckByIdentifier {
|
||||
identifier: identifier.to_owned(),
|
||||
tx,
|
||||
},
|
||||
@@ -3501,13 +3501,13 @@ impl<S: StorageIO + 'static> tranquil_db_traits::UserRepository for MetastoreCli
|
||||
recv(rx).await
|
||||
}
|
||||
|
||||
async fn get_login_info_by_handle_or_email(
|
||||
async fn get_login_info_by_identifier(
|
||||
&self,
|
||||
identifier: &str,
|
||||
) -> Result<Option<UserLoginInfo>, DbError> {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
self.pool.send(MetastoreRequest::User(
|
||||
UserRequest::GetLoginInfoByHandleOrEmail {
|
||||
UserRequest::GetLoginInfoByIdentifier {
|
||||
identifier: identifier.to_owned(),
|
||||
tx,
|
||||
},
|
||||
@@ -4233,6 +4233,47 @@ impl<S: StorageIO + 'static> tranquil_db_traits::UserRepository for MetastoreCli
|
||||
recv(rx).await
|
||||
}
|
||||
|
||||
async fn save_discoverable_challenge(
|
||||
&self,
|
||||
request_key: &str,
|
||||
state_json: &str,
|
||||
) -> Result<Uuid, DbError> {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
self.pool.send(MetastoreRequest::User(
|
||||
UserRequest::SaveDiscoverableChallenge {
|
||||
request_key: request_key.to_owned(),
|
||||
state_json: state_json.to_owned(),
|
||||
tx,
|
||||
},
|
||||
))?;
|
||||
recv(rx).await
|
||||
}
|
||||
|
||||
async fn load_discoverable_challenge(
|
||||
&self,
|
||||
request_key: &str,
|
||||
) -> Result<Option<String>, DbError> {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
self.pool.send(MetastoreRequest::User(
|
||||
UserRequest::LoadDiscoverableChallenge {
|
||||
request_key: request_key.to_owned(),
|
||||
tx,
|
||||
},
|
||||
))?;
|
||||
recv(rx).await
|
||||
}
|
||||
|
||||
async fn delete_discoverable_challenge(&self, request_key: &str) -> Result<(), DbError> {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
self.pool.send(MetastoreRequest::User(
|
||||
UserRequest::DeleteDiscoverableChallenge {
|
||||
request_key: request_key.to_owned(),
|
||||
tx,
|
||||
},
|
||||
))?;
|
||||
recv(rx).await
|
||||
}
|
||||
|
||||
async fn get_totp_record(&self, did: &Did) -> Result<Option<TotpRecord>, DbError> {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
self.pool
|
||||
|
||||
@@ -996,11 +996,11 @@ pub enum UserRequest {
|
||||
email: String,
|
||||
tx: Tx<Option<UserForVerification>>,
|
||||
},
|
||||
GetLoginCheckByHandleOrEmail {
|
||||
GetLoginCheckByIdentifier {
|
||||
identifier: String,
|
||||
tx: Tx<Option<UserLoginCheck>>,
|
||||
},
|
||||
GetLoginInfoByHandleOrEmail {
|
||||
GetLoginInfoByIdentifier {
|
||||
identifier: String,
|
||||
tx: Tx<Option<UserLoginInfo>>,
|
||||
},
|
||||
@@ -1273,6 +1273,19 @@ pub enum UserRequest {
|
||||
challenge_type: WebauthnChallengeType,
|
||||
tx: Tx<()>,
|
||||
},
|
||||
SaveDiscoverableChallenge {
|
||||
request_key: String,
|
||||
state_json: String,
|
||||
tx: Tx<Uuid>,
|
||||
},
|
||||
LoadDiscoverableChallenge {
|
||||
request_key: String,
|
||||
tx: Tx<Option<String>>,
|
||||
},
|
||||
DeleteDiscoverableChallenge {
|
||||
request_key: String,
|
||||
tx: Tx<()>,
|
||||
},
|
||||
GetTotpRecord {
|
||||
did: Did,
|
||||
tx: Tx<Option<TotpRecord>>,
|
||||
@@ -1726,8 +1739,8 @@ impl UserRequest {
|
||||
| Self::GetAnyAdminUserId { .. }
|
||||
| Self::SearchAccounts { .. }
|
||||
| Self::GetByEmail { .. }
|
||||
| Self::GetLoginCheckByHandleOrEmail { .. }
|
||||
| Self::GetLoginInfoByHandleOrEmail { .. }
|
||||
| Self::GetLoginCheckByIdentifier { .. }
|
||||
| Self::GetLoginInfoByIdentifier { .. }
|
||||
| Self::CheckEmailVerifiedByIdentifier { .. }
|
||||
| Self::StoreTelegramChatId { .. }
|
||||
| Self::StoreDiscordUserId { .. }
|
||||
@@ -1743,7 +1756,10 @@ impl UserRequest {
|
||||
| Self::CleanupExpiredHandleReservations { .. }
|
||||
| Self::CheckAndConsumeInviteCode { .. }
|
||||
| Self::GetPasswordResetInfo { .. }
|
||||
| Self::ExpirePasswordResetCode { .. } => Routing::Global,
|
||||
| Self::ExpirePasswordResetCode { .. }
|
||||
| Self::SaveDiscoverableChallenge { .. }
|
||||
| Self::LoadDiscoverableChallenge { .. }
|
||||
| Self::DeleteDiscoverableChallenge { .. } => Routing::Global,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5066,15 +5082,15 @@ fn dispatch_user<S: StorageIO + 'static>(state: &HandlerState<S>, req: UserReque
|
||||
UserRequest::GetByEmail { email, tx } => {
|
||||
let _ = tx.send(user.get_by_email(&email).map_err(metastore_to_db));
|
||||
}
|
||||
UserRequest::GetLoginCheckByHandleOrEmail { identifier, tx } => {
|
||||
UserRequest::GetLoginCheckByIdentifier { identifier, tx } => {
|
||||
let _ = tx.send(
|
||||
user.get_login_check_by_handle_or_email(&identifier)
|
||||
user.get_login_check_by_identifier(&identifier)
|
||||
.map_err(metastore_to_db),
|
||||
);
|
||||
}
|
||||
UserRequest::GetLoginInfoByHandleOrEmail { identifier, tx } => {
|
||||
UserRequest::GetLoginInfoByIdentifier { identifier, tx } => {
|
||||
let _ = tx.send(
|
||||
user.get_login_info_by_handle_or_email(&identifier)
|
||||
user.get_login_info_by_identifier(&identifier)
|
||||
.map_err(metastore_to_db),
|
||||
);
|
||||
}
|
||||
@@ -5434,6 +5450,28 @@ fn dispatch_user<S: StorageIO + 'static>(state: &HandlerState<S>, req: UserReque
|
||||
.map_err(metastore_to_db),
|
||||
);
|
||||
}
|
||||
UserRequest::SaveDiscoverableChallenge {
|
||||
request_key,
|
||||
state_json,
|
||||
tx,
|
||||
} => {
|
||||
let _ = tx.send(
|
||||
user.save_discoverable_challenge(&request_key, &state_json)
|
||||
.map_err(metastore_to_db),
|
||||
);
|
||||
}
|
||||
UserRequest::LoadDiscoverableChallenge { request_key, tx } => {
|
||||
let _ = tx.send(
|
||||
user.load_discoverable_challenge(&request_key)
|
||||
.map_err(metastore_to_db),
|
||||
);
|
||||
}
|
||||
UserRequest::DeleteDiscoverableChallenge { request_key, tx } => {
|
||||
let _ = tx.send(
|
||||
user.delete_discoverable_challenge(&request_key)
|
||||
.map_err(metastore_to_db),
|
||||
);
|
||||
}
|
||||
UserRequest::GetTotpRecord { did, tx } => {
|
||||
let _ = tx.send(user.get_totp_record(&did).map_err(metastore_to_db));
|
||||
}
|
||||
|
||||
@@ -125,15 +125,9 @@ impl UserOps {
|
||||
}
|
||||
|
||||
fn load_by_identifier(&self, identifier: &str) -> Result<Option<UserValue>, MetastoreError> {
|
||||
match identifier.contains('@') {
|
||||
true => self.load_by_email(identifier).and_then(|opt| match opt {
|
||||
Some(v) => Ok(Some(v)),
|
||||
None => self.load_by_handle(identifier),
|
||||
}),
|
||||
false => self.load_by_handle(identifier).and_then(|opt| match opt {
|
||||
Some(v) => Ok(Some(v)),
|
||||
None => self.load_by_email(identifier),
|
||||
}),
|
||||
match identifier.starts_with("did:") {
|
||||
true => self.load_user_by_did(identifier),
|
||||
false => self.load_by_handle(identifier),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -472,7 +466,7 @@ impl UserOps {
|
||||
.transpose()
|
||||
}
|
||||
|
||||
pub fn get_login_check_by_handle_or_email(
|
||||
pub fn get_login_check_by_identifier(
|
||||
&self,
|
||||
identifier: &str,
|
||||
) -> Result<Option<UserLoginCheck>, MetastoreError> {
|
||||
@@ -487,7 +481,7 @@ impl UserOps {
|
||||
.transpose()
|
||||
}
|
||||
|
||||
pub fn get_login_info_by_handle_or_email(
|
||||
pub fn get_login_info_by_identifier(
|
||||
&self,
|
||||
identifier: &str,
|
||||
) -> Result<Option<UserLoginInfo>, MetastoreError> {
|
||||
@@ -1511,6 +1505,57 @@ impl UserOps {
|
||||
.map_err(MetastoreError::Fjall)
|
||||
}
|
||||
|
||||
const DISCOVERABLE_CHALLENGE_TYPE: u8 = 2;
|
||||
|
||||
pub fn save_discoverable_challenge(
|
||||
&self,
|
||||
request_key: &str,
|
||||
state_json: &str,
|
||||
) -> Result<Uuid, MetastoreError> {
|
||||
let key_hash = UserHash::from_did(request_key);
|
||||
let id = Uuid::new_v4();
|
||||
let now_ms = Utc::now().timestamp_millis();
|
||||
|
||||
let value = WebauthnChallengeValue {
|
||||
id,
|
||||
challenge_type: Self::DISCOVERABLE_CHALLENGE_TYPE,
|
||||
state_json: state_json.to_owned(),
|
||||
created_at_ms: now_ms,
|
||||
};
|
||||
|
||||
let key = webauthn_challenge_key(key_hash, Self::DISCOVERABLE_CHALLENGE_TYPE);
|
||||
self.auth
|
||||
.insert(key.as_slice(), value.serialize_with_ttl())
|
||||
.map_err(MetastoreError::Fjall)?;
|
||||
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
pub fn load_discoverable_challenge(
|
||||
&self,
|
||||
request_key: &str,
|
||||
) -> Result<Option<String>, MetastoreError> {
|
||||
let key_hash = UserHash::from_did(request_key);
|
||||
let key = webauthn_challenge_key(key_hash, Self::DISCOVERABLE_CHALLENGE_TYPE);
|
||||
|
||||
let val: Option<WebauthnChallengeValue> = point_lookup(
|
||||
&self.auth,
|
||||
key.as_slice(),
|
||||
WebauthnChallengeValue::deserialize,
|
||||
"corrupt webauthn challenge",
|
||||
)?;
|
||||
|
||||
Ok(val.map(|v| v.state_json))
|
||||
}
|
||||
|
||||
pub fn delete_discoverable_challenge(&self, request_key: &str) -> Result<(), MetastoreError> {
|
||||
let key_hash = UserHash::from_did(request_key);
|
||||
let key = webauthn_challenge_key(key_hash, Self::DISCOVERABLE_CHALLENGE_TYPE);
|
||||
self.auth
|
||||
.remove(key.as_slice())
|
||||
.map_err(MetastoreError::Fjall)
|
||||
}
|
||||
|
||||
pub fn get_totp_record(&self, did: &Did) -> Result<Option<TotpRecord>, MetastoreError> {
|
||||
let user_hash = self.resolve_hash(did.as_str());
|
||||
let key = totp_key(user_hash);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts" module>
|
||||
const EXAMPLE_HANDLES = [
|
||||
"nel.pet",
|
||||
"lewis.moe",
|
||||
"oyster.cafe",
|
||||
"llaama.bsky.social",
|
||||
"debugman.wizardry.systems",
|
||||
"nonbinary.computer",
|
||||
|
||||
@@ -15,9 +15,10 @@
|
||||
session: Session
|
||||
hasPassword: boolean
|
||||
onPasskeysChanged?: (count: number) => void
|
||||
onReauthRequired: (methods: string[], retryAction: () => Promise<void>) => void
|
||||
}
|
||||
|
||||
let { session, hasPassword, onPasskeysChanged }: Props = $props()
|
||||
let { session, hasPassword, onPasskeysChanged, onReauthRequired }: Props = $props()
|
||||
|
||||
interface Passkey {
|
||||
id: string
|
||||
@@ -81,6 +82,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
function handleReauthError(e: unknown, fallback: string, retryAction: () => Promise<void>) {
|
||||
if (e instanceof ApiError && e.error === 'ReauthRequired') {
|
||||
onReauthRequired(e.reauthMethods || ['password'], retryAction)
|
||||
} else {
|
||||
toast.error(e instanceof ApiError ? e.message : fallback)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeletePasskey(id: string) {
|
||||
const passkey = passkeys.find(p => p.id === id)
|
||||
if (!confirm($_('security.deletePasskeyConfirm', { values: { name: passkey?.friendlyName || 'this passkey' } }))) return
|
||||
@@ -89,7 +98,7 @@
|
||||
await loadPasskeys()
|
||||
toast.success($_('security.passkeyDeleted'))
|
||||
} catch (e) {
|
||||
toast.error(e instanceof ApiError ? e.message : 'Failed to delete passkey')
|
||||
handleReauthError(e, 'Failed to delete passkey', () => handleDeletePasskey(id))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -282,6 +282,7 @@
|
||||
{session}
|
||||
{hasPassword}
|
||||
onPasskeysChanged={(count) => passkeyCount = count}
|
||||
onReauthRequired={handleReauthRequired}
|
||||
/>
|
||||
|
||||
<TotpSection
|
||||
|
||||
@@ -522,6 +522,7 @@
|
||||
"passkeyHintNotAvailable": "No passkey registered",
|
||||
"passwordPlaceholder": "Password",
|
||||
"usePasskey": "Use passkey",
|
||||
"passkeyNotAllowed": "No passkey available for this site",
|
||||
"orUseCredentials": "or",
|
||||
"verificationResent": "Verification code sent"
|
||||
},
|
||||
|
||||
@@ -509,6 +509,7 @@
|
||||
"passkeyHintNotAvailable": "Ei pääsyavainta",
|
||||
"passwordPlaceholder": "Salasana",
|
||||
"usePasskey": "Käytä pääsyavainta",
|
||||
"passkeyNotAllowed": "Pääsyavainta ei ole saatavilla tälle sivustolle",
|
||||
"orUseCredentials": "tai",
|
||||
"verificationResent": "Vahvistuskoodi lähetetty"
|
||||
},
|
||||
|
||||
@@ -509,6 +509,7 @@
|
||||
"passkeyHintNotAvailable": "パスキーなし",
|
||||
"passwordPlaceholder": "パスワード",
|
||||
"usePasskey": "パスキーを使用",
|
||||
"passkeyNotAllowed": "このサイトで利用可能なパスキーがありません",
|
||||
"orUseCredentials": "または",
|
||||
"verificationResent": "確認コードを送信しました"
|
||||
},
|
||||
|
||||
@@ -509,6 +509,7 @@
|
||||
"passkeyHintNotAvailable": "패스키 없음",
|
||||
"passwordPlaceholder": "비밀번호",
|
||||
"usePasskey": "패스키 사용",
|
||||
"passkeyNotAllowed": "이 사이트에 사용 가능한 패스키가 없습니다",
|
||||
"orUseCredentials": "또는",
|
||||
"verificationResent": "인증 코드 전송됨"
|
||||
},
|
||||
|
||||
@@ -509,6 +509,7 @@
|
||||
"passkeyHintNotAvailable": "Ingen nyckel registrerad",
|
||||
"passwordPlaceholder": "Lösenord",
|
||||
"usePasskey": "Använd nyckel",
|
||||
"passkeyNotAllowed": "Ingen nyckel tillgänglig för denna webbplats",
|
||||
"orUseCredentials": "eller",
|
||||
"verificationResent": "Verifieringskod skickad"
|
||||
},
|
||||
|
||||
@@ -509,6 +509,7 @@
|
||||
"passkeyHintNotAvailable": "未注册通行密钥",
|
||||
"passwordPlaceholder": "密码",
|
||||
"usePasskey": "使用通行密钥",
|
||||
"passkeyNotAllowed": "此站点没有可用的通行密钥",
|
||||
"orUseCredentials": "或",
|
||||
"verificationResent": "验证码已发送"
|
||||
},
|
||||
|
||||
@@ -38,13 +38,8 @@
|
||||
let submitting = $state(false)
|
||||
let error = $state<string | null>(null)
|
||||
let verificationResent = $state(false)
|
||||
let hasPasskeys = $state(false)
|
||||
let hasTotp = $state(false)
|
||||
let hasPassword = $state(true)
|
||||
let isDelegated = $state(false)
|
||||
let userDid = $state<string | null>(null)
|
||||
let checkingSecurityStatus = $state(false)
|
||||
let securityStatusChecked = $state(false)
|
||||
let passkeySupported = $state(false)
|
||||
let clientName = $state<string | null>(null)
|
||||
|
||||
@@ -160,31 +155,27 @@
|
||||
|
||||
let checkTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
let checkingDelegation = false
|
||||
|
||||
$effect(() => {
|
||||
if (checkTimeout) {
|
||||
clearTimeout(checkTimeout)
|
||||
}
|
||||
hasPasskeys = false
|
||||
hasTotp = false
|
||||
securityStatusChecked = false
|
||||
isDelegated = false
|
||||
if (username.length >= 3) {
|
||||
checkTimeout = setTimeout(() => checkUserSecurityStatus(), 500)
|
||||
checkTimeout = setTimeout(() => checkDelegationStatus(), 500)
|
||||
}
|
||||
})
|
||||
|
||||
async function checkUserSecurityStatus() {
|
||||
if (!username || checkingSecurityStatus) return
|
||||
checkingSecurityStatus = true
|
||||
async function checkDelegationStatus() {
|
||||
if (!username || checkingDelegation) return
|
||||
checkingDelegation = true
|
||||
try {
|
||||
const response = await fetch(`/oauth/security-status?identifier=${encodeURIComponent(username)}`)
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
hasPasskeys = passkeySupported && data.hasPasskeys === true
|
||||
hasTotp = data.hasTotp === true
|
||||
hasPassword = data.hasPassword !== false
|
||||
isDelegated = data.isDelegated === true
|
||||
userDid = data.did || null
|
||||
securityStatusChecked = true
|
||||
|
||||
if (isDelegated && data.did) {
|
||||
const requestUri = getRequestUri()
|
||||
@@ -198,19 +189,16 @@
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
hasPasskeys = false
|
||||
hasTotp = false
|
||||
hasPassword = true
|
||||
isDelegated = false
|
||||
} finally {
|
||||
checkingSecurityStatus = false
|
||||
checkingDelegation = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function handlePasskeyLogin() {
|
||||
const requestUri = getRequestUri()
|
||||
if (!requestUri || !username) {
|
||||
if (!requestUri) {
|
||||
error = $_('common.error')
|
||||
return
|
||||
}
|
||||
@@ -220,16 +208,18 @@
|
||||
verificationResent = false
|
||||
|
||||
try {
|
||||
const body: Record<string, string> = { request_uri: requestUri }
|
||||
if (username.trim()) {
|
||||
body.identifier = username
|
||||
}
|
||||
|
||||
const startResponse = await fetch('/oauth/passkey/start', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
request_uri: requestUri,
|
||||
identifier: username
|
||||
})
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
|
||||
if (!startResponse.ok) {
|
||||
@@ -306,9 +296,9 @@
|
||||
} catch (e) {
|
||||
console.error('Passkey login error:', e)
|
||||
if (e instanceof DOMException && e.name === 'NotAllowedError') {
|
||||
error = $_('common.error')
|
||||
error = $_('oauth.login.passkeyNotAllowed')
|
||||
} else {
|
||||
error = `${$_('common.error')}: ${e instanceof Error ? e.message : String(e)}`
|
||||
error = e instanceof Error ? e.message : String(e)
|
||||
}
|
||||
submitting = false
|
||||
}
|
||||
@@ -439,17 +429,15 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if passkeySupported && username.length >= 3}
|
||||
<div class="auth-methods" class:single-method={!hasPassword}>
|
||||
{#if passkeySupported}
|
||||
<div class="auth-methods">
|
||||
<div class="passkey-method">
|
||||
<h3>{$_('oauth.login.signInWithPasskey')}</h3>
|
||||
<button
|
||||
type="button"
|
||||
style="width: 100%"
|
||||
class:passkey-unavailable={!hasPasskeys || checkingSecurityStatus || !securityStatusChecked}
|
||||
onclick={handlePasskeyLogin}
|
||||
disabled={submitting || !hasPasskeys || !username || checkingSecurityStatus || !securityStatusChecked}
|
||||
title={checkingSecurityStatus ? $_('oauth.login.passkeyHintChecking') : hasPasskeys ? $_('oauth.login.passkeyHintAvailable') : $_('oauth.login.passkeyHintNotAvailable')}
|
||||
disabled={submitting}
|
||||
>
|
||||
<svg class="passkey-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M15 7a4 4 0 1 0-8 0 4 4 0 0 0 8 0z" />
|
||||
@@ -459,80 +447,72 @@
|
||||
<span class="passkey-text">
|
||||
{#if submitting}
|
||||
{$_('oauth.login.authenticating')}
|
||||
{:else if checkingSecurityStatus || !securityStatusChecked}
|
||||
{$_('oauth.login.checkingPasskey')}
|
||||
{:else if hasPasskeys}
|
||||
{$_('oauth.login.usePasskey')}
|
||||
{:else}
|
||||
{$_('oauth.login.passkeyNotSetUp')}
|
||||
{$_('oauth.login.usePasskey')}
|
||||
{/if}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if hasPassword}
|
||||
<div class="method-divider">
|
||||
<span>{$_('oauth.login.orUsePassword')}</span>
|
||||
<div class="method-divider">
|
||||
<span>{$_('oauth.login.orUsePassword')}</span>
|
||||
</div>
|
||||
|
||||
<div class="password-method">
|
||||
<h3>{$_('oauth.login.password')}</h3>
|
||||
<div class="field">
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
bind:value={password}
|
||||
disabled={submitting}
|
||||
required
|
||||
autocomplete="current-password"
|
||||
placeholder={$_('oauth.login.passwordPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="password-method">
|
||||
<h3>{$_('oauth.login.password')}</h3>
|
||||
<div class="field">
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
bind:value={password}
|
||||
disabled={submitting}
|
||||
required
|
||||
autocomplete="current-password"
|
||||
placeholder={$_('oauth.login.passwordPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<label class="remember-device">
|
||||
<input type="checkbox" bind:checked={rememberDevice} disabled={submitting} />
|
||||
<span>{$_('oauth.login.rememberDevice')}</span>
|
||||
</label>
|
||||
|
||||
<label class="remember-device">
|
||||
<input type="checkbox" bind:checked={rememberDevice} disabled={submitting} />
|
||||
<span>{$_('oauth.login.rememberDevice')}</span>
|
||||
</label>
|
||||
|
||||
<div class="actions">
|
||||
<button type="button" class="ghost sm" onclick={handleCancel} disabled={submitting}>
|
||||
{$_('common.cancel')}
|
||||
</button>
|
||||
<button type="submit" disabled={submitting || !username || !password}>
|
||||
{submitting ? $_('oauth.login.signingIn') : $_('oauth.login.title')}
|
||||
</button>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="button" class="ghost sm" onclick={handleCancel} disabled={submitting}>
|
||||
{$_('common.cancel')}
|
||||
</button>
|
||||
<button type="submit" disabled={submitting || !username || !password}>
|
||||
{submitting ? $_('oauth.login.signingIn') : $_('oauth.login.title')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
{#if hasPassword || !securityStatusChecked}
|
||||
<div>
|
||||
<label for="password">{$_('oauth.login.password')}</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
bind:value={password}
|
||||
disabled={submitting}
|
||||
required
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="password">{$_('oauth.login.password')}</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
bind:value={password}
|
||||
disabled={submitting}
|
||||
required
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label class="remember-device">
|
||||
<input type="checkbox" bind:checked={rememberDevice} disabled={submitting} />
|
||||
<span>{$_('oauth.login.rememberDevice')}</span>
|
||||
</label>
|
||||
<label class="remember-device">
|
||||
<input type="checkbox" bind:checked={rememberDevice} disabled={submitting} />
|
||||
<span>{$_('oauth.login.rememberDevice')}</span>
|
||||
</label>
|
||||
|
||||
<div class="actions">
|
||||
<button type="button" class="ghost sm" onclick={handleCancel} disabled={submitting}>
|
||||
{$_('common.cancel')}
|
||||
</button>
|
||||
<button type="submit" disabled={submitting || !username || !password}>
|
||||
{submitting ? $_('oauth.login.signingIn') : $_('oauth.login.title')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="actions">
|
||||
<button type="button" class="ghost sm" onclick={handleCancel} disabled={submitting}>
|
||||
{$_('common.cancel')}
|
||||
</button>
|
||||
<button type="submit" disabled={submitting || !username || !password}>
|
||||
{submitting ? $_('oauth.login.signingIn') : $_('oauth.login.title')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</form>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user