mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-09-19 08:44:13 +00:00
'Clever' stateless token verification. We could revert later if it sucks.
This commit is contained in:
+34
-28
@@ -1,7 +1,7 @@
|
||||
use crate::api::error::ApiError;
|
||||
use crate::auth::BearerAuthAdmin;
|
||||
use crate::state::AppState;
|
||||
use axum::{extract::State, Json};
|
||||
use axum::{Json, extract::State};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::error;
|
||||
|
||||
@@ -80,7 +80,7 @@ pub async fn get_server_config(
|
||||
async fn upsert_config(db: &sqlx::PgPool, key: &str, value: &str) -> Result<(), sqlx::Error> {
|
||||
sqlx::query(
|
||||
"INSERT INTO server_config (key, value, updated_at) VALUES ($1, $2, NOW())
|
||||
ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()"
|
||||
ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()",
|
||||
)
|
||||
.bind(key)
|
||||
.bind(value)
|
||||
@@ -105,7 +105,9 @@ pub async fn update_server_config(
|
||||
if let Some(server_name) = req.server_name {
|
||||
let trimmed = server_name.trim();
|
||||
if trimmed.is_empty() || trimmed.len() > 100 {
|
||||
return Err(ApiError::InvalidRequest("Server name must be 1-100 characters".into()));
|
||||
return Err(ApiError::InvalidRequest(
|
||||
"Server name must be 1-100 characters".into(),
|
||||
));
|
||||
}
|
||||
upsert_config(&state.db, "server_name", trimmed).await?;
|
||||
}
|
||||
@@ -116,7 +118,9 @@ pub async fn update_server_config(
|
||||
} else if is_valid_hex_color(color) {
|
||||
upsert_config(&state.db, "primary_color", color).await?;
|
||||
} else {
|
||||
return Err(ApiError::InvalidRequest("Invalid primary color format (expected #RRGGBB)".into()));
|
||||
return Err(ApiError::InvalidRequest(
|
||||
"Invalid primary color format (expected #RRGGBB)".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,7 +130,9 @@ pub async fn update_server_config(
|
||||
} else if is_valid_hex_color(color) {
|
||||
upsert_config(&state.db, "primary_color_dark", color).await?;
|
||||
} else {
|
||||
return Err(ApiError::InvalidRequest("Invalid primary dark color format (expected #RRGGBB)".into()));
|
||||
return Err(ApiError::InvalidRequest(
|
||||
"Invalid primary dark color format (expected #RRGGBB)".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,7 +142,9 @@ pub async fn update_server_config(
|
||||
} else if is_valid_hex_color(color) {
|
||||
upsert_config(&state.db, "secondary_color", color).await?;
|
||||
} else {
|
||||
return Err(ApiError::InvalidRequest("Invalid secondary color format (expected #RRGGBB)".into()));
|
||||
return Err(ApiError::InvalidRequest(
|
||||
"Invalid secondary color format (expected #RRGGBB)".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,16 +154,17 @@ pub async fn update_server_config(
|
||||
} else if is_valid_hex_color(color) {
|
||||
upsert_config(&state.db, "secondary_color_dark", color).await?;
|
||||
} else {
|
||||
return Err(ApiError::InvalidRequest("Invalid secondary dark color format (expected #RRGGBB)".into()));
|
||||
return Err(ApiError::InvalidRequest(
|
||||
"Invalid secondary dark color format (expected #RRGGBB)".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref logo_cid) = req.logo_cid {
|
||||
let old_logo_cid: Option<String> = sqlx::query_scalar(
|
||||
"SELECT value FROM server_config WHERE key = 'logo_cid'"
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
let old_logo_cid: Option<String> =
|
||||
sqlx::query_scalar("SELECT value FROM server_config WHERE key = 'logo_cid'")
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
|
||||
let should_delete_old = match (&old_logo_cid, logo_cid.is_empty()) {
|
||||
(Some(old), true) => Some(old.clone()),
|
||||
@@ -163,23 +172,20 @@ pub async fn update_server_config(
|
||||
_ => None,
|
||||
};
|
||||
|
||||
if let Some(old_cid) = should_delete_old {
|
||||
if let Ok(Some(blob)) = sqlx::query!(
|
||||
"SELECT storage_key FROM blobs WHERE cid = $1",
|
||||
old_cid
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
{
|
||||
if let Err(e) = state.blob_store.delete(&blob.storage_key).await {
|
||||
error!("Failed to delete old logo blob from storage: {:?}", e);
|
||||
}
|
||||
if let Err(e) = sqlx::query!("DELETE FROM blobs WHERE cid = $1", old_cid)
|
||||
.execute(&state.db)
|
||||
if let Some(old_cid) = should_delete_old
|
||||
&& let Ok(Some(blob)) =
|
||||
sqlx::query!("SELECT storage_key FROM blobs WHERE cid = $1", old_cid)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
{
|
||||
error!("Failed to delete old logo blob record: {:?}", e);
|
||||
}
|
||||
{
|
||||
if let Err(e) = state.blob_store.delete(&blob.storage_key).await {
|
||||
error!("Failed to delete old logo blob from storage: {:?}", e);
|
||||
}
|
||||
if let Err(e) = sqlx::query!("DELETE FROM blobs WHERE cid = $1", old_cid)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
{
|
||||
error!("Failed to delete old logo blob record: {:?}", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+3
-1
@@ -94,7 +94,9 @@ impl ApiError {
|
||||
fn error_name(&self) -> Cow<'static, str> {
|
||||
match self {
|
||||
Self::InternalError | Self::DatabaseError => Cow::Borrowed("InternalError"),
|
||||
Self::UpstreamFailure | Self::UpstreamUnavailable(_) => Cow::Borrowed("UpstreamFailure"),
|
||||
Self::UpstreamFailure | Self::UpstreamUnavailable(_) => {
|
||||
Cow::Borrowed("UpstreamFailure")
|
||||
}
|
||||
Self::UpstreamTimeout => Cow::Borrowed("UpstreamTimeout"),
|
||||
Self::UpstreamError { error, .. } => {
|
||||
if let Some(e) = error {
|
||||
|
||||
+75
-71
@@ -132,11 +132,11 @@ pub async fn create_account(
|
||||
.map(|d| d.starts_with("did:plc:"))
|
||||
.unwrap_or(false);
|
||||
|
||||
if is_migration || is_did_web_byod {
|
||||
if let (Some(provided_did), Some(auth_did)) = (input.did.as_ref(), migration_auth.as_ref())
|
||||
{
|
||||
if provided_did != auth_did {
|
||||
return (
|
||||
if (is_migration || is_did_web_byod)
|
||||
&& let (Some(provided_did), Some(auth_did)) = (input.did.as_ref(), migration_auth.as_ref())
|
||||
{
|
||||
if provided_did != auth_did {
|
||||
return (
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(json!({
|
||||
"error": "AuthorizationError",
|
||||
@@ -144,12 +144,11 @@ pub async fn create_account(
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
if is_did_web_byod {
|
||||
info!(did = %provided_did, "Processing did:web BYOD account creation");
|
||||
} else {
|
||||
info!(did = %provided_did, "Processing account migration");
|
||||
}
|
||||
}
|
||||
if is_did_web_byod {
|
||||
info!(did = %provided_did, "Processing did:web BYOD account creation");
|
||||
} else {
|
||||
info!(did = %provided_did, "Processing account migration");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -348,16 +347,15 @@ pub async fn create_account(
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
if !is_did_web_byod {
|
||||
if let Err(e) =
|
||||
if !is_did_web_byod
|
||||
&& let Err(e) =
|
||||
verify_did_web(d, &hostname, &input.handle, input.signing_key.as_deref()).await
|
||||
{
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidDid", "message": e})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
{
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidDid", "message": e})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
info!(did = %d, "Creating external did:web account");
|
||||
d.clone()
|
||||
@@ -368,17 +366,20 @@ pub async fn create_account(
|
||||
info!(did = %d, "Migration with existing did:plc");
|
||||
d.clone()
|
||||
} else if d.starts_with("did:web:") {
|
||||
if !is_did_web_byod {
|
||||
if let Err(e) =
|
||||
verify_did_web(d, &hostname, &input.handle, input.signing_key.as_deref())
|
||||
.await
|
||||
{
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidDid", "message": e})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
if !is_did_web_byod
|
||||
&& let Err(e) = verify_did_web(
|
||||
d,
|
||||
&hostname,
|
||||
&input.handle,
|
||||
input.signing_key.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidDid", "message": e})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
d.clone()
|
||||
} else if !d.trim().is_empty() {
|
||||
@@ -710,8 +711,6 @@ pub async fn create_account(
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let verification_code = format!("{:06}", rand::random::<u32>() % 1_000_000);
|
||||
let code_expires_at = chrono::Utc::now() + chrono::Duration::minutes(30);
|
||||
let is_first_user = sqlx::query_scalar!("SELECT COUNT(*) as count FROM users")
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
@@ -758,7 +757,7 @@ pub async fn create_account(
|
||||
)
|
||||
.bind(is_first_user)
|
||||
.bind(deactivated_at)
|
||||
.bind(is_migration)
|
||||
.bind(false)
|
||||
.fetch_one(&mut *tx)
|
||||
.await;
|
||||
let user_id = match user_insert {
|
||||
@@ -806,25 +805,6 @@ pub async fn create_account(
|
||||
}
|
||||
};
|
||||
|
||||
if !is_migration
|
||||
&& let Some(ref recipient) = verification_recipient
|
||||
&& let Err(e) = sqlx::query!(
|
||||
"INSERT INTO channel_verifications (user_id, channel, code, pending_identifier, expires_at) VALUES ($1, $2::comms_channel, $3, $4, $5)",
|
||||
user_id,
|
||||
verification_channel as _,
|
||||
verification_code,
|
||||
recipient,
|
||||
code_expires_at
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await {
|
||||
error!("Error inserting verification code: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let encrypted_key_bytes = match crate::config::encrypt_key(&secret_key_bytes) {
|
||||
Ok(enc) => enc,
|
||||
Err(e) => {
|
||||
@@ -881,17 +861,18 @@ pub async fn create_account(
|
||||
}
|
||||
};
|
||||
let rev = Tid::now(LimitedU32::MIN);
|
||||
let (commit_bytes, _sig) = match create_signed_commit(&did, mst_root, &rev.to_string(), None, &signing_key) {
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
error!("Error creating genesis commit: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let (commit_bytes, _sig) =
|
||||
match create_signed_commit(&did, mst_root, rev.as_ref(), None, &signing_key) {
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
error!("Error creating genesis commit: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let commit_cid = match state.block_store.put(&commit_bytes).await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
@@ -973,22 +954,45 @@ pub async fn create_account(
|
||||
warn!("Failed to create default profile for {}: {}", did, e);
|
||||
}
|
||||
}
|
||||
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
if !is_migration {
|
||||
if let Some(ref recipient) = verification_recipient
|
||||
&& let Err(e) = crate::comms::enqueue_signup_verification(
|
||||
if let Some(ref recipient) = verification_recipient {
|
||||
let verification_token = crate::auth::verification_token::generate_signup_token(
|
||||
&did,
|
||||
verification_channel,
|
||||
recipient,
|
||||
);
|
||||
let formatted_token =
|
||||
crate::auth::verification_token::format_token_for_display(&verification_token);
|
||||
if let Err(e) = crate::comms::enqueue_signup_verification(
|
||||
&state.db,
|
||||
user_id,
|
||||
verification_channel,
|
||||
recipient,
|
||||
&verification_code,
|
||||
&formatted_token,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!(
|
||||
"Failed to enqueue signup verification notification: {:?}",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if let Some(ref user_email) = email {
|
||||
let token = crate::auth::verification_token::generate_migration_token(&did, user_email);
|
||||
let formatted_token = crate::auth::verification_token::format_token_for_display(&token);
|
||||
if let Err(e) = crate::comms::enqueue_migration_verification(
|
||||
&state.db,
|
||||
user_id,
|
||||
user_email,
|
||||
&formatted_token,
|
||||
&hostname,
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!(
|
||||
"Failed to enqueue signup verification notification: {:?}",
|
||||
e
|
||||
);
|
||||
warn!("Failed to enqueue migration verification email: {:?}", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,21 +6,11 @@ use axum::{
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use chrono::{Duration, Utc};
|
||||
use rand::Rng;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use sqlx::Row;
|
||||
use tracing::info;
|
||||
|
||||
fn generate_verification_code() -> String {
|
||||
rand::thread_rng()
|
||||
.sample_iter(&rand::distributions::Uniform::new(0, 10))
|
||||
.take(6)
|
||||
.map(|x| x.to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NotificationPrefsResponse {
|
||||
@@ -228,36 +218,28 @@ pub struct UpdateNotificationPrefsResponse {
|
||||
pub async fn request_channel_verification(
|
||||
db: &sqlx::PgPool,
|
||||
user_id: uuid::Uuid,
|
||||
did: &str,
|
||||
channel: &str,
|
||||
identifier: &str,
|
||||
handle: Option<&str>,
|
||||
) -> Result<String, String> {
|
||||
let code = generate_verification_code();
|
||||
let expires_at = Utc::now() + Duration::minutes(10);
|
||||
|
||||
sqlx::query!(
|
||||
r#"
|
||||
INSERT INTO channel_verifications (user_id, channel, code, pending_identifier, expires_at)
|
||||
VALUES ($1, $2::comms_channel, $3, $4, $5)
|
||||
ON CONFLICT (user_id, channel) DO UPDATE
|
||||
SET code = $3, pending_identifier = $4, expires_at = $5, created_at = NOW()
|
||||
"#,
|
||||
user_id,
|
||||
channel as _,
|
||||
code,
|
||||
identifier,
|
||||
expires_at
|
||||
)
|
||||
.execute(db)
|
||||
.await
|
||||
.map_err(|e| format!("Database error: {}", e))?;
|
||||
let token =
|
||||
crate::auth::verification_token::generate_channel_update_token(did, channel, identifier);
|
||||
let formatted_token = crate::auth::verification_token::format_token_for_display(&token);
|
||||
|
||||
if channel == "email" {
|
||||
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
let handle_str = handle.unwrap_or("user");
|
||||
crate::comms::enqueue_email_update(db, user_id, identifier, handle_str, &code, &hostname)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to enqueue email notification: {}", e))?;
|
||||
crate::comms::enqueue_email_update(
|
||||
db,
|
||||
user_id,
|
||||
identifier,
|
||||
handle_str,
|
||||
&formatted_token,
|
||||
&hostname,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to enqueue email notification: {}", e))?;
|
||||
} else {
|
||||
sqlx::query!(
|
||||
r#"
|
||||
@@ -267,15 +249,15 @@ pub async fn request_channel_verification(
|
||||
user_id,
|
||||
channel as _,
|
||||
identifier,
|
||||
format!("Your verification code is: {}", code),
|
||||
json!({"code": code})
|
||||
format!("Your verification code is: {}", formatted_token),
|
||||
json!({"code": formatted_token})
|
||||
)
|
||||
.execute(db)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to enqueue notification: {}", e))?;
|
||||
}
|
||||
|
||||
Ok(code)
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
pub async fn update_notification_prefs(
|
||||
@@ -397,6 +379,7 @@ pub async fn update_notification_prefs(
|
||||
if let Err(e) = request_channel_verification(
|
||||
&state.db,
|
||||
user_id,
|
||||
&user.did,
|
||||
"email",
|
||||
&email_clean,
|
||||
Some(&handle),
|
||||
@@ -429,16 +412,12 @@ pub async fn update_notification_prefs(
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let _ = sqlx::query!(
|
||||
"DELETE FROM channel_verifications WHERE user_id = $1 AND channel = 'discord'",
|
||||
user_id
|
||||
)
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
info!(did = %user.did, "Cleared Discord ID");
|
||||
} else {
|
||||
if let Err(e) =
|
||||
request_channel_verification(&state.db, user_id, "discord", discord_id, None).await
|
||||
if let Err(e) = request_channel_verification(
|
||||
&state.db, user_id, &user.did, "discord", discord_id, None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
@@ -467,17 +446,17 @@ pub async fn update_notification_prefs(
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let _ = sqlx::query!(
|
||||
"DELETE FROM channel_verifications WHERE user_id = $1 AND channel = 'telegram'",
|
||||
user_id
|
||||
)
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
info!(did = %user.did, "Cleared Telegram username");
|
||||
} else {
|
||||
if let Err(e) =
|
||||
request_channel_verification(&state.db, user_id, "telegram", telegram_clean, None)
|
||||
.await
|
||||
if let Err(e) = request_channel_verification(
|
||||
&state.db,
|
||||
user_id,
|
||||
&user.did,
|
||||
"telegram",
|
||||
telegram_clean,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
@@ -505,16 +484,11 @@ pub async fn update_notification_prefs(
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let _ = sqlx::query!(
|
||||
"DELETE FROM channel_verifications WHERE user_id = $1 AND channel = 'signal'",
|
||||
user_id
|
||||
)
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
info!(did = %user.did, "Cleared Signal number");
|
||||
} else {
|
||||
if let Err(e) =
|
||||
request_channel_verification(&state.db, user_id, "signal", signal, None).await
|
||||
request_channel_verification(&state.db, user_id, &user.did, "signal", signal, None)
|
||||
.await
|
||||
{
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
|
||||
@@ -3,7 +3,7 @@ use bytes::Bytes;
|
||||
use cid::Cid;
|
||||
use jacquard::types::{integer::LimitedU32, string::Tid};
|
||||
use jacquard_repo::storage::BlockStore;
|
||||
use k256::ecdsa::{signature::Signer, Signature, SigningKey};
|
||||
use k256::ecdsa::{Signature, SigningKey, signature::Signer};
|
||||
use serde::Serialize;
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
|
||||
+68
-112
@@ -6,7 +6,6 @@ use axum::{
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use chrono::Utc;
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use tracing::{error, info, warn};
|
||||
@@ -66,7 +65,7 @@ pub async fn request_email_update(
|
||||
return e;
|
||||
}
|
||||
|
||||
let did = auth_user.did;
|
||||
let did = auth_user.did.clone();
|
||||
let user = match sqlx::query!("SELECT id, handle, email FROM users WHERE did = $1", did)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
@@ -117,6 +116,7 @@ pub async fn request_email_update(
|
||||
if let Err(e) = crate::api::notification_prefs::request_channel_verification(
|
||||
&state.db,
|
||||
user_id,
|
||||
&did,
|
||||
"email",
|
||||
&email,
|
||||
Some(&handle),
|
||||
@@ -206,62 +206,50 @@ pub async fn confirm_email(
|
||||
}
|
||||
};
|
||||
|
||||
let verification = match sqlx::query!(
|
||||
"SELECT code, pending_identifier, expires_at FROM channel_verifications WHERE user_id = $1 AND channel = 'email'",
|
||||
user_id
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
{
|
||||
Ok(Some(row)) => row,
|
||||
_ => {
|
||||
let email = input.email.trim().to_lowercase();
|
||||
let confirmation_code =
|
||||
crate::auth::verification_token::normalize_token_input(input.token.trim());
|
||||
|
||||
let verified = crate::auth::verification_token::verify_channel_update_token(
|
||||
&confirmation_code,
|
||||
"email",
|
||||
&email,
|
||||
);
|
||||
|
||||
match verified {
|
||||
Ok(token_data) => {
|
||||
if token_data.did != did {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(
|
||||
json!({"error": "InvalidToken", "message": "Token does not match account"}),
|
||||
),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
Err(crate::auth::verification_token::VerifyError::Expired) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRequest", "message": "No pending email update found"})),
|
||||
Json(json!({"error": "ExpiredToken", "message": "Token has expired"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidToken", "message": "Invalid token"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let pending_email = verification.pending_identifier.unwrap_or_default();
|
||||
let email = input.email.trim().to_lowercase();
|
||||
let confirmation_code = input.token.trim();
|
||||
|
||||
if pending_email != email {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRequest", "message": "Email does not match pending update"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
if verification.code != confirmation_code {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidToken", "message": "Invalid token"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
if Utc::now() > verification.expires_at {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "ExpiredToken", "message": "Token has expired"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let mut tx = match state.db.begin().await {
|
||||
Ok(tx) => tx,
|
||||
Err(_) => return ApiError::InternalError.into_response(),
|
||||
};
|
||||
|
||||
let update = sqlx::query!(
|
||||
"UPDATE users SET email = $1, updated_at = NOW() WHERE id = $2",
|
||||
pending_email,
|
||||
"UPDATE users SET email = $1, email_verified = TRUE, updated_at = NOW() WHERE id = $2",
|
||||
email,
|
||||
user_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
|
||||
if let Err(e) = update {
|
||||
@@ -283,21 +271,6 @@ pub async fn confirm_email(
|
||||
.into_response();
|
||||
}
|
||||
|
||||
if let Err(e) = sqlx::query!(
|
||||
"DELETE FROM channel_verifications WHERE user_id = $1 AND channel = 'email'",
|
||||
user_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
{
|
||||
error!("Failed to delete verification record: {:?}", e);
|
||||
return ApiError::InternalError.into_response();
|
||||
}
|
||||
|
||||
if tx.commit().await.is_err() {
|
||||
return ApiError::InternalError.into_response();
|
||||
}
|
||||
|
||||
info!("Email updated for user {}", user_id);
|
||||
(StatusCode::OK, Json(json!({}))).into_response()
|
||||
}
|
||||
@@ -377,50 +350,49 @@ pub async fn update_email(
|
||||
return (StatusCode::OK, Json(json!({}))).into_response();
|
||||
}
|
||||
|
||||
let verification = sqlx::query!(
|
||||
"SELECT code, pending_identifier, expires_at FROM channel_verifications WHERE user_id = $1 AND channel = 'email'",
|
||||
user_id
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.unwrap_or(None);
|
||||
let confirmation_token = match &input.token {
|
||||
Some(t) => crate::auth::verification_token::normalize_token_input(t.trim()),
|
||||
None => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "TokenRequired", "message": "Token required. Call requestEmailUpdate first."})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(ver) = verification {
|
||||
let confirmation_token = match &input.token {
|
||||
Some(t) => t.trim(),
|
||||
None => {
|
||||
let verified = crate::auth::verification_token::verify_channel_update_token(
|
||||
&confirmation_token,
|
||||
"email",
|
||||
&new_email,
|
||||
);
|
||||
|
||||
match verified {
|
||||
Ok(token_data) => {
|
||||
if token_data.did != did {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "TokenRequired", "message": "Token required. Call requestEmailUpdate first."})),
|
||||
Json(
|
||||
json!({"error": "InvalidToken", "message": "Token does not match account"}),
|
||||
),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let pending_email = ver.pending_identifier.unwrap_or_default();
|
||||
if pending_email.to_lowercase() != new_email {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRequest", "message": "Email does not match pending update"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
if ver.code != confirmation_token {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidToken", "message": "Invalid token"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
if Utc::now() > ver.expires_at {
|
||||
Err(crate::auth::verification_token::VerifyError::Expired) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "ExpiredToken", "message": "Token has expired"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidToken", "message": "Invalid token"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
let exists = sqlx::query!(
|
||||
@@ -439,17 +411,12 @@ pub async fn update_email(
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let mut tx = match state.db.begin().await {
|
||||
Ok(tx) => tx,
|
||||
Err(_) => return ApiError::InternalError.into_response(),
|
||||
};
|
||||
|
||||
let update = sqlx::query!(
|
||||
"UPDATE users SET email = $1, updated_at = NOW() WHERE id = $2",
|
||||
"UPDATE users SET email = $1, email_verified = TRUE, updated_at = NOW() WHERE id = $2",
|
||||
new_email,
|
||||
user_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
|
||||
if let Err(e) = update {
|
||||
@@ -471,17 +438,6 @@ pub async fn update_email(
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let _ = sqlx::query!(
|
||||
"DELETE FROM channel_verifications WHERE user_id = $1 AND channel = 'email'",
|
||||
user_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await;
|
||||
|
||||
if tx.commit().await.is_err() {
|
||||
return ApiError::InternalError.into_response();
|
||||
}
|
||||
|
||||
match sqlx::query!(
|
||||
"INSERT INTO account_preferences (user_id, name, value_json) VALUES ($1, 'email_auth_factor', $2) ON CONFLICT (user_id, name) DO UPDATE SET value_json = $2",
|
||||
user_id,
|
||||
|
||||
+11
-12
@@ -9,18 +9,17 @@ use axum::{
|
||||
use tracing::error;
|
||||
|
||||
pub async fn get_logo(State(state): State<AppState>) -> Response {
|
||||
let logo_cid: Option<String> = match sqlx::query_scalar(
|
||||
"SELECT value FROM server_config WHERE key = 'logo_cid'"
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
{
|
||||
Ok(cid) => cid,
|
||||
Err(e) => {
|
||||
error!("DB error fetching logo_cid: {:?}", e);
|
||||
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||
}
|
||||
};
|
||||
let logo_cid: Option<String> =
|
||||
match sqlx::query_scalar("SELECT value FROM server_config WHERE key = 'logo_cid'")
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
{
|
||||
Ok(cid) => cid,
|
||||
Err(e) => {
|
||||
error!("DB error fetching logo_cid: {:?}", e);
|
||||
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let cid = match logo_cid {
|
||||
Some(c) if !c.is_empty() => c,
|
||||
|
||||
@@ -13,6 +13,8 @@ pub mod session;
|
||||
pub mod signing_key;
|
||||
pub mod totp;
|
||||
pub mod trusted_devices;
|
||||
pub mod verify_email;
|
||||
pub mod verify_token;
|
||||
|
||||
pub use account_status::{
|
||||
activate_account, check_account_status, deactivate_account, delete_account,
|
||||
@@ -35,9 +37,9 @@ pub use password::{
|
||||
change_password, get_password_status, remove_password, request_password_reset, reset_password,
|
||||
};
|
||||
pub use reauth::{
|
||||
check_legacy_session_mfa, check_reauth_required, get_reauth_status, legacy_mfa_required_response,
|
||||
reauth_passkey_finish, reauth_passkey_start, reauth_password, reauth_required_response,
|
||||
reauth_totp, update_mfa_verified,
|
||||
check_legacy_session_mfa, check_reauth_required, get_reauth_status,
|
||||
legacy_mfa_required_response, reauth_passkey_finish, reauth_passkey_start, reauth_password,
|
||||
reauth_required_response, reauth_totp, update_mfa_verified,
|
||||
};
|
||||
pub use service_auth::get_service_auth;
|
||||
pub use session::{
|
||||
@@ -54,3 +56,5 @@ pub use trusted_devices::{
|
||||
extend_device_trust, is_device_trusted, list_trusted_devices, revoke_trusted_device,
|
||||
trust_device, update_trusted_device,
|
||||
};
|
||||
pub use verify_email::{resend_migration_verification, verify_migration_email};
|
||||
pub use verify_token::{VerifyTokenInput, VerifyTokenOutput, verify_token, verify_token_internal};
|
||||
|
||||
@@ -117,7 +117,10 @@ pub async fn create_passkey_account(
|
||||
.await
|
||||
{
|
||||
Ok(claims) => {
|
||||
debug!("Service token verified for BYOD did:web: iss={}", claims.iss);
|
||||
debug!(
|
||||
"Service token verified for BYOD did:web: iss={}",
|
||||
claims.iss
|
||||
);
|
||||
Some(claims.iss)
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -342,9 +345,10 @@ pub async fn create_passkey_account(
|
||||
.into_response();
|
||||
}
|
||||
if is_byod_did_web {
|
||||
if let Some(ref auth_did) = byod_auth {
|
||||
if d != auth_did {
|
||||
return (
|
||||
if let Some(ref auth_did) = byod_auth
|
||||
&& d != auth_did
|
||||
{
|
||||
return (
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(json!({
|
||||
"error": "AuthorizationError",
|
||||
@@ -352,7 +356,6 @@ pub async fn create_passkey_account(
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
info!(did = %d, "Creating external did:web passkey account (BYOD key)");
|
||||
} else {
|
||||
@@ -416,12 +419,6 @@ pub async fn create_passkey_account(
|
||||
|
||||
info!(did = %did, handle = %handle, "Created DID for passkey-only account");
|
||||
|
||||
let verification_code = format!(
|
||||
"{:06}",
|
||||
rand::Rng::gen_range(&mut rand::thread_rng(), 0..1_000_000u32)
|
||||
);
|
||||
let verification_code_expires_at = Utc::now() + Duration::minutes(30);
|
||||
|
||||
let setup_token = generate_setup_token();
|
||||
let setup_token_hash = match hash(&setup_token, DEFAULT_COST) {
|
||||
Ok(h) => h,
|
||||
@@ -591,17 +588,18 @@ pub async fn create_passkey_account(
|
||||
}
|
||||
};
|
||||
let rev = Tid::now(LimitedU32::MIN);
|
||||
let (commit_bytes, _sig) = match create_signed_commit(&did, mst_root, &rev.to_string(), None, &secret_key) {
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
error!("Error creating genesis commit: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let (commit_bytes, _sig) =
|
||||
match create_signed_commit(&did, mst_root, rev.as_ref(), None, &secret_key) {
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
error!("Error creating genesis commit: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let commit_cid: cid::Cid = match state.block_store.put(&commit_bytes).await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
@@ -647,25 +645,6 @@ pub async fn create_passkey_account(
|
||||
.await;
|
||||
}
|
||||
|
||||
if let Err(e) = sqlx::query!(
|
||||
"INSERT INTO channel_verifications (user_id, channel, code, pending_identifier, expires_at) VALUES ($1, $2::comms_channel, $3, $4, $5)",
|
||||
user_id,
|
||||
verification_channel as _,
|
||||
verification_code,
|
||||
verification_recipient,
|
||||
verification_code_expires_at
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
{
|
||||
error!("Error inserting channel verification: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
if let Err(e) = tx.commit().await {
|
||||
error!("Error committing transaction: {:?}", e);
|
||||
return (
|
||||
@@ -703,12 +682,19 @@ pub async fn create_passkey_account(
|
||||
}
|
||||
}
|
||||
|
||||
let verification_token = crate::auth::verification_token::generate_signup_token(
|
||||
&did,
|
||||
verification_channel,
|
||||
&verification_recipient,
|
||||
);
|
||||
let formatted_token =
|
||||
crate::auth::verification_token::format_token_for_display(&verification_token);
|
||||
if let Err(e) = crate::comms::enqueue_signup_verification(
|
||||
&state.db,
|
||||
user_id,
|
||||
verification_channel,
|
||||
&verification_recipient,
|
||||
&verification_code,
|
||||
&formatted_token,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
@@ -847,21 +833,20 @@ pub async fn complete_passkey_setup(
|
||||
}
|
||||
};
|
||||
|
||||
let credential: webauthn_rs::prelude::RegisterPublicKeyCredential = match serde_json::from_value(
|
||||
input.passkey_credential,
|
||||
) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
warn!("Failed to parse credential: {:?}", e);
|
||||
return (
|
||||
let credential: webauthn_rs::prelude::RegisterPublicKeyCredential =
|
||||
match serde_json::from_value(input.passkey_credential) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
warn!("Failed to parse credential: {:?}", e);
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(
|
||||
json!({"error": "InvalidCredential", "message": "Failed to parse credential"}),
|
||||
),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
let security_key = match webauthn.finish_registration(&credential, ®_state) {
|
||||
Ok(sk) => sk,
|
||||
|
||||
@@ -471,7 +471,13 @@ pub async fn remove_password(State(state): State<AppState>, auth: BearerAuth) ->
|
||||
.await;
|
||||
}
|
||||
|
||||
if crate::api::server::reauth::check_reauth_required_cached(&state.db, &state.cache, &auth.0.did).await {
|
||||
if crate::api::server::reauth::check_reauth_required_cached(
|
||||
&state.db,
|
||||
&state.cache,
|
||||
&auth.0.did,
|
||||
)
|
||||
.await
|
||||
{
|
||||
return crate::api::server::reauth::reauth_required_response(&state.db, &auth.0.did).await;
|
||||
}
|
||||
|
||||
|
||||
@@ -376,7 +376,8 @@ pub async fn reauth_passkey_finish(
|
||||
{
|
||||
Ok(false) => {
|
||||
warn!(did = %auth.0.did, "Passkey counter anomaly detected - possible cloned key");
|
||||
let _ = crate::auth::webauthn::delete_authentication_state(&state.db, &auth.0.did).await;
|
||||
let _ =
|
||||
crate::auth::webauthn::delete_authentication_state(&state.db, &auth.0.did).await;
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({
|
||||
@@ -494,14 +495,14 @@ pub async fn check_reauth_required_cached(
|
||||
did: &str,
|
||||
) -> bool {
|
||||
let cache_key = format!("reauth:{}", did);
|
||||
if let Some(timestamp_str) = cache.get(&cache_key).await {
|
||||
if let Ok(timestamp) = timestamp_str.parse::<i64>() {
|
||||
let reauth_time = chrono::DateTime::from_timestamp(timestamp, 0);
|
||||
if let Some(t) = reauth_time {
|
||||
let elapsed = Utc::now().signed_duration_since(t);
|
||||
if elapsed.num_seconds() <= REAUTH_WINDOW_SECONDS {
|
||||
return false;
|
||||
}
|
||||
if let Some(timestamp_str) = cache.get(&cache_key).await
|
||||
&& let Ok(timestamp) = timestamp_str.parse::<i64>()
|
||||
{
|
||||
let reauth_time = chrono::DateTime::from_timestamp(timestamp, 0);
|
||||
if let Some(t) = reauth_time {
|
||||
let elapsed = Utc::now().signed_duration_since(t);
|
||||
if elapsed.num_seconds() <= REAUTH_WINDOW_SECONDS {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +66,9 @@ pub async fn get_service_auth(
|
||||
}
|
||||
};
|
||||
|
||||
let (token, is_dpop) = if auth_header.len() >= 7 && auth_header[..7].eq_ignore_ascii_case("bearer ") {
|
||||
let (token, is_dpop) = if auth_header.len() >= 7
|
||||
&& auth_header[..7].eq_ignore_ascii_case("bearer ")
|
||||
{
|
||||
(auth_header[7..].trim().to_string(), false)
|
||||
} else if auth_header.len() >= 5 && auth_header[..5].eq_ignore_ascii_case("dpop ") {
|
||||
(auth_header[5..].trim().to_string(), true)
|
||||
@@ -81,10 +83,14 @@ pub async fn get_service_auth(
|
||||
&token,
|
||||
dpop_proof,
|
||||
"GET",
|
||||
&format!("/xrpc/com.atproto.server.getServiceAuth?aud={}&lxm={}",
|
||||
params.aud,
|
||||
params.lxm.as_deref().unwrap_or("")),
|
||||
).await {
|
||||
&format!(
|
||||
"/xrpc/com.atproto.server.getServiceAuth?aud={}&lxm={}",
|
||||
params.aud,
|
||||
params.lxm.as_deref().unwrap_or("")
|
||||
),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => crate::auth::AuthenticatedUser {
|
||||
did: result.did,
|
||||
is_oauth: true,
|
||||
@@ -100,7 +106,8 @@ pub async fn get_service_auth(
|
||||
"error": "use_dpop_nonce",
|
||||
"message": "DPoP nonce required"
|
||||
})),
|
||||
).into_response();
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(error = ?e, "getServiceAuth DPoP auth validation failed");
|
||||
@@ -110,7 +117,8 @@ pub async fn get_service_auth(
|
||||
"error": "AuthenticationFailed",
|
||||
"message": format!("{:?}", e)
|
||||
})),
|
||||
).into_response();
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -136,7 +144,7 @@ pub async fn get_service_auth(
|
||||
"SELECT k.key_bytes, k.encryption_version
|
||||
FROM users u
|
||||
JOIN user_keys k ON u.id = k.user_id
|
||||
WHERE u.did = $1"
|
||||
WHERE u.did = $1",
|
||||
)
|
||||
.bind(&auth_user.did)
|
||||
.fetch_optional(&state.db)
|
||||
@@ -155,17 +163,13 @@ pub async fn get_service_auth(
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
return ApiError::AuthenticationFailedMsg(
|
||||
"User has no signing key".into(),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::AuthenticationFailedMsg("User has no signing key".into())
|
||||
.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!(error = ?e, "DB error fetching user key");
|
||||
return ApiError::AuthenticationFailedMsg(
|
||||
"Failed to get signing key".into(),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::AuthenticationFailedMsg("Failed to get signing key".into())
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+51
-73
@@ -8,7 +8,6 @@ use axum::{
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use bcrypt::verify;
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use tracing::{error, info, warn};
|
||||
@@ -167,10 +166,7 @@ pub async fn create_session(
|
||||
let has_totp = row.totp_enabled.unwrap_or(false);
|
||||
let is_legacy_login = has_totp;
|
||||
if has_totp && !row.allow_legacy_login {
|
||||
warn!(
|
||||
"Legacy login blocked for TOTP-enabled account: {}",
|
||||
row.did
|
||||
);
|
||||
warn!("Legacy login blocked for TOTP-enabled account: {}", row.did);
|
||||
return (
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(json!({
|
||||
@@ -556,6 +552,7 @@ pub async fn confirm_signup(
|
||||
r#"SELECT
|
||||
u.id, u.did, u.handle, u.email,
|
||||
u.preferred_comms_channel as "channel: crate::comms::CommsChannel",
|
||||
u.discord_id, u.telegram_username, u.signal_number,
|
||||
k.key_bytes, k.encryption_version
|
||||
FROM users u
|
||||
JOIN user_keys k ON u.id = k.user_id
|
||||
@@ -577,38 +574,46 @@ pub async fn confirm_signup(
|
||||
}
|
||||
};
|
||||
|
||||
let channel_str = match row.channel {
|
||||
crate::comms::CommsChannel::Email => "email",
|
||||
crate::comms::CommsChannel::Discord => "discord",
|
||||
crate::comms::CommsChannel::Telegram => "telegram",
|
||||
crate::comms::CommsChannel::Signal => "signal",
|
||||
};
|
||||
let verification = match sqlx::query!(
|
||||
"SELECT code, expires_at FROM channel_verifications WHERE user_id = $1 AND channel = $2::comms_channel",
|
||||
row.id,
|
||||
channel_str as _
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
{
|
||||
Ok(Some(v)) => v,
|
||||
Ok(None) => {
|
||||
warn!("No verification code found for user: {}", input.did);
|
||||
return ApiError::InvalidRequest("No pending verification".into()).into_response();
|
||||
let (channel_str, identifier) = match row.channel {
|
||||
crate::comms::CommsChannel::Email => ("email", row.email.clone().unwrap_or_default()),
|
||||
crate::comms::CommsChannel::Discord => {
|
||||
("discord", row.discord_id.clone().unwrap_or_default())
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Database error fetching verification: {:?}", e);
|
||||
return ApiError::InternalError.into_response();
|
||||
crate::comms::CommsChannel::Telegram => (
|
||||
"telegram",
|
||||
row.telegram_username.clone().unwrap_or_default(),
|
||||
),
|
||||
crate::comms::CommsChannel::Signal => {
|
||||
("signal", row.signal_number.clone().unwrap_or_default())
|
||||
}
|
||||
};
|
||||
|
||||
if verification.code != input.verification_code {
|
||||
warn!("Invalid verification code for user: {}", input.did);
|
||||
return ApiError::InvalidRequest("Invalid verification code".into()).into_response();
|
||||
}
|
||||
if verification.expires_at < Utc::now() {
|
||||
warn!("Verification code expired for user: {}", input.did);
|
||||
return ApiError::ExpiredTokenMsg("Verification code has expired".into()).into_response();
|
||||
let normalized_token =
|
||||
crate::auth::verification_token::normalize_token_input(&input.verification_code);
|
||||
match crate::auth::verification_token::verify_signup_token(
|
||||
&normalized_token,
|
||||
channel_str,
|
||||
&identifier,
|
||||
) {
|
||||
Ok(token_data) => {
|
||||
if token_data.did != input.did {
|
||||
warn!(
|
||||
"Token DID mismatch for confirm_signup: expected {}, got {}",
|
||||
input.did, token_data.did
|
||||
);
|
||||
return ApiError::InvalidRequest("Invalid verification code".into())
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
Err(crate::auth::verification_token::VerifyError::Expired) => {
|
||||
warn!("Verification code expired for user: {}", input.did);
|
||||
return ApiError::ExpiredTokenMsg("Verification code has expired".into())
|
||||
.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Invalid verification code for user {}: {:?}", input.did, e);
|
||||
return ApiError::InvalidRequest("Invalid verification code".into()).into_response();
|
||||
}
|
||||
}
|
||||
|
||||
let key_bytes = match crate::config::decrypt_key(&row.key_bytes, row.encryption_version) {
|
||||
@@ -634,17 +639,6 @@ pub async fn confirm_signup(
|
||||
return ApiError::InternalError.into_response();
|
||||
}
|
||||
|
||||
if let Err(e) = sqlx::query!(
|
||||
"DELETE FROM channel_verifications WHERE user_id = $1 AND channel = $2::comms_channel",
|
||||
row.id,
|
||||
channel_str as _
|
||||
)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
{
|
||||
error!("Failed to delete verification record: {:?}", e);
|
||||
}
|
||||
|
||||
let access_meta = match crate::auth::create_access_token_with_metadata(&row.did, &key_bytes) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
@@ -737,8 +731,6 @@ pub async fn resend_verification(
|
||||
if is_verified {
|
||||
return ApiError::InvalidRequest("Account is already verified".into()).into_response();
|
||||
}
|
||||
let verification_code = format!("{:06}", rand::random::<u32>() % 1_000_000);
|
||||
let code_expires_at = Utc::now() + chrono::Duration::minutes(30);
|
||||
|
||||
let (channel_str, recipient) = match row.channel {
|
||||
crate::comms::CommsChannel::Email => ("email", row.email.clone().unwrap_or_default()),
|
||||
@@ -754,31 +746,17 @@ pub async fn resend_verification(
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = sqlx::query!(
|
||||
r#"
|
||||
INSERT INTO channel_verifications (user_id, channel, code, pending_identifier, expires_at)
|
||||
VALUES ($1, $2::comms_channel, $3, $4, $5)
|
||||
ON CONFLICT (user_id, channel) DO UPDATE
|
||||
SET code = $3, pending_identifier = $4, expires_at = $5, created_at = NOW()
|
||||
"#,
|
||||
row.id,
|
||||
channel_str as _,
|
||||
verification_code,
|
||||
recipient,
|
||||
code_expires_at
|
||||
)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
{
|
||||
error!("Failed to update verification code: {:?}", e);
|
||||
return ApiError::InternalError.into_response();
|
||||
}
|
||||
let verification_token =
|
||||
crate::auth::verification_token::generate_signup_token(&input.did, channel_str, &recipient);
|
||||
let formatted_token =
|
||||
crate::auth::verification_token::format_token_for_display(&verification_token);
|
||||
|
||||
if let Err(e) = crate::comms::enqueue_signup_verification(
|
||||
&state.db,
|
||||
row.id,
|
||||
channel_str,
|
||||
&recipient,
|
||||
&verification_code,
|
||||
&formatted_token,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
@@ -886,8 +864,7 @@ pub async fn list_sessions(
|
||||
Ok(rows) => {
|
||||
for (id, token_id, created_at, expires_at, client_id) in rows {
|
||||
let client_name = extract_client_name(&client_id);
|
||||
let is_current_oauth = auth.0.is_oauth
|
||||
&& current_jti.as_ref() == Some(&token_id);
|
||||
let is_current_oauth = auth.0.is_oauth && current_jti.as_ref() == Some(&token_id);
|
||||
sessions.push(SessionInfo {
|
||||
id: format!("oauth:{}", id),
|
||||
session_type: "oauth".to_string(),
|
||||
@@ -1071,11 +1048,12 @@ pub async fn revoke_all_sessions(
|
||||
.into_response();
|
||||
}
|
||||
} else {
|
||||
if let Err(e) = sqlx::query("DELETE FROM session_tokens WHERE did = $1 AND access_jti != $2")
|
||||
.bind(&auth.0.did)
|
||||
.bind(jti)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
if let Err(e) =
|
||||
sqlx::query("DELETE FROM session_tokens WHERE did = $1 AND access_jti != $2")
|
||||
.bind(&auth.0.did)
|
||||
.bind(jti)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
{
|
||||
error!("DB error revoking JWT sessions: {:?}", e);
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
use axum::{Json, extract::State, http::StatusCode};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct VerifyMigrationEmailInput {
|
||||
pub token: String,
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct VerifyMigrationEmailOutput {
|
||||
pub success: bool,
|
||||
pub did: String,
|
||||
}
|
||||
|
||||
pub async fn verify_migration_email(
|
||||
State(state): State<AppState>,
|
||||
Json(input): Json<VerifyMigrationEmailInput>,
|
||||
) -> Result<Json<VerifyMigrationEmailOutput>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let token_input = super::verify_token::VerifyTokenInput {
|
||||
token: input.token,
|
||||
identifier: input.email,
|
||||
};
|
||||
|
||||
let result = super::verify_token::verify_token_internal(&state, None, token_input).await?;
|
||||
|
||||
Ok(Json(VerifyMigrationEmailOutput {
|
||||
success: result.success,
|
||||
did: result.did.clone(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ResendMigrationVerificationInput {
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ResendMigrationVerificationOutput {
|
||||
pub sent: bool,
|
||||
}
|
||||
|
||||
pub async fn resend_migration_verification(
|
||||
State(state): State<AppState>,
|
||||
Json(input): Json<ResendMigrationVerificationInput>,
|
||||
) -> Result<Json<ResendMigrationVerificationOutput>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let email = input.email.trim().to_lowercase();
|
||||
|
||||
let user = sqlx::query!(
|
||||
"SELECT id, did, email, email_verified, handle FROM users WHERE LOWER(email) = $1",
|
||||
email
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
warn!(error = %e, "Database error during resend verification");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": "InternalError", "message": "Database error" })),
|
||||
)
|
||||
})?;
|
||||
|
||||
let user = match user {
|
||||
Some(u) => u,
|
||||
None => {
|
||||
return Ok(Json(ResendMigrationVerificationOutput { sent: true }));
|
||||
}
|
||||
};
|
||||
|
||||
if user.email_verified {
|
||||
return Ok(Json(ResendMigrationVerificationOutput { sent: true }));
|
||||
}
|
||||
|
||||
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
let token = crate::auth::verification_token::generate_migration_token(&user.did, &email);
|
||||
let formatted_token = crate::auth::verification_token::format_token_for_display(&token);
|
||||
|
||||
if let Err(e) = crate::comms::enqueue_migration_verification(
|
||||
&state.db,
|
||||
user.id,
|
||||
&email,
|
||||
&formatted_token,
|
||||
&hostname,
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!(error = %e, "Failed to enqueue migration verification email");
|
||||
}
|
||||
|
||||
info!(did = %user.did, "Resent migration verification email");
|
||||
|
||||
Ok(Json(ResendMigrationVerificationOutput { sent: true }))
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
http::{HeaderMap, StatusCode},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use crate::auth::verification_token::{
|
||||
VerificationPurpose, VerifyError, normalize_token_input, verify_token_signature,
|
||||
};
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Deserialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct VerifyTokenInput {
|
||||
pub token: String,
|
||||
pub identifier: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct VerifyTokenOutput {
|
||||
pub success: bool,
|
||||
pub did: String,
|
||||
pub purpose: String,
|
||||
pub channel: String,
|
||||
}
|
||||
|
||||
pub async fn verify_token(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(input): Json<VerifyTokenInput>,
|
||||
) -> Result<Json<VerifyTokenOutput>, (StatusCode, Json<serde_json::Value>)> {
|
||||
verify_token_internal(&state, Some(&headers), input).await
|
||||
}
|
||||
|
||||
pub async fn verify_token_internal(
|
||||
state: &AppState,
|
||||
headers: Option<&HeaderMap>,
|
||||
input: VerifyTokenInput,
|
||||
) -> Result<Json<VerifyTokenOutput>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let normalized_token = normalize_token_input(&input.token);
|
||||
let identifier = input.identifier.trim().to_lowercase();
|
||||
|
||||
let token_data = match verify_token_signature(&normalized_token) {
|
||||
Ok(data) => data,
|
||||
Err(e) => {
|
||||
let (status, error, message) = match e {
|
||||
VerifyError::InvalidFormat => (
|
||||
StatusCode::BAD_REQUEST,
|
||||
"InvalidToken",
|
||||
"The verification token is invalid or malformed",
|
||||
),
|
||||
VerifyError::UnsupportedVersion => (
|
||||
StatusCode::BAD_REQUEST,
|
||||
"InvalidToken",
|
||||
"This verification token version is not supported",
|
||||
),
|
||||
VerifyError::Expired => (
|
||||
StatusCode::BAD_REQUEST,
|
||||
"ExpiredToken",
|
||||
"The verification token has expired. Please request a new one.",
|
||||
),
|
||||
VerifyError::InvalidSignature => (
|
||||
StatusCode::BAD_REQUEST,
|
||||
"InvalidToken",
|
||||
"The verification token signature is invalid",
|
||||
),
|
||||
_ => (
|
||||
StatusCode::BAD_REQUEST,
|
||||
"InvalidToken",
|
||||
"The verification token is not valid",
|
||||
),
|
||||
};
|
||||
warn!(error = ?e, "Token verification failed");
|
||||
return Err((status, Json(json!({ "error": error, "message": message }))));
|
||||
}
|
||||
};
|
||||
|
||||
let expected_hash = crate::auth::verification_token::hash_identifier(&identifier);
|
||||
if token_data.identifier_hash != expected_hash {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(
|
||||
json!({ "error": "IdentifierMismatch", "message": "The identifier does not match the verification token" }),
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
match token_data.purpose {
|
||||
VerificationPurpose::Migration => {
|
||||
handle_migration_verification(state, &token_data.did, &token_data.channel, &identifier)
|
||||
.await
|
||||
}
|
||||
VerificationPurpose::ChannelUpdate => {
|
||||
let auth_did = extract_and_validate_auth(state, headers).await?;
|
||||
if auth_did != token_data.did {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(
|
||||
json!({ "error": "InvalidToken", "message": "Token does not match authenticated account" }),
|
||||
),
|
||||
));
|
||||
}
|
||||
handle_channel_update(state, &token_data.did, &token_data.channel, &identifier).await
|
||||
}
|
||||
VerificationPurpose::Signup => {
|
||||
handle_signup_verification(state, &token_data.did, &token_data.channel, &identifier)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn extract_and_validate_auth(
|
||||
state: &AppState,
|
||||
headers: Option<&HeaderMap>,
|
||||
) -> Result<String, (StatusCode, Json<serde_json::Value>)> {
|
||||
let headers = headers.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({ "error": "AuthenticationRequired", "message": "Authentication required for this verification" })),
|
||||
)
|
||||
})?;
|
||||
|
||||
let token = crate::auth::extract_bearer_token_from_header(
|
||||
headers.get("Authorization").and_then(|h| h.to_str().ok()),
|
||||
)
|
||||
.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({ "error": "AuthenticationRequired", "message": "Authentication required for this verification" })),
|
||||
)
|
||||
})?;
|
||||
|
||||
let user = crate::auth::validate_bearer_token(&state.db, &token)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({ "error": "AuthenticationFailed", "message": "Invalid authentication token" })),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(user.did)
|
||||
}
|
||||
|
||||
async fn handle_migration_verification(
|
||||
state: &AppState,
|
||||
did: &str,
|
||||
channel: &str,
|
||||
identifier: &str,
|
||||
) -> Result<Json<VerifyTokenOutput>, (StatusCode, Json<serde_json::Value>)> {
|
||||
if channel != "email" {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(
|
||||
json!({ "error": "InvalidChannel", "message": "Migration verification is only supported for email" }),
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
let user = sqlx::query!(
|
||||
"SELECT id, email, email_verified FROM users WHERE did = $1",
|
||||
did
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
warn!(error = %e, "Database error during migration verification");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": "InternalError", "message": "Database error" })),
|
||||
)
|
||||
})?;
|
||||
|
||||
let user = user.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({ "error": "AccountNotFound", "message": "No account found for this verification token" })),
|
||||
)
|
||||
})?;
|
||||
|
||||
if user.email.as_ref().map(|e| e.to_lowercase()) != Some(identifier.to_string()) {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(
|
||||
json!({ "error": "IdentifierMismatch", "message": "The email address does not match the account" }),
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
if !user.email_verified {
|
||||
sqlx::query!(
|
||||
"UPDATE users SET email_verified = true WHERE id = $1",
|
||||
user.id
|
||||
)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
warn!(error = %e, "Failed to update email_verified status");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": "InternalError", "message": "Failed to verify email" })),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
info!(did = %did, "Migration email verified successfully");
|
||||
|
||||
Ok(Json(VerifyTokenOutput {
|
||||
success: true,
|
||||
did: did.to_string(),
|
||||
purpose: "migration".to_string(),
|
||||
channel: channel.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn handle_channel_update(
|
||||
state: &AppState,
|
||||
did: &str,
|
||||
channel: &str,
|
||||
identifier: &str,
|
||||
) -> Result<Json<VerifyTokenOutput>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let user_id = sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": "InternalError", "message": "User not found" })),
|
||||
)
|
||||
})?;
|
||||
|
||||
let update_result = match channel {
|
||||
"email" => sqlx::query!(
|
||||
"UPDATE users SET email = $1, email_verified = TRUE, updated_at = NOW() WHERE id = $2",
|
||||
identifier,
|
||||
user_id
|
||||
).execute(&state.db).await,
|
||||
"discord" => sqlx::query!(
|
||||
"UPDATE users SET discord_id = $1, discord_verified = TRUE, updated_at = NOW() WHERE id = $2",
|
||||
identifier,
|
||||
user_id
|
||||
).execute(&state.db).await,
|
||||
"telegram" => sqlx::query!(
|
||||
"UPDATE users SET telegram_username = $1, telegram_verified = TRUE, updated_at = NOW() WHERE id = $2",
|
||||
identifier,
|
||||
user_id
|
||||
).execute(&state.db).await,
|
||||
"signal" => sqlx::query!(
|
||||
"UPDATE users SET signal_number = $1, signal_verified = TRUE, updated_at = NOW() WHERE id = $2",
|
||||
identifier,
|
||||
user_id
|
||||
).execute(&state.db).await,
|
||||
_ => {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "error": "InvalidChannel", "message": "Invalid channel" })),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = update_result {
|
||||
error!("Failed to update user channel: {:?}", e);
|
||||
if channel == "email"
|
||||
&& e.as_database_error()
|
||||
.map(|db| db.is_unique_violation())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "error": "EmailTaken", "message": "Email already in use" })),
|
||||
));
|
||||
}
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": "InternalError", "message": "Failed to update channel" })),
|
||||
));
|
||||
}
|
||||
|
||||
info!(did = %did, channel = %channel, "Channel verified successfully");
|
||||
|
||||
Ok(Json(VerifyTokenOutput {
|
||||
success: true,
|
||||
did: did.to_string(),
|
||||
purpose: "channel_update".to_string(),
|
||||
channel: channel.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn handle_signup_verification(
|
||||
state: &AppState,
|
||||
did: &str,
|
||||
channel: &str,
|
||||
_identifier: &str,
|
||||
) -> Result<Json<VerifyTokenOutput>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let user = sqlx::query!(
|
||||
"SELECT id, handle, email, email_verified, discord_verified, telegram_verified, signal_verified FROM users WHERE did = $1",
|
||||
did
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
warn!(error = %e, "Database error during signup verification");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": "InternalError", "message": "Database error" })),
|
||||
)
|
||||
})?;
|
||||
|
||||
let user = user.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({ "error": "AccountNotFound", "message": "No account found for this verification token" })),
|
||||
)
|
||||
})?;
|
||||
|
||||
let is_verified = user.email_verified
|
||||
|| user.discord_verified
|
||||
|| user.telegram_verified
|
||||
|| user.signal_verified;
|
||||
if is_verified {
|
||||
info!(did = %did, "Account already verified");
|
||||
return Ok(Json(VerifyTokenOutput {
|
||||
success: true,
|
||||
did: did.to_string(),
|
||||
purpose: "signup".to_string(),
|
||||
channel: channel.to_string(),
|
||||
}));
|
||||
}
|
||||
|
||||
let update_result = match channel {
|
||||
"email" => {
|
||||
sqlx::query!(
|
||||
"UPDATE users SET email_verified = TRUE WHERE id = $1",
|
||||
user.id
|
||||
)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
}
|
||||
"discord" => {
|
||||
sqlx::query!(
|
||||
"UPDATE users SET discord_verified = TRUE WHERE id = $1",
|
||||
user.id
|
||||
)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
}
|
||||
"telegram" => {
|
||||
sqlx::query!(
|
||||
"UPDATE users SET telegram_verified = TRUE WHERE id = $1",
|
||||
user.id
|
||||
)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
}
|
||||
"signal" => {
|
||||
sqlx::query!(
|
||||
"UPDATE users SET signal_verified = TRUE WHERE id = $1",
|
||||
user.id
|
||||
)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
}
|
||||
_ => {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "error": "InvalidChannel", "message": "Invalid channel" })),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
update_result.map_err(|e| {
|
||||
warn!(error = %e, "Failed to update channel verified status");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": "InternalError", "message": "Failed to verify channel" })),
|
||||
)
|
||||
})?;
|
||||
|
||||
info!(did = %did, channel = %channel, "Signup verified successfully");
|
||||
|
||||
Ok(Json(VerifyTokenOutput {
|
||||
success: true,
|
||||
did: did.to_string(),
|
||||
purpose: "signup".to_string(),
|
||||
channel: channel.to_string(),
|
||||
}))
|
||||
}
|
||||
@@ -64,16 +64,16 @@ pub fn validate_short_handle(handle: &str) -> Result<String, HandleValidationErr
|
||||
return Err(HandleValidationError::TooLong);
|
||||
}
|
||||
|
||||
if let Some(first_char) = handle.chars().next() {
|
||||
if first_char == '-' || first_char == '_' {
|
||||
return Err(HandleValidationError::StartsWithInvalidChar);
|
||||
}
|
||||
if let Some(first_char) = handle.chars().next()
|
||||
&& (first_char == '-' || first_char == '_')
|
||||
{
|
||||
return Err(HandleValidationError::StartsWithInvalidChar);
|
||||
}
|
||||
|
||||
if let Some(last_char) = handle.chars().last() {
|
||||
if last_char == '-' || last_char == '_' {
|
||||
return Err(HandleValidationError::EndsWithInvalidChar);
|
||||
}
|
||||
if let Some(last_char) = handle.chars().last()
|
||||
&& (last_char == '-' || last_char == '_')
|
||||
{
|
||||
return Err(HandleValidationError::EndsWithInvalidChar);
|
||||
}
|
||||
|
||||
for c in handle.chars() {
|
||||
|
||||
+8
-176
@@ -1,20 +1,18 @@
|
||||
use crate::auth::validate_bearer_token;
|
||||
use crate::state::AppState;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
http::{HeaderMap, StatusCode},
|
||||
http::HeaderMap,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use chrono::Utc;
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use tracing::{error, info};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConfirmChannelVerificationInput {
|
||||
pub channel: String,
|
||||
pub identifier: String,
|
||||
pub code: String,
|
||||
}
|
||||
|
||||
@@ -23,179 +21,13 @@ pub async fn confirm_channel_verification(
|
||||
headers: HeaderMap,
|
||||
Json(input): Json<ConfirmChannelVerificationInput>,
|
||||
) -> Response {
|
||||
let token = match crate::auth::extract_bearer_token_from_header(
|
||||
headers.get("Authorization").and_then(|h| h.to_str().ok()),
|
||||
) {
|
||||
Some(t) => t,
|
||||
None => return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({"error": "AuthenticationRequired", "message": "Authentication required"})),
|
||||
)
|
||||
.into_response(),
|
||||
};
|
||||
let user = match validate_bearer_token(&state.db, &token).await {
|
||||
Ok(u) => u,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({"error": "AuthenticationFailed", "message": "Invalid token"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let token_input = crate::api::server::VerifyTokenInput {
|
||||
token: input.code,
|
||||
identifier: input.identifier,
|
||||
};
|
||||
|
||||
let user_id = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", user.did)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
{
|
||||
Ok(id) => id,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "User not found"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let channel_str = input.channel.as_str();
|
||||
if !["email", "discord", "telegram", "signal"].contains(&channel_str) {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRequest", "message": "Invalid channel"})),
|
||||
)
|
||||
.into_response();
|
||||
match crate::api::server::verify_token_internal(&state, Some(&headers), token_input).await {
|
||||
Ok(output) => Json(json!({"success": output.success})).into_response(),
|
||||
Err((status, err_json)) => (status, err_json).into_response(),
|
||||
}
|
||||
|
||||
let record = match sqlx::query!(
|
||||
r#"
|
||||
SELECT code, pending_identifier, expires_at FROM channel_verifications
|
||||
WHERE user_id = $1 AND channel = $2::comms_channel
|
||||
"#,
|
||||
user_id,
|
||||
channel_str as _
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await {
|
||||
Ok(Some(r)) => r,
|
||||
Ok(None) => return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRequest", "message": "No pending verification found. Update notification preferences first."})),
|
||||
)
|
||||
.into_response(),
|
||||
Err(e) => return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": format!("Database error: {}", e)})),
|
||||
)
|
||||
.into_response(),
|
||||
};
|
||||
|
||||
let pending_identifier =
|
||||
match record.pending_identifier {
|
||||
Some(p) => p,
|
||||
None => return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRequest", "message": "No pending identifier found"})),
|
||||
)
|
||||
.into_response(),
|
||||
};
|
||||
|
||||
if record.expires_at < Utc::now() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "ExpiredToken", "message": "Verification code expired"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
if record.code != input.code {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidCode", "message": "Invalid verification code"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let mut tx = match state.db.begin().await {
|
||||
Ok(tx) => tx,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let update_result = match channel_str {
|
||||
"email" => sqlx::query!(
|
||||
"UPDATE users SET email = $1, updated_at = NOW() WHERE id = $2",
|
||||
pending_identifier,
|
||||
user_id
|
||||
).execute(&mut *tx).await,
|
||||
"discord" => sqlx::query!(
|
||||
"UPDATE users SET discord_id = $1, discord_verified = TRUE, updated_at = NOW() WHERE id = $2",
|
||||
pending_identifier,
|
||||
user_id
|
||||
).execute(&mut *tx).await,
|
||||
"telegram" => sqlx::query!(
|
||||
"UPDATE users SET telegram_username = $1, telegram_verified = TRUE, updated_at = NOW() WHERE id = $2",
|
||||
pending_identifier,
|
||||
user_id
|
||||
).execute(&mut *tx).await,
|
||||
"signal" => sqlx::query!(
|
||||
"UPDATE users SET signal_number = $1, signal_verified = TRUE, updated_at = NOW() WHERE id = $2",
|
||||
pending_identifier,
|
||||
user_id
|
||||
).execute(&mut *tx).await,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
|
||||
if let Err(e) = update_result {
|
||||
error!("Failed to update user channel: {:?}", e);
|
||||
if channel_str == "email"
|
||||
&& e.as_database_error()
|
||||
.map(|db| db.is_unique_violation())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "EmailTaken", "message": "Email already in use"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Failed to update channel"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
if let Err(e) = sqlx::query!(
|
||||
"DELETE FROM channel_verifications WHERE user_id = $1 AND channel = $2::comms_channel",
|
||||
user_id,
|
||||
channel_str as _
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
{
|
||||
error!("Failed to delete verification record: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
if tx.commit().await.is_err() {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
info!(did = %user.did, channel = %channel_str, "Channel verified successfully");
|
||||
|
||||
Json(json!({"success": true})).into_response()
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ pub mod scope_check;
|
||||
pub mod service;
|
||||
pub mod token;
|
||||
pub mod totp;
|
||||
pub mod verification_token;
|
||||
pub mod verify;
|
||||
pub mod webauthn;
|
||||
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use hmac::Mac;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
type HmacSha256 = hmac::Hmac<Sha256>;
|
||||
|
||||
const TOKEN_VERSION: u8 = 1;
|
||||
const DEFAULT_SIGNUP_EXPIRY_MINUTES: u64 = 30;
|
||||
const DEFAULT_MIGRATION_EXPIRY_HOURS: u64 = 48;
|
||||
const DEFAULT_CHANNEL_UPDATE_EXPIRY_MINUTES: u64 = 10;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum VerificationPurpose {
|
||||
Signup,
|
||||
Migration,
|
||||
ChannelUpdate,
|
||||
}
|
||||
|
||||
impl VerificationPurpose {
|
||||
fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Signup => "signup",
|
||||
Self::Migration => "migration",
|
||||
Self::ChannelUpdate => "channel_update",
|
||||
}
|
||||
}
|
||||
|
||||
fn from_str(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"signup" => Some(Self::Signup),
|
||||
"migration" => Some(Self::Migration),
|
||||
"channel_update" => Some(Self::ChannelUpdate),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn default_expiry_seconds(&self) -> u64 {
|
||||
match self {
|
||||
Self::Signup => DEFAULT_SIGNUP_EXPIRY_MINUTES * 60,
|
||||
Self::Migration => DEFAULT_MIGRATION_EXPIRY_HOURS * 3600,
|
||||
Self::ChannelUpdate => DEFAULT_CHANNEL_UPDATE_EXPIRY_MINUTES * 60,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct VerificationToken {
|
||||
pub did: String,
|
||||
pub purpose: VerificationPurpose,
|
||||
pub channel: String,
|
||||
pub identifier_hash: String,
|
||||
pub expires_at: u64,
|
||||
}
|
||||
|
||||
fn derive_verification_key() -> [u8; 32] {
|
||||
use hkdf::Hkdf;
|
||||
let master_key = std::env::var("MASTER_KEY").unwrap_or_else(|_| {
|
||||
if cfg!(test) || std::env::var("TRANQUIL_PDS_ALLOW_INSECURE_SECRETS").is_ok() {
|
||||
"test-master-key-not-for-production".to_string()
|
||||
} else {
|
||||
panic!("MASTER_KEY must be set");
|
||||
}
|
||||
});
|
||||
let hk = Hkdf::<Sha256>::new(None, master_key.as_bytes());
|
||||
let mut key = [0u8; 32];
|
||||
hk.expand(b"tranquil-pds-verification-token-v1", &mut key)
|
||||
.expect("HKDF expansion failed");
|
||||
key
|
||||
}
|
||||
|
||||
pub fn hash_identifier(identifier: &str) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(identifier.to_lowercase().as_bytes());
|
||||
let result = hasher.finalize();
|
||||
URL_SAFE_NO_PAD.encode(&result[..16])
|
||||
}
|
||||
|
||||
pub fn generate_signup_token(did: &str, channel: &str, identifier: &str) -> String {
|
||||
generate_token(did, VerificationPurpose::Signup, channel, identifier)
|
||||
}
|
||||
|
||||
pub fn generate_migration_token(did: &str, email: &str) -> String {
|
||||
generate_token(did, VerificationPurpose::Migration, "email", email)
|
||||
}
|
||||
|
||||
pub fn generate_channel_update_token(did: &str, channel: &str, identifier: &str) -> String {
|
||||
generate_token(did, VerificationPurpose::ChannelUpdate, channel, identifier)
|
||||
}
|
||||
|
||||
pub fn generate_token(
|
||||
did: &str,
|
||||
purpose: VerificationPurpose,
|
||||
channel: &str,
|
||||
identifier: &str,
|
||||
) -> String {
|
||||
generate_token_with_expiry(
|
||||
did,
|
||||
purpose,
|
||||
channel,
|
||||
identifier,
|
||||
purpose.default_expiry_seconds(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn generate_token_with_expiry(
|
||||
did: &str,
|
||||
purpose: VerificationPurpose,
|
||||
channel: &str,
|
||||
identifier: &str,
|
||||
expiry_seconds: u64,
|
||||
) -> String {
|
||||
let key = derive_verification_key();
|
||||
let identifier_hash = hash_identifier(identifier);
|
||||
let expires_at = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
+ expiry_seconds;
|
||||
|
||||
let payload = format!(
|
||||
"{}|{}|{}|{}|{}",
|
||||
did,
|
||||
purpose.as_str(),
|
||||
channel,
|
||||
identifier_hash,
|
||||
expires_at
|
||||
);
|
||||
|
||||
let mut mac = <HmacSha256 as Mac>::new_from_slice(&key).expect("HMAC key size is valid");
|
||||
mac.update(payload.as_bytes());
|
||||
let signature = URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes());
|
||||
|
||||
let token_data = format!(
|
||||
"{}|{}|{}|{}|{}|{}|{}",
|
||||
TOKEN_VERSION,
|
||||
did,
|
||||
purpose.as_str(),
|
||||
channel,
|
||||
identifier_hash,
|
||||
expires_at,
|
||||
signature
|
||||
);
|
||||
URL_SAFE_NO_PAD.encode(token_data.as_bytes())
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum VerifyError {
|
||||
InvalidFormat,
|
||||
UnsupportedVersion,
|
||||
Expired,
|
||||
InvalidSignature,
|
||||
IdentifierMismatch,
|
||||
PurposeMismatch,
|
||||
ChannelMismatch,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for VerifyError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::InvalidFormat => write!(f, "Invalid token format"),
|
||||
Self::UnsupportedVersion => write!(f, "Unsupported token version"),
|
||||
Self::Expired => write!(f, "Token has expired"),
|
||||
Self::InvalidSignature => write!(f, "Invalid token signature"),
|
||||
Self::IdentifierMismatch => write!(f, "Identifier does not match token"),
|
||||
Self::PurposeMismatch => write!(f, "Token purpose does not match"),
|
||||
Self::ChannelMismatch => write!(f, "Token channel does not match"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn verify_signup_token(
|
||||
token: &str,
|
||||
expected_channel: &str,
|
||||
expected_identifier: &str,
|
||||
) -> Result<VerificationToken, VerifyError> {
|
||||
let parsed = verify_token_signature(token)?;
|
||||
if parsed.purpose != VerificationPurpose::Signup {
|
||||
return Err(VerifyError::PurposeMismatch);
|
||||
}
|
||||
if parsed.channel != expected_channel {
|
||||
return Err(VerifyError::ChannelMismatch);
|
||||
}
|
||||
let expected_hash = hash_identifier(expected_identifier);
|
||||
if parsed.identifier_hash != expected_hash {
|
||||
return Err(VerifyError::IdentifierMismatch);
|
||||
}
|
||||
Ok(parsed)
|
||||
}
|
||||
|
||||
pub fn verify_migration_token(
|
||||
token: &str,
|
||||
expected_email: &str,
|
||||
) -> Result<VerificationToken, VerifyError> {
|
||||
let parsed = verify_token_signature(token)?;
|
||||
if parsed.purpose != VerificationPurpose::Migration {
|
||||
return Err(VerifyError::PurposeMismatch);
|
||||
}
|
||||
if parsed.channel != "email" {
|
||||
return Err(VerifyError::ChannelMismatch);
|
||||
}
|
||||
let expected_hash = hash_identifier(expected_email);
|
||||
if parsed.identifier_hash != expected_hash {
|
||||
return Err(VerifyError::IdentifierMismatch);
|
||||
}
|
||||
Ok(parsed)
|
||||
}
|
||||
|
||||
pub fn verify_channel_update_token(
|
||||
token: &str,
|
||||
expected_channel: &str,
|
||||
expected_identifier: &str,
|
||||
) -> Result<VerificationToken, VerifyError> {
|
||||
let parsed = verify_token_signature(token)?;
|
||||
if parsed.purpose != VerificationPurpose::ChannelUpdate {
|
||||
return Err(VerifyError::PurposeMismatch);
|
||||
}
|
||||
if parsed.channel != expected_channel {
|
||||
return Err(VerifyError::ChannelMismatch);
|
||||
}
|
||||
let expected_hash = hash_identifier(expected_identifier);
|
||||
if parsed.identifier_hash != expected_hash {
|
||||
return Err(VerifyError::IdentifierMismatch);
|
||||
}
|
||||
Ok(parsed)
|
||||
}
|
||||
|
||||
pub fn verify_token_for_did(
|
||||
token: &str,
|
||||
expected_did: &str,
|
||||
) -> Result<VerificationToken, VerifyError> {
|
||||
let parsed = verify_token_signature(token)?;
|
||||
if parsed.did != expected_did {
|
||||
return Err(VerifyError::IdentifierMismatch);
|
||||
}
|
||||
Ok(parsed)
|
||||
}
|
||||
|
||||
pub fn verify_token_signature(token: &str) -> Result<VerificationToken, VerifyError> {
|
||||
let token_bytes = URL_SAFE_NO_PAD
|
||||
.decode(token.trim())
|
||||
.map_err(|_| VerifyError::InvalidFormat)?;
|
||||
let token_str = String::from_utf8(token_bytes).map_err(|_| VerifyError::InvalidFormat)?;
|
||||
|
||||
let parts: Vec<&str> = token_str.split('|').collect();
|
||||
if parts.len() != 7 {
|
||||
return Err(VerifyError::InvalidFormat);
|
||||
}
|
||||
|
||||
let version: u8 = parts[0].parse().map_err(|_| VerifyError::InvalidFormat)?;
|
||||
if version != TOKEN_VERSION {
|
||||
return Err(VerifyError::UnsupportedVersion);
|
||||
}
|
||||
|
||||
let did = parts[1];
|
||||
let purpose_str = parts[2];
|
||||
let channel = parts[3];
|
||||
let identifier_hash = parts[4];
|
||||
let expires_at: u64 = parts[5].parse().map_err(|_| VerifyError::InvalidFormat)?;
|
||||
let provided_signature = parts[6];
|
||||
|
||||
let purpose = VerificationPurpose::from_str(purpose_str).ok_or(VerifyError::InvalidFormat)?;
|
||||
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
if now > expires_at {
|
||||
return Err(VerifyError::Expired);
|
||||
}
|
||||
|
||||
let key = derive_verification_key();
|
||||
let payload = format!(
|
||||
"{}|{}|{}|{}|{}",
|
||||
did, purpose_str, channel, identifier_hash, expires_at
|
||||
);
|
||||
let mut mac = <HmacSha256 as Mac>::new_from_slice(&key).expect("HMAC key size is valid");
|
||||
mac.update(payload.as_bytes());
|
||||
let expected_signature = URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes());
|
||||
|
||||
use subtle::ConstantTimeEq;
|
||||
let sig_matches: bool = provided_signature
|
||||
.as_bytes()
|
||||
.ct_eq(expected_signature.as_bytes())
|
||||
.into();
|
||||
if !sig_matches {
|
||||
return Err(VerifyError::InvalidSignature);
|
||||
}
|
||||
|
||||
Ok(VerificationToken {
|
||||
did: did.to_string(),
|
||||
purpose,
|
||||
channel: channel.to_string(),
|
||||
identifier_hash: identifier_hash.to_string(),
|
||||
expires_at,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn format_token_for_display(token: &str) -> String {
|
||||
let clean = token.replace(['-', ' '], "");
|
||||
let mut result = String::new();
|
||||
for (i, c) in clean.chars().enumerate() {
|
||||
if i > 0 && i % 4 == 0 {
|
||||
result.push('-');
|
||||
}
|
||||
result.push(c);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub fn normalize_token_input(input: &str) -> String {
|
||||
input
|
||||
.chars()
|
||||
.filter(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '=')
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_signup_token() {
|
||||
let did = "did:plc:test123";
|
||||
let channel = "email";
|
||||
let identifier = "test@example.com";
|
||||
let token = generate_signup_token(did, channel, identifier);
|
||||
let result = verify_signup_token(&token, channel, identifier);
|
||||
assert!(result.is_ok(), "Expected Ok, got {:?}", result);
|
||||
let parsed = result.unwrap();
|
||||
assert_eq!(parsed.did, did);
|
||||
assert_eq!(parsed.purpose, VerificationPurpose::Signup);
|
||||
assert_eq!(parsed.channel, channel);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migration_token() {
|
||||
let did = "did:plc:test123";
|
||||
let email = "test@example.com";
|
||||
let token = generate_migration_token(did, email);
|
||||
let result = verify_migration_token(&token, email);
|
||||
assert!(result.is_ok(), "Expected Ok, got {:?}", result);
|
||||
let parsed = result.unwrap();
|
||||
assert_eq!(parsed.did, did);
|
||||
assert_eq!(parsed.purpose, VerificationPurpose::Migration);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_token_case_insensitive() {
|
||||
let did = "did:plc:test123";
|
||||
let token = generate_signup_token(did, "email", "Test@Example.COM");
|
||||
let result = verify_signup_token(&token, "email", "test@example.com");
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_token_wrong_identifier() {
|
||||
let did = "did:plc:test123";
|
||||
let token = generate_signup_token(did, "email", "test@example.com");
|
||||
let result = verify_signup_token(&token, "email", "other@example.com");
|
||||
assert!(matches!(result, Err(VerifyError::IdentifierMismatch)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_token_wrong_channel() {
|
||||
let did = "did:plc:test123";
|
||||
let token = generate_signup_token(did, "email", "test@example.com");
|
||||
let result = verify_signup_token(&token, "discord", "test@example.com");
|
||||
assert!(matches!(result, Err(VerifyError::ChannelMismatch)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expired_token() {
|
||||
let did = "did:plc:test123";
|
||||
let token = generate_token_with_expiry(
|
||||
did,
|
||||
VerificationPurpose::Signup,
|
||||
"email",
|
||||
"test@example.com",
|
||||
0,
|
||||
);
|
||||
std::thread::sleep(std::time::Duration::from_millis(1100));
|
||||
let result = verify_signup_token(&token, "email", "test@example.com");
|
||||
assert!(matches!(result, Err(VerifyError::Expired)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_token() {
|
||||
let result = verify_signup_token("invalid-token", "email", "test@example.com");
|
||||
assert!(matches!(result, Err(VerifyError::InvalidFormat)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_purpose_mismatch() {
|
||||
let did = "did:plc:test123";
|
||||
let email = "test@example.com";
|
||||
let signup_token = generate_signup_token(did, "email", email);
|
||||
let result = verify_migration_token(&signup_token, email);
|
||||
assert!(matches!(result, Err(VerifyError::PurposeMismatch)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_discord_channel() {
|
||||
let did = "did:plc:test123";
|
||||
let discord_id = "123456789012345678";
|
||||
let token = generate_signup_token(did, "discord", discord_id);
|
||||
let result = verify_signup_token(&token, "discord", discord_id);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_token_for_display() {
|
||||
let token = "ABCDEFGHIJKLMNOP";
|
||||
let formatted = format_token_for_display(token);
|
||||
assert_eq!(formatted, "ABCD-EFGH-IJKL-MNOP");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_token_input() {
|
||||
let input = "ABCD-EFGH IJKL-MNOP";
|
||||
let normalized = normalize_token_input(input);
|
||||
assert_eq!(normalized, "ABCDEFGHIJKLMNOP");
|
||||
}
|
||||
}
|
||||
+28
-28
@@ -12,8 +12,6 @@ pub fn validate_locale(locale: &str) -> &str {
|
||||
pub struct NotificationStrings {
|
||||
pub welcome_subject: &'static str,
|
||||
pub welcome_body: &'static str,
|
||||
pub email_verification_subject: &'static str,
|
||||
pub email_verification_body: &'static str,
|
||||
pub password_reset_subject: &'static str,
|
||||
pub password_reset_body: &'static str,
|
||||
pub email_update_subject: &'static str,
|
||||
@@ -30,6 +28,8 @@ pub struct NotificationStrings {
|
||||
pub signup_verification_body: &'static str,
|
||||
pub legacy_login_subject: &'static str,
|
||||
pub legacy_login_body: &'static str,
|
||||
pub migration_verification_subject: &'static str,
|
||||
pub migration_verification_body: &'static str,
|
||||
}
|
||||
|
||||
pub fn get_strings(locale: &str) -> &'static NotificationStrings {
|
||||
@@ -46,12 +46,10 @@ pub fn get_strings(locale: &str) -> &'static NotificationStrings {
|
||||
static STRINGS_EN: NotificationStrings = NotificationStrings {
|
||||
welcome_subject: "Welcome to {hostname}",
|
||||
welcome_body: "Welcome to {hostname}!\n\nYour handle is: @{handle}\n\nThank you for joining us.",
|
||||
email_verification_subject: "Verify your email - {hostname}",
|
||||
email_verification_body: "Hello @{handle},\n\nYour email verification code is: {code}\n\nThis code will expire in 10 minutes.\n\nIf you did not request this, please ignore this email.",
|
||||
password_reset_subject: "Password Reset - {hostname}",
|
||||
password_reset_body: "Hello @{handle},\n\nYour password reset code is: {code}\n\nThis code will expire in 10 minutes.\n\nIf you did not request this, please ignore this message.",
|
||||
email_update_subject: "Confirm your new email - {hostname}",
|
||||
email_update_body: "Hello @{handle},\n\nYour email update confirmation code is: {code}\n\nThis code will expire in 10 minutes.\n\nIf you did not request this, please ignore this email.",
|
||||
email_update_body: "Hello @{handle},\n\nYour verification code is:\n{code}\n\nCopy the code above and enter it at:\n{verify_page}\n\nThis code will expire in 10 minutes.\n\nIf you did not request this, please ignore this email.\n\n(Or if you like to live dangerously: {verify_link})",
|
||||
account_deletion_subject: "Account Deletion Request - {hostname}",
|
||||
account_deletion_body: "Hello @{handle},\n\nYour account deletion confirmation code is: {code}\n\nThis code will expire in 10 minutes.\n\nIf you did not request this, please secure your account immediately.",
|
||||
plc_operation_subject: "{hostname} - PLC Operation Token",
|
||||
@@ -61,20 +59,20 @@ static STRINGS_EN: NotificationStrings = NotificationStrings {
|
||||
passkey_recovery_subject: "Account Recovery - {hostname}",
|
||||
passkey_recovery_body: "Hello @{handle},\n\nYou requested to recover your passkey-only account.\n\nClick the link below to set a temporary password and regain access:\n{url}\n\nThis link will expire in 1 hour.\n\nIf you did not request this, please ignore this message. Your account remains secure.",
|
||||
signup_verification_subject: "Verify your account - {hostname}",
|
||||
signup_verification_body: "Welcome! Your account verification code is: {code}\n\nThis code will expire in 30 minutes.\n\nEnter this code to complete your registration on {hostname}.",
|
||||
signup_verification_body: "Welcome! Your verification code is:\n{code}\n\nCopy the code above and enter it at:\n{verify_page}\n\nThis code will expire in 30 minutes.\n\nIf you did not create an account on {hostname}, please ignore this message.\n\n(Or if you like to live dangerously: {verify_link})",
|
||||
legacy_login_subject: "Security Alert: Legacy Login Detected - {hostname}",
|
||||
legacy_login_body: "Hello @{handle},\n\nA login to your account was detected using a legacy app (like Bluesky) that doesn't support TOTP verification.\n\nDetails:\n- Time: {timestamp}\n- IP Address: {ip}\n\nYour TOTP protection was bypassed for this login. The session has limited permissions for sensitive operations.\n\nIf this wasn't you, please:\n1. Change your password immediately\n2. Review your active sessions\n3. Consider disabling legacy app logins in your security settings\n\nStay safe,\n{hostname}",
|
||||
migration_verification_subject: "Verify your email - {hostname}",
|
||||
migration_verification_body: "Welcome to {hostname}!\n\nYour account has been migrated successfully. To complete the setup, please verify your email address.\n\nYour verification code is:\n{code}\n\nCopy the code above and enter it at:\n{verify_page}\n\nThis code will expire in 48 hours.\n\nIf you did not migrate your account, please ignore this email.\n\n(Or if you like to live dangerously: {verify_link})",
|
||||
};
|
||||
|
||||
static STRINGS_ZH: NotificationStrings = NotificationStrings {
|
||||
welcome_subject: "欢迎加入 {hostname}",
|
||||
welcome_body: "欢迎加入 {hostname}!\n\n您的用户名是:@{handle}\n\n感谢您的加入。",
|
||||
email_verification_subject: "验证您的邮箱 - {hostname}",
|
||||
email_verification_body: "您好 @{handle},\n\n您的邮箱验证码是:{code}\n\n此验证码将在10分钟后过期。\n\n如果这不是您的操作,请忽略此邮件。",
|
||||
password_reset_subject: "密码重置 - {hostname}",
|
||||
password_reset_body: "您好 @{handle},\n\n您的密码重置验证码是:{code}\n\n此验证码将在10分钟后过期。\n\n如果这不是您的操作,请忽略此消息。",
|
||||
email_update_subject: "确认您的新邮箱 - {hostname}",
|
||||
email_update_body: "您好 @{handle},\n\n您的邮箱更新确认码是:{code}\n\n此验证码将在10分钟后过期。\n\n如果这不是您的操作,请忽略此邮件。",
|
||||
email_update_body: "您好 @{handle},\n\n您的验证码是:\n{code}\n\n复制上述验证码并在此输入:\n{verify_page}\n\n此验证码将在10分钟后过期。\n\n如果这不是您的操作,请忽略此邮件。\n\n(或者直接点击链接:{verify_link})",
|
||||
account_deletion_subject: "账户删除请求 - {hostname}",
|
||||
account_deletion_body: "您好 @{handle},\n\n您的账户删除确认码是:{code}\n\n此验证码将在10分钟后过期。\n\n如果这不是您的操作,请立即保护您的账户。",
|
||||
plc_operation_subject: "{hostname} - PLC 操作令牌",
|
||||
@@ -84,20 +82,20 @@ static STRINGS_ZH: NotificationStrings = NotificationStrings {
|
||||
passkey_recovery_subject: "账户恢复 - {hostname}",
|
||||
passkey_recovery_body: "您好 @{handle},\n\n您请求恢复仅通行密钥账户的访问权限。\n\n点击以下链接设置临时密码并恢复访问:\n{url}\n\n此链接将在1小时后过期。\n\n如果这不是您的操作,请忽略此消息。您的账户仍然安全。",
|
||||
signup_verification_subject: "验证您的账户 - {hostname}",
|
||||
signup_verification_body: "欢迎!您的账户验证码是:{code}\n\n此验证码将在30分钟后过期。\n\n请输入此验证码完成在 {hostname} 上的注册。",
|
||||
signup_verification_body: "欢迎!您的验证码是:\n{code}\n\n复制上述验证码并在此输入:\n{verify_page}\n\n此验证码将在30分钟后过期。\n\n如果您没有在 {hostname} 上创建账户,请忽略此消息。\n\n(或者直接点击链接:{verify_link})",
|
||||
legacy_login_subject: "安全提醒:检测到传统应用登录 - {hostname}",
|
||||
legacy_login_body: "您好 @{handle},\n\n检测到使用不支持 TOTP 验证的传统应用(如 Bluesky)登录您的账户。\n\n详细信息:\n- 时间:{timestamp}\n- IP 地址:{ip}\n\n此次登录绕过了 TOTP 保护。该会话对敏感操作的权限有限。\n\n如果这不是您的操作,请:\n1. 立即更改密码\n2. 检查您的活跃会话\n3. 考虑在安全设置中禁用传统应用登录\n\n请注意安全,\n{hostname}",
|
||||
migration_verification_subject: "验证您的邮箱 - {hostname}",
|
||||
migration_verification_body: "欢迎来到 {hostname}!\n\n您的账户已成功迁移。要完成设置,请验证您的邮箱地址。\n\n您的验证码是:\n{code}\n\n复制上述验证码并在此输入:\n{verify_page}\n\n此验证码将在 48 小时后过期。\n\n如果您没有迁移账户,请忽略此邮件。\n\n(或者直接点击链接:{verify_link})",
|
||||
};
|
||||
|
||||
static STRINGS_JA: NotificationStrings = NotificationStrings {
|
||||
welcome_subject: "{hostname} へようこそ",
|
||||
welcome_body: "{hostname} へようこそ!\n\nお客様のハンドル:@{handle}\n\nご登録ありがとうございます。",
|
||||
email_verification_subject: "メール認証 - {hostname}",
|
||||
email_verification_body: "@{handle} 様\n\nメール認証コードは:{code}\n\nこのコードは10分後に期限切れとなります。\n\nこの操作に心当たりがない場合は、このメールを無視してください。",
|
||||
password_reset_subject: "パスワードリセット - {hostname}",
|
||||
password_reset_body: "@{handle} 様\n\nパスワードリセットコードは:{code}\n\nこのコードは10分後に期限切れとなります。\n\nこの操作に心当たりがない場合は、このメッセージを無視してください。",
|
||||
email_update_subject: "新しいメールアドレスの確認 - {hostname}",
|
||||
email_update_body: "@{handle} 様\n\nメールアドレス更新の確認コードは:{code}\n\nこのコードは10分後に期限切れとなります。\n\nこの操作に心当たりがない場合は、このメールを無視してください。",
|
||||
email_update_body: "@{handle} 様\n\n確認コードは:\n{code}\n\n上記のコードをコピーして、こちらで入力してください:\n{verify_page}\n\nこのコードは10分後に期限切れとなります。\n\nこの操作に心当たりがない場合は、このメールを無視してください。\n\n(自己責任でワンクリック認証:{verify_link})",
|
||||
account_deletion_subject: "アカウント削除リクエスト - {hostname}",
|
||||
account_deletion_body: "@{handle} 様\n\nアカウント削除の確認コードは:{code}\n\nこのコードは10分後に期限切れとなります。\n\nこの操作に心当たりがない場合は、直ちにアカウントを保護してください。",
|
||||
plc_operation_subject: "{hostname} - PLC 操作トークン",
|
||||
@@ -107,20 +105,20 @@ static STRINGS_JA: NotificationStrings = NotificationStrings {
|
||||
passkey_recovery_subject: "アカウント復旧 - {hostname}",
|
||||
passkey_recovery_body: "@{handle} 様\n\nパスキー専用アカウントの復旧をリクエストされました。\n\n以下のリンクをクリックして一時パスワードを設定し、アクセスを回復してください:\n{url}\n\nこのリンクは1時間後に期限切れとなります。\n\nこの操作に心当たりがない場合は、このメッセージを無視してください。アカウントは安全なままです。",
|
||||
signup_verification_subject: "アカウント認証 - {hostname}",
|
||||
signup_verification_body: "ようこそ!アカウント認証コードは:{code}\n\nこのコードは30分後に期限切れとなります。\n\n{hostname} への登録を完了するには、このコードを入力してください。",
|
||||
signup_verification_body: "ようこそ!認証コードは:\n{code}\n\n上記のコードをコピーして、こちらで入力してください:\n{verify_page}\n\nこのコードは30分後に期限切れとなります。\n\n{hostname} でアカウントを作成していない場合は、このメールを無視してください。\n\n(自己責任でワンクリック認証:{verify_link})",
|
||||
legacy_login_subject: "セキュリティ警告:レガシーログインを検出 - {hostname}",
|
||||
legacy_login_body: "@{handle} 様\n\nTOTP 認証に対応していないレガシーアプリ(Bluesky など)からのログインが検出されました。\n\n詳細:\n- 時刻:{timestamp}\n- IP アドレス:{ip}\n\nこのログインでは TOTP 保護がバイパスされました。このセッションは機密操作に対する権限が制限されています。\n\n心当たりがない場合は:\n1. 直ちにパスワードを変更してください\n2. アクティブなセッションを確認してください\n3. セキュリティ設定でレガシーアプリのログインを無効にすることを検討してください\n\nご注意ください。\n{hostname}",
|
||||
migration_verification_subject: "メールアドレスの認証 - {hostname}",
|
||||
migration_verification_body: "{hostname} へようこそ!\n\nアカウントの移行が完了しました。設定を完了するには、メールアドレスを認証してください。\n\n認証コードは:\n{code}\n\n上記のコードをコピーして、こちらで入力してください:\n{verify_page}\n\nこのコードは48時間後に期限切れとなります。\n\nアカウントを移行していない場合は、このメールを無視してください。\n\n(自己責任でワンクリック認証:{verify_link})",
|
||||
};
|
||||
|
||||
static STRINGS_KO: NotificationStrings = NotificationStrings {
|
||||
welcome_subject: "{hostname}에 오신 것을 환영합니다",
|
||||
welcome_body: "{hostname}에 오신 것을 환영합니다!\n\n회원님의 핸들은: @{handle}\n\n가입해 주셔서 감사합니다.",
|
||||
email_verification_subject: "이메일 인증 - {hostname}",
|
||||
email_verification_body: "안녕하세요 @{handle}님,\n\n이메일 인증 코드는: {code}\n\n이 코드는 10분 후에 만료됩니다.\n\n요청하지 않으셨다면 이 이메일을 무시하세요.",
|
||||
password_reset_subject: "비밀번호 재설정 - {hostname}",
|
||||
password_reset_body: "안녕하세요 @{handle}님,\n\n비밀번호 재설정 코드는: {code}\n\n이 코드는 10분 후에 만료됩니다.\n\n요청하지 않으셨다면 이 메시지를 무시하세요.",
|
||||
email_update_subject: "새 이메일 확인 - {hostname}",
|
||||
email_update_body: "안녕하세요 @{handle}님,\n\n이메일 업데이트 확인 코드는: {code}\n\n이 코드는 10분 후에 만료됩니다.\n\n요청하지 않으셨다면 이 이메일을 무시하세요.",
|
||||
email_update_subject: "새 이메일 주소 확인 - {hostname}",
|
||||
email_update_body: "안녕하세요 @{handle}님,\n\n인증 코드는:\n{code}\n\n위 코드를 복사하여 여기에 입력하세요:\n{verify_page}\n\n이 코드는 10분 후에 만료됩니다.\n\n요청하지 않으셨다면 이 이메일을 무시하세요.\n\n(위험을 감수하고 원클릭 인증: {verify_link})",
|
||||
account_deletion_subject: "계정 삭제 요청 - {hostname}",
|
||||
account_deletion_body: "안녕하세요 @{handle}님,\n\n계정 삭제 확인 코드는: {code}\n\n이 코드는 10분 후에 만료됩니다.\n\n요청하지 않으셨다면 즉시 계정을 보호하세요.",
|
||||
plc_operation_subject: "{hostname} - PLC 작업 토큰",
|
||||
@@ -130,20 +128,20 @@ static STRINGS_KO: NotificationStrings = NotificationStrings {
|
||||
passkey_recovery_subject: "계정 복구 - {hostname}",
|
||||
passkey_recovery_body: "안녕하세요 @{handle}님,\n\n패스키 전용 계정 복구를 요청하셨습니다.\n\n아래 링크를 클릭하여 임시 비밀번호를 설정하고 액세스를 복구하세요:\n{url}\n\n이 링크는 1시간 후에 만료됩니다.\n\n요청하지 않으셨다면 이 메시지를 무시하세요. 계정은 안전하게 유지됩니다.",
|
||||
signup_verification_subject: "계정 인증 - {hostname}",
|
||||
signup_verification_body: "환영합니다! 계정 인증 코드는: {code}\n\n이 코드는 30분 후에 만료됩니다.\n\n{hostname}에서 등록을 완료하려면 이 코드를 입력하세요.",
|
||||
signup_verification_body: "환영합니다! 인증 코드는:\n{code}\n\n위 코드를 복사하여 여기에 입력하세요:\n{verify_page}\n\n이 코드는 30분 후에 만료됩니다.\n\n{hostname}에서 계정을 만들지 않았다면 이 이메일을 무시하세요.\n\n(위험을 감수하고 원클릭 인증: {verify_link})",
|
||||
legacy_login_subject: "보안 알림: 레거시 로그인 감지 - {hostname}",
|
||||
legacy_login_body: "안녕하세요 @{handle}님,\n\nTOTP 인증을 지원하지 않는 레거시 앱(예: Bluesky)을 사용한 로그인이 감지되었습니다.\n\n세부 정보:\n- 시간: {timestamp}\n- IP 주소: {ip}\n\n이 로그인에서 TOTP 보호가 우회되었습니다. 이 세션은 민감한 작업에 대한 권한이 제한됩니다.\n\n본인이 아닌 경우:\n1. 즉시 비밀번호를 변경하세요\n2. 활성 세션을 검토하세요\n3. 보안 설정에서 레거시 앱 로그인 비활성화를 고려하세요\n\n{hostname} 드림",
|
||||
migration_verification_subject: "이메일 인증 - {hostname}",
|
||||
migration_verification_body: "{hostname}에 오신 것을 환영합니다!\n\n계정 마이그레이션이 완료되었습니다. 설정을 완료하려면 이메일 주소를 인증하세요.\n\n인증 코드는:\n{code}\n\n위 코드를 복사하여 여기에 입력하세요:\n{verify_page}\n\n이 코드는 48시간 후에 만료됩니다.\n\n계정을 마이그레이션하지 않았다면 이 이메일을 무시하세요.\n\n(위험을 감수하고 원클릭 인증: {verify_link})",
|
||||
};
|
||||
|
||||
static STRINGS_SV: NotificationStrings = NotificationStrings {
|
||||
welcome_subject: "Välkommen till {hostname}",
|
||||
welcome_body: "Välkommen till {hostname}!\n\nDitt användarnamn är: @{handle}\n\nTack för att du gick med.",
|
||||
email_verification_subject: "Verifiera din e-post - {hostname}",
|
||||
email_verification_body: "Hej @{handle},\n\nDin e-postverifieringskod är: {code}\n\nDenna kod upphör om 10 minuter.\n\nOm du inte begärde detta kan du ignorera detta meddelande.",
|
||||
password_reset_subject: "Lösenordsåterställning - {hostname}",
|
||||
password_reset_body: "Hej @{handle},\n\nDin kod för lösenordsåterställning är: {code}\n\nDenna kod upphör om 10 minuter.\n\nOm du inte begärde detta kan du ignorera detta meddelande.",
|
||||
email_update_subject: "Bekräfta din nya e-post - {hostname}",
|
||||
email_update_body: "Hej @{handle},\n\nDin bekräftelsekod för e-postuppdatering är: {code}\n\nDenna kod upphör om 10 minuter.\n\nOm du inte begärde detta kan du ignorera detta meddelande.",
|
||||
email_update_body: "Hej @{handle},\n\nDin verifieringskod är:\n{code}\n\nKopiera koden ovan och ange den på:\n{verify_page}\n\nDenna kod upphör om 10 minuter.\n\nOm du inte begärde detta kan du ignorera detta meddelande.\n\n(Eller om du gillar att leva farligt: {verify_link})",
|
||||
account_deletion_subject: "Begäran om kontoradering - {hostname}",
|
||||
account_deletion_body: "Hej @{handle},\n\nDin bekräftelsekod för kontoradering är: {code}\n\nDenna kod upphör om 10 minuter.\n\nOm du inte begärde detta, skydda ditt konto omedelbart.",
|
||||
plc_operation_subject: "{hostname} - PLC-operationstoken",
|
||||
@@ -153,20 +151,20 @@ static STRINGS_SV: NotificationStrings = NotificationStrings {
|
||||
passkey_recovery_subject: "Kontoåterställning - {hostname}",
|
||||
passkey_recovery_body: "Hej @{handle},\n\nDu begärde att återställa ditt endast nyckelkonto.\n\nKlicka på länken nedan för att ställa in ett tillfälligt lösenord och återfå åtkomst:\n{url}\n\nDenna länk upphör om 1 timme.\n\nOm du inte begärde detta kan du ignorera detta meddelande. Ditt konto förblir säkert.",
|
||||
signup_verification_subject: "Verifiera ditt konto - {hostname}",
|
||||
signup_verification_body: "Välkommen! Din kontoverifieringskod är: {code}\n\nDenna kod upphör om 30 minuter.\n\nAnge denna kod för att slutföra din registrering på {hostname}.",
|
||||
signup_verification_body: "Välkommen! Din verifieringskod är:\n{code}\n\nKopiera koden ovan och ange den på:\n{verify_page}\n\nDenna kod upphör om 30 minuter.\n\nOm du inte skapade ett konto på {hostname}, ignorera detta meddelande.\n\n(Eller om du gillar att leva farligt: {verify_link})",
|
||||
legacy_login_subject: "Säkerhetsvarning: Äldre inloggning upptäckt - {hostname}",
|
||||
legacy_login_body: "Hej @{handle},\n\nEn inloggning till ditt konto upptäcktes med en äldre app (som Bluesky) som inte stöder TOTP-verifiering.\n\nDetaljer:\n- Tid: {timestamp}\n- IP-adress: {ip}\n\nDitt TOTP-skydd kringgicks för denna inloggning. Sessionen har begränsade behörigheter för känsliga operationer.\n\nOm detta inte var du:\n1. Ändra ditt lösenord omedelbart\n2. Granska dina aktiva sessioner\n3. Överväg att inaktivera äldre appinloggningar i dina säkerhetsinställningar\n\nVar försiktig,\n{hostname}",
|
||||
migration_verification_subject: "Verifiera din e-post - {hostname}",
|
||||
migration_verification_body: "Välkommen till {hostname}!\n\nDitt konto har migrerats framgångsrikt. För att slutföra installationen, verifiera din e-postadress.\n\nDin verifieringskod är:\n{code}\n\nKopiera koden ovan och ange den på:\n{verify_page}\n\nDenna kod upphör om 48 timmar.\n\nOm du inte migrerade ditt konto kan du ignorera detta meddelande.\n\n(Eller om du gillar att leva farligt: {verify_link})",
|
||||
};
|
||||
|
||||
static STRINGS_FI: NotificationStrings = NotificationStrings {
|
||||
welcome_subject: "Tervetuloa palveluun {hostname}",
|
||||
welcome_body: "Tervetuloa palveluun {hostname}!\n\nKäyttäjänimesi on: @{handle}\n\nKiitos liittymisestä.",
|
||||
email_verification_subject: "Vahvista sähköpostisi - {hostname}",
|
||||
email_verification_body: "Hei @{handle},\n\nSähköpostin vahvistuskoodisi on: {code}\n\nTämä koodi vanhenee 10 minuutissa.\n\nJos et pyytänyt tätä, voit jättää tämän viestin huomiotta.",
|
||||
password_reset_subject: "Salasanan palautus - {hostname}",
|
||||
password_reset_body: "Hei @{handle},\n\nSalasanan palautuskoodisi on: {code}\n\nTämä koodi vanhenee 10 minuutissa.\n\nJos et pyytänyt tätä, voit jättää tämän viestin huomiotta.",
|
||||
email_update_subject: "Vahvista uusi sähköpostiosoitteesi - {hostname}",
|
||||
email_update_body: "Hei @{handle},\n\nSähköpostin päivityksen vahvistuskoodisi on: {code}\n\nTämä koodi vanhenee 10 minuutissa.\n\nJos et pyytänyt tätä, voit jättää tämän viestin huomiotta.",
|
||||
email_update_subject: "Vahvista uusi sähköpostisi - {hostname}",
|
||||
email_update_body: "Hei @{handle},\n\nVahvistuskoodisi on:\n{code}\n\nKopioi koodi yllä ja syötä se osoitteessa:\n{verify_page}\n\nTämä koodi vanhenee 10 minuutissa.\n\nJos et pyytänyt tätä, voit jättää tämän viestin huomiotta.\n\n(Tai jos pidät vaarallisesta elämästä: {verify_link})",
|
||||
account_deletion_subject: "Tilin poistopyyntö - {hostname}",
|
||||
account_deletion_body: "Hei @{handle},\n\nTilin poiston vahvistuskoodisi on: {code}\n\nTämä koodi vanhenee 10 minuutissa.\n\nJos et pyytänyt tätä, suojaa tilisi välittömästi.",
|
||||
plc_operation_subject: "{hostname} - PLC-toimintotunniste",
|
||||
@@ -176,9 +174,11 @@ static STRINGS_FI: NotificationStrings = NotificationStrings {
|
||||
passkey_recovery_subject: "Tilin palautus - {hostname}",
|
||||
passkey_recovery_body: "Hei @{handle},\n\nPyysit palauttamaan vain pääsyavaintilisi.\n\nKlikkaa alla olevaa linkkiä asettaaksesi väliaikaisen salasanan ja saadaksesi pääsyn takaisin:\n{url}\n\nTämä linkki vanhenee tunnissa.\n\nJos et pyytänyt tätä, voit jättää tämän viestin huomiotta. Tilisi pysyy turvassa.",
|
||||
signup_verification_subject: "Vahvista tilisi - {hostname}",
|
||||
signup_verification_body: "Tervetuloa! Tilin vahvistuskoodisi on: {code}\n\nTämä koodi vanhenee 30 minuutissa.\n\nSyötä tämä koodi viimeistelläksesi rekisteröintisi palveluun {hostname}.",
|
||||
signup_verification_body: "Tervetuloa! Vahvistuskoodisi on:\n{code}\n\nKopioi koodi yllä ja syötä se osoitteessa:\n{verify_page}\n\nTämä koodi vanhenee 30 minuutissa.\n\nJos et luonut tiliä palveluun {hostname}, jätä tämä viesti huomiotta.\n\n(Tai jos pidät vaarallisesta elämästä: {verify_link})",
|
||||
legacy_login_subject: "Turvallisuushälytys: Vanha kirjautuminen havaittu - {hostname}",
|
||||
legacy_login_body: "Hei @{handle},\n\nTilillesi havaittiin kirjautuminen vanhalla sovelluksella (kuten Bluesky), joka ei tue TOTP-vahvistusta.\n\nTiedot:\n- Aika: {timestamp}\n- IP-osoite: {ip}\n\nTOTP-suojauksesi ohitettiin tässä kirjautumisessa. Istunnolla on rajoitetut oikeudet arkaluontoisiin toimintoihin.\n\nJos tämä et ollut sinä:\n1. Vaihda salasanasi välittömästi\n2. Tarkista aktiiviset istuntosi\n3. Harkitse vanhojen sovellusten kirjautumisen poistamista käytöstä turvallisuusasetuksissa\n\nOle varovainen,\n{hostname}",
|
||||
migration_verification_subject: "Vahvista sähköpostisi - {hostname}",
|
||||
migration_verification_body: "Tervetuloa palveluun {hostname}!\n\nTilisi on siirretty onnistuneesti. Viimeistele asennus vahvistamalla sähköpostiosoitteesi.\n\nVahvistuskoodisi on:\n{code}\n\nKopioi koodi yllä ja syötä se osoitteessa:\n{verify_page}\n\nTämä koodi vanhenee 48 tunnissa.\n\nJos et siirtänyt tiliäsi, voit jättää tämän viestin huomiotta.\n\n(Tai jos pidät vaarallisesta elämästä: {verify_link})",
|
||||
};
|
||||
|
||||
pub fn format_message(template: &str, vars: &[(&str, &str)]) -> String {
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ pub use sender::{
|
||||
|
||||
pub use service::{
|
||||
CommsService, channel_display_name, enqueue_2fa_code, enqueue_account_deletion, enqueue_comms,
|
||||
enqueue_email_update, enqueue_email_verification, enqueue_passkey_recovery,
|
||||
enqueue_email_update, enqueue_migration_verification, enqueue_passkey_recovery,
|
||||
enqueue_password_reset, enqueue_plc_operation, enqueue_signup_verification, enqueue_welcome,
|
||||
queue_legacy_login_notification,
|
||||
};
|
||||
|
||||
+81
-34
@@ -313,34 +313,6 @@ pub async fn enqueue_welcome(
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn enqueue_email_verification(
|
||||
db: &PgPool,
|
||||
user_id: Uuid,
|
||||
email: &str,
|
||||
handle: &str,
|
||||
code: &str,
|
||||
hostname: &str,
|
||||
) -> Result<Uuid, sqlx::Error> {
|
||||
let prefs = get_user_comms_prefs(db, user_id).await?;
|
||||
let strings = get_strings(&prefs.locale);
|
||||
let body = format_message(
|
||||
strings.email_verification_body,
|
||||
&[("handle", handle), ("code", code)],
|
||||
);
|
||||
let subject = format_message(strings.email_verification_subject, &[("hostname", hostname)]);
|
||||
enqueue_comms(
|
||||
db,
|
||||
NewComms::email(
|
||||
user_id,
|
||||
super::types::CommsType::EmailVerification,
|
||||
email.to_string(),
|
||||
subject,
|
||||
body,
|
||||
),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn enqueue_password_reset(
|
||||
db: &PgPool,
|
||||
user_id: Uuid,
|
||||
@@ -378,9 +350,21 @@ pub async fn enqueue_email_update(
|
||||
) -> Result<Uuid, sqlx::Error> {
|
||||
let prefs = get_user_comms_prefs(db, user_id).await?;
|
||||
let strings = get_strings(&prefs.locale);
|
||||
let encoded_email = urlencoding::encode(new_email);
|
||||
let encoded_token = urlencoding::encode(code);
|
||||
let verify_page = format!("https://{}/#/verify", hostname);
|
||||
let verify_link = format!(
|
||||
"https://{}/#/verify?token={}&identifier={}",
|
||||
hostname, encoded_token, encoded_email
|
||||
);
|
||||
let body = format_message(
|
||||
strings.email_update_body,
|
||||
&[("handle", handle), ("code", code)],
|
||||
&[
|
||||
("handle", handle),
|
||||
("code", code),
|
||||
("verify_page", &verify_page),
|
||||
("verify_link", &verify_link),
|
||||
],
|
||||
);
|
||||
let subject = format_message(strings.email_update_subject, &[("hostname", hostname)]);
|
||||
enqueue_comms(
|
||||
@@ -530,14 +514,33 @@ pub async fn enqueue_signup_verification(
|
||||
_ => CommsChannel::Email,
|
||||
};
|
||||
let strings = get_strings(locale.unwrap_or("en"));
|
||||
let (verify_page, verify_link) = if comms_channel == CommsChannel::Email {
|
||||
let encoded_email = urlencoding::encode(recipient);
|
||||
let encoded_token = urlencoding::encode(code);
|
||||
(
|
||||
format!("https://{}/#/verify", hostname),
|
||||
format!(
|
||||
"https://{}/#/verify?token={}&identifier={}",
|
||||
hostname, encoded_token, encoded_email
|
||||
),
|
||||
)
|
||||
} else {
|
||||
(String::new(), String::new())
|
||||
};
|
||||
let body = format_message(
|
||||
strings.signup_verification_body,
|
||||
&[("code", code), ("hostname", &hostname)],
|
||||
&[
|
||||
("code", code),
|
||||
("hostname", &hostname),
|
||||
("verify_page", &verify_page),
|
||||
("verify_link", &verify_link),
|
||||
],
|
||||
);
|
||||
let subject = match comms_channel {
|
||||
CommsChannel::Email => {
|
||||
Some(format_message(strings.signup_verification_subject, &[("hostname", &hostname)]))
|
||||
}
|
||||
CommsChannel::Email => Some(format_message(
|
||||
strings.signup_verification_subject,
|
||||
&[("hostname", &hostname)],
|
||||
)),
|
||||
_ => None,
|
||||
};
|
||||
enqueue_comms(
|
||||
@@ -554,6 +557,48 @@ pub async fn enqueue_signup_verification(
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn enqueue_migration_verification(
|
||||
db: &PgPool,
|
||||
user_id: Uuid,
|
||||
email: &str,
|
||||
token: &str,
|
||||
hostname: &str,
|
||||
) -> Result<Uuid, sqlx::Error> {
|
||||
let prefs = get_user_comms_prefs(db, user_id).await?;
|
||||
let strings = get_strings(&prefs.locale);
|
||||
let encoded_email = urlencoding::encode(email);
|
||||
let encoded_token = urlencoding::encode(token);
|
||||
let verify_page = format!("https://{}/#/verify", hostname);
|
||||
let verify_link = format!(
|
||||
"https://{}/#/verify?token={}&identifier={}",
|
||||
hostname, encoded_token, encoded_email
|
||||
);
|
||||
let body = format_message(
|
||||
strings.migration_verification_body,
|
||||
&[
|
||||
("code", token),
|
||||
("hostname", hostname),
|
||||
("verify_page", &verify_page),
|
||||
("verify_link", &verify_link),
|
||||
],
|
||||
);
|
||||
let subject = format_message(
|
||||
strings.migration_verification_subject,
|
||||
&[("hostname", hostname)],
|
||||
);
|
||||
enqueue_comms(
|
||||
db,
|
||||
NewComms::email(
|
||||
user_id,
|
||||
super::types::CommsType::MigrationVerification,
|
||||
email.to_string(),
|
||||
subject,
|
||||
body,
|
||||
),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn queue_legacy_login_notification(
|
||||
db: &PgPool,
|
||||
user_id: Uuid,
|
||||
@@ -563,7 +608,9 @@ pub async fn queue_legacy_login_notification(
|
||||
) -> Result<Uuid, sqlx::Error> {
|
||||
let prefs = get_user_comms_prefs(db, user_id).await?;
|
||||
let strings = get_strings(&prefs.locale);
|
||||
let timestamp = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string();
|
||||
let timestamp = chrono::Utc::now()
|
||||
.format("%Y-%m-%d %H:%M:%S UTC")
|
||||
.to_string();
|
||||
let body = format_message(
|
||||
strings.legacy_login_body,
|
||||
&[
|
||||
|
||||
@@ -34,6 +34,7 @@ pub enum CommsType {
|
||||
TwoFactorCode,
|
||||
PasskeyRecovery,
|
||||
LegacyLoginAlert,
|
||||
MigrationVerification,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, FromRow)]
|
||||
|
||||
+5
-2
@@ -114,8 +114,11 @@ impl AuthConfig {
|
||||
.expect("HKDF expansion failed");
|
||||
|
||||
let mut device_cookie_key = [0u8; 32];
|
||||
hk.expand(b"tranquil-pds-device-cookie-signing", &mut device_cookie_key)
|
||||
.expect("HKDF expansion failed");
|
||||
hk.expand(
|
||||
b"tranquil-pds-device-cookie-signing",
|
||||
&mut device_cookie_key,
|
||||
)
|
||||
.expect("HKDF expansion failed");
|
||||
|
||||
AuthConfig {
|
||||
jwt_secret,
|
||||
|
||||
+12
@@ -295,6 +295,14 @@ pub fn app(state: AppState) -> Router {
|
||||
"/xrpc/com.atproto.server.reserveSigningKey",
|
||||
post(api::server::reserve_signing_key),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.atproto.server.verifyMigrationEmail",
|
||||
post(api::server::verify_migration_email),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.atproto.server.resendMigrationVerification",
|
||||
post(api::server::resend_migration_verification),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.atproto.identity.updateHandle",
|
||||
post(api::identity::update_handle),
|
||||
@@ -550,6 +558,10 @@ pub fn app(state: AppState) -> Router {
|
||||
"/xrpc/com.tranquil.account.confirmChannelVerification",
|
||||
post(api::verification::confirm_channel_verification),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.tranquil.account.verifyToken",
|
||||
post(api::server::verify_token),
|
||||
)
|
||||
.route("/xrpc/{*method}", any(api::proxy::proxy_handler))
|
||||
.layer(middleware::from_fn(metrics::metrics_middleware))
|
||||
.layer(
|
||||
|
||||
@@ -172,7 +172,8 @@ pub async fn frontend_client_metadata(
|
||||
"refresh_token".to_string(),
|
||||
],
|
||||
response_types: vec!["code".to_string()],
|
||||
scope: "atproto repo:*?action=create repo:*?action=update repo:*?action=delete blob:*/*".to_string(),
|
||||
scope: "atproto repo:*?action=create repo:*?action=update repo:*?action=delete blob:*/*"
|
||||
.to_string(),
|
||||
token_endpoint_auth_method: "none".to_string(),
|
||||
application_type: "web".to_string(),
|
||||
dpop_bound_access_tokens: true,
|
||||
|
||||
+4
-3
@@ -74,9 +74,10 @@ impl RateLimiters {
|
||||
email_update: Arc::new(RateLimiter::keyed(Quota::per_hour(
|
||||
NonZeroU32::new(5).unwrap(),
|
||||
))),
|
||||
totp_verify: Arc::new(RateLimiter::keyed(Quota::with_period(std::time::Duration::from_secs(60))
|
||||
.unwrap()
|
||||
.allow_burst(NonZeroU32::new(5).unwrap()),
|
||||
totp_verify: Arc::new(RateLimiter::keyed(
|
||||
Quota::with_period(std::time::Duration::from_secs(60))
|
||||
.unwrap()
|
||||
.allow_burst(NonZeroU32::new(5).unwrap()),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
+51
-13
@@ -458,19 +458,57 @@ pub fn validate_password(password: &str) -> Result<(), PasswordValidationError>
|
||||
|
||||
fn is_common_password(password: &str) -> bool {
|
||||
const COMMON_PASSWORDS: &[&str] = &[
|
||||
"password", "Password1", "Password123", "Passw0rd", "Passw0rd!",
|
||||
"12345678", "123456789", "1234567890",
|
||||
"qwerty123", "Qwerty123", "qwertyui", "Qwertyui",
|
||||
"letmein1", "Letmein1", "welcome1", "Welcome1",
|
||||
"admin123", "Admin123", "password1", "Password1!",
|
||||
"iloveyou", "Iloveyou1", "monkey123", "Monkey123",
|
||||
"dragon12", "Dragon123", "master12", "Master123",
|
||||
"login123", "Login123", "abc12345", "Abc12345",
|
||||
"football", "Football1", "baseball", "Baseball1",
|
||||
"trustno1", "Trustno1", "sunshine", "Sunshine1",
|
||||
"princess", "Princess1", "computer", "Computer1",
|
||||
"whatever", "Whatever1", "nintendo", "Nintendo1",
|
||||
"bluesky1", "Bluesky1", "Bluesky123",
|
||||
"password",
|
||||
"Password1",
|
||||
"Password123",
|
||||
"Passw0rd",
|
||||
"Passw0rd!",
|
||||
"12345678",
|
||||
"123456789",
|
||||
"1234567890",
|
||||
"qwerty123",
|
||||
"Qwerty123",
|
||||
"qwertyui",
|
||||
"Qwertyui",
|
||||
"letmein1",
|
||||
"Letmein1",
|
||||
"welcome1",
|
||||
"Welcome1",
|
||||
"admin123",
|
||||
"Admin123",
|
||||
"password1",
|
||||
"Password1!",
|
||||
"iloveyou",
|
||||
"Iloveyou1",
|
||||
"monkey123",
|
||||
"Monkey123",
|
||||
"dragon12",
|
||||
"Dragon123",
|
||||
"master12",
|
||||
"Master123",
|
||||
"login123",
|
||||
"Login123",
|
||||
"abc12345",
|
||||
"Abc12345",
|
||||
"football",
|
||||
"Football1",
|
||||
"baseball",
|
||||
"Baseball1",
|
||||
"trustno1",
|
||||
"Trustno1",
|
||||
"sunshine",
|
||||
"Sunshine1",
|
||||
"princess",
|
||||
"Princess1",
|
||||
"computer",
|
||||
"Computer1",
|
||||
"whatever",
|
||||
"Whatever1",
|
||||
"nintendo",
|
||||
"Nintendo1",
|
||||
"bluesky1",
|
||||
"Bluesky1",
|
||||
"Bluesky123",
|
||||
];
|
||||
|
||||
let lower = password.to_lowercase();
|
||||
|
||||
Reference in New Issue
Block a user