mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-09-03 08:46:55 +00:00
fix: ability to send more verifications
This commit is contained in:
+58
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT id, did, preferred_comms_channel as \"preferred_comms_channel: CommsChannel\", recovery_token, recovery_token_expires_at FROM users WHERE did = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "did",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "preferred_comms_channel: CommsChannel",
|
||||
"type_info": {
|
||||
"Custom": {
|
||||
"name": "comms_channel",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"email",
|
||||
"discord",
|
||||
"telegram",
|
||||
"signal"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "recovery_token",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "recovery_token_expires_at",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "85882f1c27888b695582395b798b8e4994ed1d761a598f938f2271e5ba320eea"
|
||||
}
|
||||
-40
@@ -1,40 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT id, did, recovery_token, recovery_token_expires_at FROM users WHERE did = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "did",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "recovery_token",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "recovery_token_expires_at",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "a10a29aee170a54af2ddbd59cf989a2910508b9f7e6f60465dd4cb5c7a79d848"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n UPDATE oauth_authorization_request\n SET expires_at = $2\n WHERE id = $1 AND did IS NOT NULL AND code IS NULL\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Timestamptz"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "bd5861c7ed2021d025e78d63ef6a35b2bb07d2c11f88f3945fbcf099b9c7c1cf"
|
||||
}
|
||||
-28
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT id, password_reset_code_expires_at FROM users WHERE password_reset_code = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "password_reset_code_expires_at",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "e3e4b6131b7692edf87fcf3b67b59127d3a218afb7a34a4bcb3c56765f8cd4c6"
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT id, did, preferred_comms_channel as \"preferred_comms_channel: CommsChannel\", password_reset_code_expires_at FROM users WHERE password_reset_code = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "did",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "preferred_comms_channel: CommsChannel",
|
||||
"type_info": {
|
||||
"Custom": {
|
||||
"name": "comms_channel",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"email",
|
||||
"discord",
|
||||
"telegram",
|
||||
"signal"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "password_reset_code_expires_at",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "eb3029b84fb58576a94987da1984cbe152f5ee7aa55b2b3f678603fe1e1c906b"
|
||||
}
|
||||
@@ -265,8 +265,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn token_type_accepts_bluesky_uppercase_jwt() {
|
||||
let result: Result<Header, _> =
|
||||
serde_json::from_str(r#"{"alg":"ES256K","typ":"JWT"}"#);
|
||||
let result: Result<Header, _> = serde_json::from_str(r#"{"alg":"ES256K","typ":"JWT"}"#);
|
||||
let header = result.expect("should parse uppercase JWT from bluesky reference pds");
|
||||
assert_eq!(header.typ, TokenType::Service);
|
||||
assert_eq!(header.alg, SigningAlgorithm::ES256K);
|
||||
@@ -274,8 +273,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn token_type_accepts_lowercase_jwt() {
|
||||
let result: Result<Header, _> =
|
||||
serde_json::from_str(r#"{"alg":"ES256K","typ":"jwt"}"#);
|
||||
let result: Result<Header, _> = serde_json::from_str(r#"{"alg":"ES256K","typ":"jwt"}"#);
|
||||
let header = result.expect("should parse lowercase jwt");
|
||||
assert_eq!(header.typ, TokenType::Service);
|
||||
}
|
||||
@@ -294,8 +292,17 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn signing_algorithm_case_insensitive() {
|
||||
assert_eq!(SigningAlgorithm::from_str("ES256K").unwrap(), SigningAlgorithm::ES256K);
|
||||
assert_eq!(SigningAlgorithm::from_str("es256k").unwrap(), SigningAlgorithm::ES256K);
|
||||
assert_eq!(SigningAlgorithm::from_str("hs256").unwrap(), SigningAlgorithm::HS256);
|
||||
assert_eq!(
|
||||
SigningAlgorithm::from_str("ES256K").unwrap(),
|
||||
SigningAlgorithm::ES256K
|
||||
);
|
||||
assert_eq!(
|
||||
SigningAlgorithm::from_str("es256k").unwrap(),
|
||||
SigningAlgorithm::ES256K
|
||||
);
|
||||
assert_eq!(
|
||||
SigningAlgorithm::from_str("hs256").unwrap(),
|
||||
SigningAlgorithm::HS256
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,35 @@ pub fn try_get() -> Option<&'static TranquilConfig> {
|
||||
CONFIG.get()
|
||||
}
|
||||
|
||||
/// Initialize with minimal defaults for unit tests.
|
||||
/// Noop if already initialized.
|
||||
pub fn ensure_test_defaults() {
|
||||
use std::env;
|
||||
let _ = CONFIG.get_or_init(|| {
|
||||
unsafe {
|
||||
if env::var("PDS_HOSTNAME").is_err() {
|
||||
env::set_var("PDS_HOSTNAME", "test.local");
|
||||
}
|
||||
if env::var("DATABASE_URL").is_err() {
|
||||
env::set_var("DATABASE_URL", "postgres://localhost/test");
|
||||
}
|
||||
if env::var("TRANQUIL_PDS_ALLOW_INSECURE_SECRETS").is_err() {
|
||||
env::set_var("TRANQUIL_PDS_ALLOW_INSECURE_SECRETS", "1");
|
||||
}
|
||||
if env::var("INVITE_CODE_REQUIRED").is_err() {
|
||||
env::set_var("INVITE_CODE_REQUIRED", "false");
|
||||
}
|
||||
if env::var("ENABLE_PDS_HOSTED_DID_WEB").is_err() {
|
||||
env::set_var("ENABLE_PDS_HOSTED_DID_WEB", "true");
|
||||
}
|
||||
}
|
||||
TranquilConfig::builder()
|
||||
.env()
|
||||
.load()
|
||||
.expect("failed to load test config defaults")
|
||||
});
|
||||
}
|
||||
|
||||
/// Load configuration from an optional TOML file path, with environment
|
||||
/// variable overrides applied on top. Fields annotated with `#[config(env)]`
|
||||
/// are read from the corresponding environment variables when the `.env()`
|
||||
|
||||
@@ -192,6 +192,11 @@ pub trait OAuthRepository: Send + Sync {
|
||||
) -> Result<Option<RequestData>, DbError>;
|
||||
async fn delete_authorization_request(&self, request_id: &RequestId) -> Result<(), DbError>;
|
||||
async fn delete_expired_authorization_requests(&self) -> Result<u64, DbError>;
|
||||
async fn extend_authorization_request_expiry(
|
||||
&self,
|
||||
request_id: &RequestId,
|
||||
new_expires_at: DateTime<Utc>,
|
||||
) -> Result<bool, DbError>;
|
||||
async fn mark_request_authenticated(
|
||||
&self,
|
||||
request_id: &RequestId,
|
||||
|
||||
@@ -886,6 +886,8 @@ pub struct UserResendVerification {
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UserResetCodeInfo {
|
||||
pub id: Uuid,
|
||||
pub did: Did,
|
||||
pub preferred_comms_channel: CommsChannel,
|
||||
pub expires_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
@@ -956,6 +958,7 @@ pub struct UserForPasskeyRecovery {
|
||||
pub struct UserForRecovery {
|
||||
pub id: Uuid,
|
||||
pub did: Did,
|
||||
pub preferred_comms_channel: CommsChannel,
|
||||
pub recovery_token: Option<String>,
|
||||
pub recovery_token_expires_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
@@ -615,6 +615,26 @@ impl OAuthRepository for PostgresOAuthRepository {
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
|
||||
async fn extend_authorization_request_expiry(
|
||||
&self,
|
||||
request_id: &RequestId,
|
||||
new_expires_at: DateTime<Utc>,
|
||||
) -> Result<bool, DbError> {
|
||||
let result = sqlx::query!(
|
||||
r#"
|
||||
UPDATE oauth_authorization_request
|
||||
SET expires_at = $2
|
||||
WHERE id = $1 AND did IS NOT NULL AND code IS NULL
|
||||
"#,
|
||||
request_id.as_str(),
|
||||
new_expires_at
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
async fn mark_request_authenticated(
|
||||
&self,
|
||||
request_id: &RequestId,
|
||||
|
||||
@@ -1715,7 +1715,7 @@ impl UserRepository for PostgresUserRepository {
|
||||
code: &str,
|
||||
) -> Result<Option<UserResetCodeInfo>, DbError> {
|
||||
sqlx::query!(
|
||||
"SELECT id, password_reset_code_expires_at FROM users WHERE password_reset_code = $1",
|
||||
"SELECT id, did, preferred_comms_channel as \"preferred_comms_channel: CommsChannel\", password_reset_code_expires_at FROM users WHERE password_reset_code = $1",
|
||||
code
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
@@ -1724,6 +1724,8 @@ impl UserRepository for PostgresUserRepository {
|
||||
.map(|opt| {
|
||||
opt.map(|row| UserResetCodeInfo {
|
||||
id: row.id,
|
||||
did: Did::from(row.did),
|
||||
preferred_comms_channel: row.preferred_comms_channel,
|
||||
expires_at: row.password_reset_code_expires_at,
|
||||
})
|
||||
})
|
||||
@@ -2202,7 +2204,7 @@ impl UserRepository for PostgresUserRepository {
|
||||
|
||||
async fn get_user_for_recovery(&self, did: &Did) -> Result<Option<UserForRecovery>, DbError> {
|
||||
let row = sqlx::query!(
|
||||
"SELECT id, did, recovery_token, recovery_token_expires_at FROM users WHERE did = $1",
|
||||
"SELECT id, did, preferred_comms_channel as \"preferred_comms_channel: CommsChannel\", recovery_token, recovery_token_expires_at FROM users WHERE did = $1",
|
||||
did.as_str()
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
@@ -2212,6 +2214,7 @@ impl UserRepository for PostgresUserRepository {
|
||||
Ok(row.map(|r| UserForRecovery {
|
||||
id: r.id,
|
||||
did: Did::from(r.did),
|
||||
preferred_comms_channel: r.preferred_comms_channel,
|
||||
recovery_token: r.recovery_token,
|
||||
recovery_token_expires_at: r.recovery_token_expires_at,
|
||||
}))
|
||||
|
||||
@@ -63,7 +63,7 @@ pub fn is_ssrf_safe(url: &str) -> Result<(), SsrfError> {
|
||||
let parsed = Url::parse(url).map_err(|_| SsrfError::InvalidUrl)?;
|
||||
let scheme = parsed.scheme();
|
||||
if scheme != "https" {
|
||||
let allow_http = tranquil_config::get().server.allow_http_proxy
|
||||
let allow_http = tranquil_config::try_get().is_some_and(|c| c.server.allow_http_proxy)
|
||||
|| url.starts_with("http://127.0.0.1")
|
||||
|| url.starts_with("http://localhost");
|
||||
if !allow_http {
|
||||
|
||||
@@ -100,7 +100,14 @@ pub async fn import_repo(
|
||||
commit_did, did
|
||||
)));
|
||||
}
|
||||
let skip_verification = tranquil_config::get().import.skip_verification;
|
||||
let skip_verification = std::env::var("SKIP_IMPORT_VERIFICATION")
|
||||
.ok()
|
||||
.map(|v| v == "true" || v == "1")
|
||||
.unwrap_or_else(|| {
|
||||
tranquil_config::try_get()
|
||||
.map(|c| c.import.skip_verification)
|
||||
.unwrap_or(false)
|
||||
});
|
||||
let is_migration = user.deactivated_at.is_some();
|
||||
if skip_verification {
|
||||
warn!("Skipping all CAR verification for import (SKIP_IMPORT_VERIFICATION=true)");
|
||||
|
||||
@@ -50,9 +50,9 @@ pub use reauth::{
|
||||
};
|
||||
pub use service_auth::get_service_auth;
|
||||
pub use session::{
|
||||
confirm_signup, create_session, delete_session, get_legacy_login_preference, get_session,
|
||||
list_sessions, refresh_session, resend_verification, revoke_all_sessions, revoke_session,
|
||||
update_legacy_login_preference, update_locale,
|
||||
auto_resend_verification, confirm_signup, create_session, delete_session,
|
||||
get_legacy_login_preference, get_session, list_sessions, refresh_session, resend_verification,
|
||||
revoke_all_sessions, revoke_session, update_legacy_login_preference, update_locale,
|
||||
};
|
||||
pub use signing_key::reserve_signing_key;
|
||||
pub use totp::{
|
||||
|
||||
@@ -946,6 +946,20 @@ pub async fn recover_passkey_account(
|
||||
if result.passkeys_deleted > 0 {
|
||||
info!(did = %input.did, count = result.passkeys_deleted, "Deleted lost passkeys during account recovery");
|
||||
}
|
||||
if let Ok(Some(prefs)) = state.user_repo.get_comms_prefs(user.id).await {
|
||||
let actual_channel =
|
||||
crate::comms::resolve_delivery_channel(&prefs, user.preferred_comms_channel);
|
||||
if let Err(e) = state
|
||||
.user_repo
|
||||
.set_channel_verified(&input.did, actual_channel)
|
||||
.await
|
||||
{
|
||||
warn!(
|
||||
"Failed to implicitly verify channel on passkey recovery: {:?}",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
info!(did = %input.did, "Passkey-only account recovered with temporary password");
|
||||
SuccessResponse::ok().into_response()
|
||||
}
|
||||
|
||||
@@ -182,6 +182,20 @@ pub async fn reset_password(
|
||||
}
|
||||
}))
|
||||
.await;
|
||||
if let Ok(Some(prefs)) = state.user_repo.get_comms_prefs(user_id).await {
|
||||
let actual_channel =
|
||||
crate::comms::resolve_delivery_channel(&prefs, user.preferred_comms_channel);
|
||||
if let Err(e) = state
|
||||
.user_repo
|
||||
.set_channel_verified(&user.did, actual_channel)
|
||||
.await
|
||||
{
|
||||
warn!(
|
||||
"Failed to implicitly verify channel on password reset: {:?}",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
info!("Password reset completed for user {}", user_id);
|
||||
EmptyResponse::ok().into_response()
|
||||
}
|
||||
|
||||
@@ -149,12 +149,23 @@ pub async fn create_session(
|
||||
.unwrap_or(false);
|
||||
if !is_verified && !is_delegated {
|
||||
warn!("Login attempt for unverified account: {}", row.did);
|
||||
let resend_info = auto_resend_verification(&state, &row.did).await;
|
||||
let handle = resend_info
|
||||
.as_ref()
|
||||
.map(|r| r.handle.to_string())
|
||||
.unwrap_or_else(|| row.handle.to_string());
|
||||
let channel = resend_info
|
||||
.as_ref()
|
||||
.map(|r| r.channel.as_str())
|
||||
.unwrap_or(row.preferred_comms_channel.as_str());
|
||||
return (
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(json!({
|
||||
"error": "AccountNotVerified",
|
||||
"error": "account_not_verified",
|
||||
"message": "Please verify your account before logging in",
|
||||
"did": row.did
|
||||
"did": row.did,
|
||||
"handle": handle,
|
||||
"channel": channel
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
@@ -730,6 +741,79 @@ pub async fn confirm_signup(
|
||||
.into_response()
|
||||
}
|
||||
|
||||
const AUTO_VERIFY_DEBOUNCE: std::time::Duration = std::time::Duration::from_secs(120);
|
||||
|
||||
pub struct AutoResendResult {
|
||||
pub handle: tranquil_types::Handle,
|
||||
pub channel: tranquil_db_traits::CommsChannel,
|
||||
}
|
||||
|
||||
pub async fn auto_resend_verification(state: &AppState, did: &Did) -> Option<AutoResendResult> {
|
||||
let debounce_key = crate::cache_keys::auto_verify_sent_key(did.as_str());
|
||||
let debounced = state.cache.get(&debounce_key).await.is_some();
|
||||
let row = match state.user_repo.get_resend_verification_by_did(did).await {
|
||||
Ok(Some(row)) => row,
|
||||
Ok(None) => return None,
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"Failed to fetch resend verification info for {}: {:?}",
|
||||
did, e
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
if row.channel_verification.has_any_verified() {
|
||||
return None;
|
||||
}
|
||||
let result = AutoResendResult {
|
||||
handle: row.handle.clone(),
|
||||
channel: row.channel,
|
||||
};
|
||||
let is_bot_channel = matches!(
|
||||
row.channel,
|
||||
tranquil_db_traits::CommsChannel::Telegram | tranquil_db_traits::CommsChannel::Discord
|
||||
);
|
||||
if is_bot_channel || debounced {
|
||||
return Some(result);
|
||||
}
|
||||
let recipient = match row.channel {
|
||||
tranquil_db_traits::CommsChannel::Email => row.email.clone().unwrap_or_default(),
|
||||
tranquil_db_traits::CommsChannel::Signal => row.signal_username.clone().unwrap_or_default(),
|
||||
_ => return Some(result),
|
||||
};
|
||||
if recipient.is_empty() {
|
||||
warn!(
|
||||
"No recipient configured for auto-resend verification: {}",
|
||||
did
|
||||
);
|
||||
return Some(result);
|
||||
}
|
||||
let verification_token =
|
||||
crate::auth::verification_token::generate_signup_token(did, row.channel, &recipient);
|
||||
let formatted_token =
|
||||
crate::auth::verification_token::format_token_for_display(&verification_token);
|
||||
let hostname = &tranquil_config::get().server.hostname;
|
||||
if let Err(e) = crate::comms::comms_repo::enqueue_signup_verification(
|
||||
state.user_repo.as_ref(),
|
||||
state.infra_repo.as_ref(),
|
||||
row.id,
|
||||
row.channel,
|
||||
&recipient,
|
||||
&formatted_token,
|
||||
hostname,
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!("Failed to auto-resend verification for {}: {:?}", did, e);
|
||||
return Some(result);
|
||||
}
|
||||
let _ = state
|
||||
.cache
|
||||
.set(&debounce_key, "1", AUTO_VERIFY_DEBOUNCE)
|
||||
.await;
|
||||
Some(result)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ResendVerificationInput {
|
||||
|
||||
@@ -321,8 +321,13 @@ pub fn normalize_token_input(input: &str) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn init() {
|
||||
tranquil_config::ensure_test_defaults();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_signup_token() {
|
||||
init();
|
||||
let did: Did = "did:plc:test123".parse().unwrap();
|
||||
let channel = CommsChannel::Email;
|
||||
let identifier = "test@example.com";
|
||||
@@ -337,6 +342,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_migration_token() {
|
||||
init();
|
||||
let did: Did = "did:plc:test123".parse().unwrap();
|
||||
let email = "test@example.com";
|
||||
let token = generate_migration_token(&did, email);
|
||||
@@ -349,6 +355,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_token_case_insensitive() {
|
||||
init();
|
||||
let did: Did = "did:plc:test123".parse().unwrap();
|
||||
let token = generate_signup_token(&did, CommsChannel::Email, "Test@Example.COM");
|
||||
let result = verify_signup_token(&token, CommsChannel::Email, "test@example.com");
|
||||
@@ -357,6 +364,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_token_wrong_identifier() {
|
||||
init();
|
||||
let did: Did = "did:plc:test123".parse().unwrap();
|
||||
let token = generate_signup_token(&did, CommsChannel::Email, "test@example.com");
|
||||
let result = verify_signup_token(&token, CommsChannel::Email, "other@example.com");
|
||||
@@ -365,6 +373,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_token_wrong_channel() {
|
||||
init();
|
||||
let did: Did = "did:plc:test123".parse().unwrap();
|
||||
let token = generate_signup_token(&did, CommsChannel::Email, "test@example.com");
|
||||
let result = verify_signup_token(&token, CommsChannel::Discord, "test@example.com");
|
||||
@@ -373,6 +382,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_expired_token() {
|
||||
init();
|
||||
let did: Did = "did:plc:test123".parse().unwrap();
|
||||
let token = generate_token_with_expiry(
|
||||
&did,
|
||||
@@ -388,12 +398,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_invalid_token() {
|
||||
init();
|
||||
let result = verify_signup_token("invalid-token", CommsChannel::Email, "test@example.com");
|
||||
assert!(matches!(result, Err(VerifyError::InvalidFormat)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_purpose_mismatch() {
|
||||
init();
|
||||
let did: Did = "did:plc:test123".parse().unwrap();
|
||||
let email = "test@example.com";
|
||||
let signup_token = generate_signup_token(&did, CommsChannel::Email, email);
|
||||
@@ -403,6 +415,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_discord_channel() {
|
||||
init();
|
||||
let did: Did = "did:plc:test123".parse().unwrap();
|
||||
let discord_id = "123456789012345678";
|
||||
let token = generate_signup_token(&did, CommsChannel::Discord, discord_id);
|
||||
|
||||
@@ -33,3 +33,7 @@ pub fn email_update_key(did: &str) -> String {
|
||||
pub fn scope_ref_key(cid: &str) -> String {
|
||||
format!("scope_ref:{}", cid)
|
||||
}
|
||||
|
||||
pub fn auto_verify_sent_key(did: &str) -> String {
|
||||
format!("auto_verify_sent:{}", did)
|
||||
}
|
||||
|
||||
@@ -7,4 +7,4 @@ pub use tranquil_comms::{
|
||||
mime_encode_header, sanitize_header_value, validate_locale,
|
||||
};
|
||||
|
||||
pub use service::{CommsService, repo as comms_repo};
|
||||
pub use service::{CommsService, repo as comms_repo, resolve_delivery_channel};
|
||||
|
||||
@@ -169,6 +169,13 @@ struct ResolvedRecipient {
|
||||
recipient: String,
|
||||
}
|
||||
|
||||
pub fn resolve_delivery_channel(
|
||||
prefs: &UserCommsPrefs,
|
||||
channel: tranquil_db_traits::CommsChannel,
|
||||
) -> tranquil_db_traits::CommsChannel {
|
||||
resolve_recipient(prefs, channel).channel
|
||||
}
|
||||
|
||||
fn resolve_recipient(
|
||||
prefs: &UserCommsPrefs,
|
||||
channel: tranquil_db_traits::CommsChannel,
|
||||
|
||||
@@ -87,11 +87,13 @@ pub async fn verify_handle_ownership(
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_service_domain_handle(handle: &str, _hostname: &str) -> bool {
|
||||
pub fn is_service_domain_handle(handle: &str, hostname: &str) -> bool {
|
||||
if !handle.contains('.') {
|
||||
return true;
|
||||
}
|
||||
let service_domains = tranquil_config::get().server.user_handle_domain_list();
|
||||
let service_domains = tranquil_config::try_get()
|
||||
.map(|c| c.server.user_handle_domain_list())
|
||||
.unwrap_or_else(|| vec![hostname.to_string()]);
|
||||
service_domains
|
||||
.iter()
|
||||
.any(|domain| handle.ends_with(&format!(".{}", domain)) || handle == domain)
|
||||
|
||||
@@ -589,6 +589,7 @@ pub fn app(state: AppState) -> Router {
|
||||
)
|
||||
.route("/authorize/consent", get(oauth::endpoints::consent_get))
|
||||
.route("/authorize/consent", post(oauth::endpoints::consent_post))
|
||||
.route("/authorize/renew", post(oauth::endpoints::authorize_renew))
|
||||
.route(
|
||||
"/authorize/redirect",
|
||||
get(oauth::endpoints::authorize_redirect),
|
||||
|
||||
@@ -34,7 +34,11 @@ fn get_slur_regexes() -> &'static Vec<Regex> {
|
||||
}
|
||||
|
||||
fn get_extra_banned_words() -> &'static Vec<String> {
|
||||
EXTRA_BANNED_WORDS.get_or_init(|| tranquil_config::get().server.banned_word_list())
|
||||
EXTRA_BANNED_WORDS.get_or_init(|| {
|
||||
tranquil_config::try_get()
|
||||
.map(|c| c.server.banned_word_list())
|
||||
.unwrap_or_default()
|
||||
})
|
||||
}
|
||||
|
||||
fn strip_trailing_digits(s: &str) -> &str {
|
||||
|
||||
@@ -28,6 +28,8 @@ use tranquil_types::{AuthorizationCode, ClientId, DeviceId as DeviceIdType, Requ
|
||||
use urlencoding::encode as url_encode;
|
||||
|
||||
const DEVICE_COOKIE_NAME: &str = "oauth_device_id";
|
||||
const RENEW_EXPIRY_SECONDS: i64 = 600;
|
||||
const MAX_RENEWAL_STALENESS_SECONDS: i64 = 3600;
|
||||
|
||||
fn redirect_see_other(uri: &str) -> Response {
|
||||
(
|
||||
@@ -556,14 +558,6 @@ pub async fn authorize_post(
|
||||
if user.takedown_ref.is_some() {
|
||||
return show_login_error("This account has been taken down.", json_response);
|
||||
}
|
||||
let is_verified = user.channel_verification.has_any_verified();
|
||||
if !is_verified {
|
||||
return show_login_error(
|
||||
"Please verify your account before logging in.",
|
||||
json_response,
|
||||
);
|
||||
}
|
||||
|
||||
if user.account_type.is_delegated() {
|
||||
if state
|
||||
.oauth_repo
|
||||
@@ -630,6 +624,35 @@ pub async fn authorize_post(
|
||||
if !password_valid {
|
||||
return show_login_error("Invalid handle/email or password.", json_response);
|
||||
}
|
||||
let is_verified = user.channel_verification.has_any_verified();
|
||||
if !is_verified {
|
||||
let resend_info = crate::api::server::auto_resend_verification(&state, &user.did).await;
|
||||
let handle = resend_info
|
||||
.as_ref()
|
||||
.map(|r| r.handle.to_string())
|
||||
.unwrap_or_else(|| form.username.clone());
|
||||
let channel = resend_info
|
||||
.map(|r| r.channel.as_str().to_owned())
|
||||
.unwrap_or_else(|| user.preferred_comms_channel.as_str().to_owned());
|
||||
if json_response {
|
||||
return (
|
||||
axum::http::StatusCode::FORBIDDEN,
|
||||
Json(serde_json::json!({
|
||||
"error": "account_not_verified",
|
||||
"error_description": "Please verify your account before logging in.",
|
||||
"did": user.did,
|
||||
"handle": handle,
|
||||
"channel": channel
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
return redirect_see_other(&format!(
|
||||
"/app/oauth/login?request_uri={}&error={}",
|
||||
url_encode(&form.request_uri),
|
||||
url_encode("account_not_verified")
|
||||
));
|
||||
}
|
||||
let has_totp = crate::api::server::has_totp_enabled(&state, &user.did).await;
|
||||
if has_totp {
|
||||
let device_cookie = extract_device_cookie(&headers);
|
||||
@@ -955,11 +978,18 @@ pub async fn authorize_select(
|
||||
};
|
||||
let is_verified = user.channel_verification.has_any_verified();
|
||||
if !is_verified {
|
||||
return json_error(
|
||||
let resend_info = crate::api::server::auto_resend_verification(&state, &did).await;
|
||||
return (
|
||||
StatusCode::FORBIDDEN,
|
||||
"access_denied",
|
||||
"Please verify your account before logging in.",
|
||||
);
|
||||
Json(serde_json::json!({
|
||||
"error": "account_not_verified",
|
||||
"error_description": "Please verify your account before logging in.",
|
||||
"did": did,
|
||||
"handle": resend_info.as_ref().map(|r| r.handle.to_string()),
|
||||
"channel": resend_info.as_ref().map(|r| r.channel.as_str())
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let has_totp = crate::api::server::has_totp_enabled(&state, &did).await;
|
||||
let select_early_device_typed = device_id.clone();
|
||||
@@ -970,11 +1000,7 @@ pub async fn authorize_select(
|
||||
if !device_is_trusted {
|
||||
if state
|
||||
.oauth_repo
|
||||
.set_authorization_did(
|
||||
&select_request_id,
|
||||
&did,
|
||||
Some(&select_early_device_typed),
|
||||
)
|
||||
.set_authorization_did(&select_request_id, &did, Some(&select_early_device_typed))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
@@ -989,8 +1015,8 @@ pub async fn authorize_select(
|
||||
}))
|
||||
.into_response();
|
||||
}
|
||||
let _ = crate::api::server::extend_device_trust(state.oauth_repo.as_ref(), &device_id)
|
||||
.await;
|
||||
let _ =
|
||||
crate::api::server::extend_device_trust(state.oauth_repo.as_ref(), &device_id).await;
|
||||
}
|
||||
if user.two_factor_enabled {
|
||||
let _ = state
|
||||
@@ -1041,55 +1067,9 @@ pub async fn authorize_select(
|
||||
.upsert_account_device(&did, &select_device_typed)
|
||||
.await;
|
||||
|
||||
let requested_scope_str = request_data
|
||||
.parameters
|
||||
.scope
|
||||
.as_deref()
|
||||
.unwrap_or("atproto");
|
||||
let requested_scopes: Vec<String> = requested_scope_str
|
||||
.split_whitespace()
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
let client_id_typed = ClientId::from(request_data.parameters.client_id.clone());
|
||||
let needs_consent = should_show_consent(
|
||||
state.oauth_repo.as_ref(),
|
||||
&did,
|
||||
&client_id_typed,
|
||||
&requested_scopes,
|
||||
)
|
||||
.await
|
||||
.unwrap_or(true);
|
||||
|
||||
if needs_consent {
|
||||
if state
|
||||
.oauth_repo
|
||||
.set_authorization_did(&select_request_id, &did, Some(&select_device_typed))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return json_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"server_error",
|
||||
"An error occurred. Please try again.",
|
||||
);
|
||||
}
|
||||
let consent_url = format!(
|
||||
"/app/oauth/consent?request_uri={}",
|
||||
url_encode(&form.request_uri)
|
||||
);
|
||||
return Json(serde_json::json!({"redirect_uri": consent_url})).into_response();
|
||||
}
|
||||
|
||||
let code = Code::generate();
|
||||
let select_code = AuthorizationCode::from(code.0.clone());
|
||||
if state
|
||||
.oauth_repo
|
||||
.update_authorization_request(
|
||||
&select_request_id,
|
||||
&did,
|
||||
Some(&select_device_typed),
|
||||
&select_code,
|
||||
)
|
||||
.set_authorization_did(&select_request_id, &did, Some(&select_device_typed))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
@@ -1099,16 +1079,11 @@ pub async fn authorize_select(
|
||||
"An error occurred. Please try again.",
|
||||
);
|
||||
}
|
||||
let redirect_url = build_intermediate_redirect_url(
|
||||
&request_data.parameters.redirect_uri,
|
||||
&code.0,
|
||||
request_data.parameters.state.as_deref(),
|
||||
request_data.parameters.response_mode.map(|m| m.as_str()),
|
||||
let consent_url = format!(
|
||||
"/app/oauth/consent?request_uri={}",
|
||||
url_encode(&form.request_uri)
|
||||
);
|
||||
Json(serde_json::json!({
|
||||
"redirect_uri": redirect_url
|
||||
}))
|
||||
.into_response()
|
||||
Json(serde_json::json!({"redirect_uri": consent_url})).into_response()
|
||||
}
|
||||
|
||||
fn build_success_redirect(
|
||||
@@ -1401,13 +1376,9 @@ pub async fn consent_get(
|
||||
}
|
||||
},
|
||||
Err(_) => {
|
||||
let _ = state
|
||||
.oauth_repo
|
||||
.delete_authorization_request(&consent_request_id)
|
||||
.await;
|
||||
return json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid_request",
|
||||
"expired_request",
|
||||
"Authorization request has expired",
|
||||
);
|
||||
}
|
||||
@@ -1758,6 +1729,93 @@ pub async fn consent_post(
|
||||
Json(serde_json::json!({ "redirect_uri": intermediate_url })).into_response()
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RenewRequest {
|
||||
pub request_uri: String,
|
||||
}
|
||||
|
||||
pub async fn authorize_renew(
|
||||
State(state): State<AppState>,
|
||||
_rate_limit: OAuthRateLimited<OAuthAuthorizeLimit>,
|
||||
Json(form): Json<RenewRequest>,
|
||||
) -> Response {
|
||||
let request_id = RequestId::from(form.request_uri.clone());
|
||||
let request_data = match state
|
||||
.oauth_repo
|
||||
.get_authorization_request(&request_id)
|
||||
.await
|
||||
{
|
||||
Ok(Some(data)) => data,
|
||||
Ok(None) => {
|
||||
return json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid_request",
|
||||
"Unknown authorization request",
|
||||
);
|
||||
}
|
||||
Err(_) => {
|
||||
return json_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"server_error",
|
||||
"Database error",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if request_data.did.is_none() {
|
||||
return json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid_request",
|
||||
"Authorization request not yet authenticated",
|
||||
);
|
||||
}
|
||||
|
||||
let now = Utc::now();
|
||||
if request_data.expires_at >= now {
|
||||
return Json(serde_json::json!({
|
||||
"request_uri": form.request_uri,
|
||||
"renewed": false
|
||||
}))
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let staleness = now - request_data.expires_at;
|
||||
if staleness.num_seconds() > MAX_RENEWAL_STALENESS_SECONDS {
|
||||
let _ = state
|
||||
.oauth_repo
|
||||
.delete_authorization_request(&request_id)
|
||||
.await;
|
||||
return json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid_request",
|
||||
"Authorization request expired too long ago to renew",
|
||||
);
|
||||
}
|
||||
|
||||
let new_expires_at = now + chrono::Duration::seconds(RENEW_EXPIRY_SECONDS);
|
||||
match state
|
||||
.oauth_repo
|
||||
.extend_authorization_request_expiry(&request_id, new_expires_at)
|
||||
.await
|
||||
{
|
||||
Ok(true) => Json(serde_json::json!({
|
||||
"request_uri": form.request_uri,
|
||||
"renewed": true
|
||||
}))
|
||||
.into_response(),
|
||||
Ok(false) => json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid_request",
|
||||
"Authorization request could not be renewed",
|
||||
),
|
||||
Err(_) => json_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"server_error",
|
||||
"Database error",
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn authorize_2fa_post(
|
||||
State(state): State<AppState>,
|
||||
_rate_limit: OAuthRateLimited<OAuthAuthorizeLimit>,
|
||||
@@ -1953,8 +2011,7 @@ pub async fn authorize_2fa_post(
|
||||
.oauth_repo
|
||||
.upsert_account_device(&did, &trust_device_id)
|
||||
.await;
|
||||
let _ = crate::api::server::trust_device(state.oauth_repo.as_ref(), &trust_device_id)
|
||||
.await;
|
||||
let _ = crate::api::server::trust_device(state.oauth_repo.as_ref(), &trust_device_id).await;
|
||||
}
|
||||
let requested_scope_str = request_data
|
||||
.parameters
|
||||
@@ -2240,11 +2297,15 @@ pub async fn passkey_start(
|
||||
let is_verified = user.channel_verification.has_any_verified();
|
||||
|
||||
if !is_verified {
|
||||
let resend_info = crate::api::server::auto_resend_verification(&state, &user.did).await;
|
||||
return (
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(serde_json::json!({
|
||||
"error": "access_denied",
|
||||
"error_description": "Please verify your account before logging in."
|
||||
"error": "account_not_verified",
|
||||
"error_description": "Please verify your account before logging in.",
|
||||
"did": user.did,
|
||||
"handle": resend_info.as_ref().map(|r| r.handle.to_string()),
|
||||
"channel": resend_info.as_ref().map(|r| r.channel.as_str())
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
@@ -3389,11 +3450,15 @@ pub async fn register_complete(
|
||||
};
|
||||
|
||||
if !is_verified {
|
||||
let resend_info = crate::api::server::auto_resend_verification(&state, &did).await;
|
||||
return (
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(serde_json::json!({
|
||||
"error": "access_denied",
|
||||
"error_description": "Please verify your account before continuing."
|
||||
"error": "account_not_verified",
|
||||
"error_description": "Please verify your account before continuing.",
|
||||
"did": did,
|
||||
"handle": resend_info.as_ref().map(|r| r.handle.to_string()),
|
||||
"channel": resend_info.as_ref().map(|r| r.channel.as_str())
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
|
||||
@@ -124,10 +124,15 @@ impl PlcClient {
|
||||
}
|
||||
|
||||
pub fn with_cache(base_url: Option<String>, cache: Option<Arc<dyn Cache>>) -> Self {
|
||||
let cfg = tranquil_config::get();
|
||||
let base_url = base_url.unwrap_or_else(|| cfg.plc.directory_url.clone());
|
||||
let timeout_secs = cfg.plc.timeout_secs;
|
||||
let connect_timeout_secs = cfg.plc.connect_timeout_secs;
|
||||
let cfg = tranquil_config::try_get();
|
||||
let base_url = base_url
|
||||
.or_else(|| std::env::var("PLC_DIRECTORY_URL").ok())
|
||||
.unwrap_or_else(|| {
|
||||
cfg.map(|c| c.plc.directory_url.clone())
|
||||
.unwrap_or_else(|| "https://plc.directory".to_string())
|
||||
});
|
||||
let timeout_secs = cfg.map_or(10, |c| c.plc.timeout_secs);
|
||||
let connect_timeout_secs = cfg.map_or(5, |c| c.plc.connect_timeout_secs);
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_secs(timeout_secs))
|
||||
.connect_timeout(Duration::from_secs(connect_timeout_secs))
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
use crate::appview::DidResolver;
|
||||
use crate::auth::webauthn::WebAuthnConfig;
|
||||
use crate::cache::{create_cache, Cache, DistributedRateLimiter};
|
||||
use crate::cache::{Cache, DistributedRateLimiter, create_cache};
|
||||
use crate::circuit_breaker::CircuitBreakers;
|
||||
use crate::config::AuthConfig;
|
||||
use crate::rate_limit::RateLimiters;
|
||||
use crate::repo::PostgresBlockStore;
|
||||
use crate::repo_write_lock::RepoWriteLocks;
|
||||
use crate::sso::{SsoConfig, SsoManager};
|
||||
use crate::storage::{create_backup_storage, create_blob_storage, BackupStorage, BlobStorage};
|
||||
use crate::storage::{BackupStorage, BlobStorage, create_backup_storage, create_blob_storage};
|
||||
use crate::sync::firehose::SequencedEvent;
|
||||
use sqlx::PgPool;
|
||||
use std::error::Error;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use tokio::sync::broadcast;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tranquil_db::{
|
||||
|
||||
@@ -145,7 +145,8 @@ impl CarVerifier {
|
||||
}
|
||||
|
||||
async fn resolve_plc_did(&self, did: &str) -> Result<DidDocument<'static>, VerifyError> {
|
||||
let plc_url = tranquil_config::get().plc.directory_url.clone();
|
||||
let plc_url = std::env::var("PLC_DIRECTORY_URL")
|
||||
.unwrap_or_else(|_| tranquil_config::get().plc.directory_url.clone());
|
||||
let url = format!("{}/{}", plc_url, urlencoding::encode(did));
|
||||
let response = self
|
||||
.http_client
|
||||
|
||||
@@ -374,6 +374,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_build_full_url_adds_xrpc_prefix_for_atproto_paths() {
|
||||
unsafe { std::env::set_var("PDS_HOSTNAME", "example.com") };
|
||||
tranquil_config::ensure_test_defaults();
|
||||
assert_eq!(
|
||||
build_full_url("/com.atproto.server.getSession"),
|
||||
"https://example.com/xrpc/com.atproto.server.getSession"
|
||||
|
||||
@@ -548,6 +548,7 @@ async fn spawn_server(config: ServerConfig) -> ServerInstance {
|
||||
unsafe {
|
||||
std::env::set_var("PDS_HOSTNAME", format!("pds.test:{}", addr.port()));
|
||||
}
|
||||
tranquil_config::ensure_test_defaults();
|
||||
let rate_limiters = RateLimiters::new()
|
||||
.with_login_limit(10000)
|
||||
.with_account_creation_limit(10000)
|
||||
|
||||
@@ -64,7 +64,7 @@ async fn test_import_with_valid_signature_and_mock_plc() {
|
||||
let mock_plc = setup_mock_plc_directory(&did, did_doc).await;
|
||||
unsafe {
|
||||
std::env::set_var("PLC_DIRECTORY_URL", mock_plc.uri());
|
||||
std::env::remove_var("SKIP_IMPORT_VERIFICATION");
|
||||
std::env::set_var("SKIP_IMPORT_VERIFICATION", "false");
|
||||
}
|
||||
let (car_bytes, _root_cid) = build_car_with_signature(&did, &signing_key);
|
||||
let import_res = client
|
||||
@@ -108,7 +108,7 @@ async fn test_import_with_wrong_signing_key_fails() {
|
||||
let mock_plc = setup_mock_plc_directory(&did, did_doc).await;
|
||||
unsafe {
|
||||
std::env::set_var("PLC_DIRECTORY_URL", mock_plc.uri());
|
||||
std::env::remove_var("SKIP_IMPORT_VERIFICATION");
|
||||
std::env::set_var("SKIP_IMPORT_VERIFICATION", "false");
|
||||
}
|
||||
let (car_bytes, _root_cid) = build_car_with_signature(&did, &wrong_signing_key);
|
||||
let import_res = client
|
||||
@@ -157,7 +157,7 @@ async fn test_import_with_did_mismatch_fails() {
|
||||
let mock_plc = setup_mock_plc_directory(&did, did_doc).await;
|
||||
unsafe {
|
||||
std::env::set_var("PLC_DIRECTORY_URL", mock_plc.uri());
|
||||
std::env::remove_var("SKIP_IMPORT_VERIFICATION");
|
||||
std::env::set_var("SKIP_IMPORT_VERIFICATION", "false");
|
||||
}
|
||||
let (car_bytes, _root_cid) = build_car_with_signature(wrong_did, &signing_key);
|
||||
let import_res = client
|
||||
@@ -202,7 +202,7 @@ async fn test_import_with_plc_resolution_failure() {
|
||||
.await;
|
||||
unsafe {
|
||||
std::env::set_var("PLC_DIRECTORY_URL", mock_plc.uri());
|
||||
std::env::remove_var("SKIP_IMPORT_VERIFICATION");
|
||||
std::env::set_var("SKIP_IMPORT_VERIFICATION", "false");
|
||||
}
|
||||
let (car_bytes, _root_cid) = build_car_with_signature(&did, &signing_key);
|
||||
let import_res = client
|
||||
@@ -248,7 +248,7 @@ async fn test_import_with_no_signing_key_in_did_doc() {
|
||||
let mock_plc = setup_mock_plc_directory(&did, did_doc_without_key).await;
|
||||
unsafe {
|
||||
std::env::set_var("PLC_DIRECTORY_URL", mock_plc.uri());
|
||||
std::env::remove_var("SKIP_IMPORT_VERIFICATION");
|
||||
std::env::set_var("SKIP_IMPORT_VERIFICATION", "false");
|
||||
}
|
||||
let (car_bytes, _root_cid) = build_car_with_signature(&did, &signing_key);
|
||||
let import_res = client
|
||||
|
||||
@@ -698,7 +698,7 @@ async fn test_cross_pds_migration_with_records() {
|
||||
.await;
|
||||
unsafe {
|
||||
std::env::set_var("PLC_DIRECTORY_URL", mock_server.uri());
|
||||
std::env::remove_var("SKIP_IMPORT_VERIFICATION");
|
||||
std::env::set_var("SKIP_IMPORT_VERIFICATION", "false");
|
||||
}
|
||||
let import_res = client
|
||||
.post(format!(
|
||||
@@ -775,7 +775,7 @@ async fn test_migration_rejects_wrong_did_document() {
|
||||
.await;
|
||||
unsafe {
|
||||
std::env::set_var("PLC_DIRECTORY_URL", mock_server.uri());
|
||||
std::env::remove_var("SKIP_IMPORT_VERIFICATION");
|
||||
std::env::set_var("SKIP_IMPORT_VERIFICATION", "false");
|
||||
}
|
||||
let import_res = client
|
||||
.post(format!(
|
||||
@@ -931,7 +931,7 @@ async fn test_full_migration_flow_end_to_end() {
|
||||
.expect("Submit failed");
|
||||
assert_eq!(submit_res.status(), StatusCode::OK);
|
||||
unsafe {
|
||||
std::env::remove_var("SKIP_IMPORT_VERIFICATION");
|
||||
std::env::set_var("SKIP_IMPORT_VERIFICATION", "false");
|
||||
}
|
||||
let import_res = client
|
||||
.post(format!(
|
||||
|
||||
@@ -767,20 +767,20 @@ pub async fn create_backup_storage() -> Option<Arc<dyn BackupStorage>> {
|
||||
_ => {
|
||||
let path = cfg.backup.path.clone();
|
||||
FilesystemBackupStorage::new(path).await.map_or_else(
|
||||
|e| {
|
||||
tracing::error!(
|
||||
"Failed to initialize filesystem backup storage: {}. \
|
||||
|e| {
|
||||
tracing::error!(
|
||||
"Failed to initialize filesystem backup storage: {}. \
|
||||
Set BACKUP_STORAGE_PATH to a valid directory path. \
|
||||
Backups will be disabled.",
|
||||
e
|
||||
);
|
||||
None
|
||||
},
|
||||
|storage| {
|
||||
tracing::info!("Initialized filesystem backup storage");
|
||||
Some(Arc::new(storage) as Arc<dyn BackupStorage>)
|
||||
},
|
||||
)
|
||||
e
|
||||
);
|
||||
None
|
||||
},
|
||||
|storage| {
|
||||
tracing::info!("Initialized filesystem backup storage");
|
||||
Some(Arc::new(storage) as Arc<dyn BackupStorage>)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -457,7 +457,13 @@
|
||||
showSetPasswordForm = false
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError) {
|
||||
toast.error(e.message)
|
||||
if (e.error === 'ReauthRequired') {
|
||||
reauthMethods = e.reauthMethods || ['passkey']
|
||||
pendingAction = () => handleSetPassword(new Event('submit'))
|
||||
showReauthModal = true
|
||||
} else {
|
||||
toast.error(e.message)
|
||||
}
|
||||
} else {
|
||||
toast.error($_('security.failedToSetPassword'))
|
||||
}
|
||||
|
||||
@@ -542,7 +542,8 @@
|
||||
"passkeyHintNotAvailable": "No passkey registered",
|
||||
"passwordPlaceholder": "Password",
|
||||
"usePasskey": "Use passkey",
|
||||
"orUseCredentials": "or"
|
||||
"orUseCredentials": "or",
|
||||
"verificationResent": "Verification code sent"
|
||||
},
|
||||
"sso": {
|
||||
"linkedAccounts": "Linked Accounts",
|
||||
|
||||
@@ -542,7 +542,8 @@
|
||||
"passkeyHintNotAvailable": "Ei pääsyavainta",
|
||||
"passwordPlaceholder": "Salasana",
|
||||
"usePasskey": "Käytä pääsyavainta",
|
||||
"orUseCredentials": "tai"
|
||||
"orUseCredentials": "tai",
|
||||
"verificationResent": "Vahvistuskoodi lähetetty"
|
||||
},
|
||||
"register": {
|
||||
"title": "Luo tili",
|
||||
|
||||
@@ -542,7 +542,8 @@
|
||||
"passkeyHintNotAvailable": "パスキーなし",
|
||||
"passwordPlaceholder": "パスワード",
|
||||
"usePasskey": "パスキーを使用",
|
||||
"orUseCredentials": "または"
|
||||
"orUseCredentials": "または",
|
||||
"verificationResent": "確認コードを送信しました"
|
||||
},
|
||||
"register": {
|
||||
"title": "アカウント作成",
|
||||
|
||||
@@ -542,7 +542,8 @@
|
||||
"passkeyHintNotAvailable": "패스키 없음",
|
||||
"passwordPlaceholder": "비밀번호",
|
||||
"usePasskey": "패스키 사용",
|
||||
"orUseCredentials": "또는"
|
||||
"orUseCredentials": "또는",
|
||||
"verificationResent": "인증 코드 전송됨"
|
||||
},
|
||||
"register": {
|
||||
"title": "계정 만들기",
|
||||
|
||||
@@ -542,7 +542,8 @@
|
||||
"passkeyHintNotAvailable": "Ingen nyckel registrerad",
|
||||
"passwordPlaceholder": "Lösenord",
|
||||
"usePasskey": "Använd nyckel",
|
||||
"orUseCredentials": "eller"
|
||||
"orUseCredentials": "eller",
|
||||
"verificationResent": "Verifieringskod skickad"
|
||||
},
|
||||
"register": {
|
||||
"title": "Skapa konto",
|
||||
|
||||
@@ -542,7 +542,8 @@
|
||||
"passkeyHintNotAvailable": "未注册通行密钥",
|
||||
"passwordPlaceholder": "密码",
|
||||
"usePasskey": "使用通行密钥",
|
||||
"orUseCredentials": "或"
|
||||
"orUseCredentials": "或",
|
||||
"verificationResent": "验证码已发送"
|
||||
},
|
||||
"register": {
|
||||
"title": "创建账户",
|
||||
|
||||
@@ -62,6 +62,21 @@
|
||||
return params.get('request_uri')
|
||||
}
|
||||
|
||||
async function tryRenewRequest(requestUri: string): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch('/oauth/authorize/renew', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ request_uri: requestUri }),
|
||||
})
|
||||
if (!response.ok) return false
|
||||
const data = await response.json()
|
||||
return data.renewed === true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchConsentData() {
|
||||
const requestUri = getRequestUri()
|
||||
if (!requestUri) {
|
||||
@@ -72,13 +87,32 @@
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/oauth/authorize/consent?request_uri=${encodeURIComponent(requestUri)}`)
|
||||
let response = await fetch(`/oauth/authorize/consent?request_uri=${encodeURIComponent(requestUri)}`)
|
||||
if (!response.ok) {
|
||||
const data = await response.json()
|
||||
console.error('[OAuthConsent] Consent fetch failed:', data)
|
||||
error = data.error_description || data.error || $_('oauth.error.genericError')
|
||||
loading = false
|
||||
return
|
||||
if (data.error === 'expired_request') {
|
||||
const renewed = await tryRenewRequest(requestUri)
|
||||
if (renewed) {
|
||||
response = await fetch(`/oauth/authorize/consent?request_uri=${encodeURIComponent(requestUri)}`)
|
||||
if (!response.ok) {
|
||||
const retryData = await response.json()
|
||||
console.error('[OAuthConsent] Consent fetch failed after renewal:', retryData)
|
||||
error = retryData.error_description || retryData.error || $_('oauth.error.genericError')
|
||||
loading = false
|
||||
return
|
||||
}
|
||||
} else {
|
||||
console.error('[OAuthConsent] Consent fetch failed:', data)
|
||||
error = data.error_description || data.error || $_('oauth.error.genericError')
|
||||
loading = false
|
||||
return
|
||||
}
|
||||
} else {
|
||||
console.error('[OAuthConsent] Consent fetch failed:', data)
|
||||
error = data.error_description || data.error || $_('oauth.error.genericError')
|
||||
loading = false
|
||||
return
|
||||
}
|
||||
}
|
||||
const data: ConsentData = await response.json()
|
||||
|
||||
|
||||
@@ -18,6 +18,18 @@
|
||||
icon: string
|
||||
}
|
||||
|
||||
const PENDING_VERIFICATION_KEY = 'tranquil_pds_pending_verification'
|
||||
|
||||
function storePendingVerification(data: { did?: string; handle?: string; channel?: string }) {
|
||||
if (data.did) {
|
||||
localStorage.setItem(PENDING_VERIFICATION_KEY, JSON.stringify({
|
||||
did: data.did,
|
||||
handle: data.handle ?? '',
|
||||
channel: data.channel ?? '',
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
let username = $state('')
|
||||
let ssoProviders = $state<SsoProvider[]>([])
|
||||
let ssoLoading = $state<string | null>(null)
|
||||
@@ -25,6 +37,7 @@
|
||||
let rememberDevice = $state(false)
|
||||
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)
|
||||
@@ -52,7 +65,11 @@
|
||||
$effect(() => {
|
||||
const urlError = getErrorFromUrl()
|
||||
if (urlError) {
|
||||
error = urlError
|
||||
if (urlError === 'account_not_verified') {
|
||||
verificationResent = true
|
||||
} else {
|
||||
error = urlError
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -200,6 +217,7 @@
|
||||
|
||||
submitting = true
|
||||
error = null
|
||||
verificationResent = false
|
||||
|
||||
try {
|
||||
const startResponse = await fetch('/oauth/passkey/start', {
|
||||
@@ -216,6 +234,12 @@
|
||||
|
||||
if (!startResponse.ok) {
|
||||
const data = await startResponse.json()
|
||||
if (data.error === 'account_not_verified') {
|
||||
verificationResent = true
|
||||
storePendingVerification(data)
|
||||
submitting = false
|
||||
return
|
||||
}
|
||||
error = data.error_description || data.error || 'Failed to start passkey login'
|
||||
submitting = false
|
||||
return
|
||||
@@ -251,6 +275,12 @@
|
||||
const data = await finishResponse.json()
|
||||
|
||||
if (!finishResponse.ok) {
|
||||
if (data.error === 'account_not_verified') {
|
||||
verificationResent = true
|
||||
storePendingVerification(data)
|
||||
submitting = false
|
||||
return
|
||||
}
|
||||
error = data.error_description || data.error || 'Passkey authentication failed'
|
||||
submitting = false
|
||||
return
|
||||
@@ -294,6 +324,7 @@
|
||||
|
||||
submitting = true
|
||||
error = null
|
||||
verificationResent = false
|
||||
|
||||
try {
|
||||
const response = await fetch('/oauth/authorize', {
|
||||
@@ -313,6 +344,12 @@
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
if (data.error === 'account_not_verified') {
|
||||
verificationResent = true
|
||||
storePendingVerification(data)
|
||||
submitting = false
|
||||
return
|
||||
}
|
||||
error = data.error_description || data.error || 'Login failed'
|
||||
submitting = false
|
||||
return
|
||||
@@ -354,7 +391,12 @@
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
{#if error}
|
||||
{#if verificationResent}
|
||||
<div class="message warning">
|
||||
<p>{$_('oauth.login.verificationResent')}</p>
|
||||
<a href={`${getFullUrl(routes.verify)}${getRequestUri() ? `?request_uri=${encodeURIComponent(getRequestUri()!)}` : ''}`}>{$_('verify.tokenTitle')}</a>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="message error">{error}</div>
|
||||
{/if}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user