Better handles

This commit is contained in:
lewis
2025-12-21 17:53:25 +02:00
parent 45b6b58ad6
commit adfa9a3812
27 changed files with 408 additions and 346 deletions
+8 -8
View File
@@ -12,16 +12,16 @@ So like... make the thing unique, make it cool.
### Passkeys and 2FA
Modern passwordless authentication using WebAuthn/FIDO2, plus TOTP for defense in depth.
- [ ] passkeys table (id, did, credential_id, public_key, sign_count, created_at, last_used, friendly_name)
- [ ] user_totp table (did, secret_encrypted, verified, created_at, last_used)
- [ ] WebAuthn registration challenge generation and attestation verification
- [ ] TOTP secret generation with QR code setup flow
- [ ] Backup codes (hashed, one-time use) with recovery flow
- [ ] OAuth authorize flow: password -> 2FA (if enabled) -> passkey (as alternative)
- [x] passkeys table (id, did, credential_id, public_key, sign_count, created_at, last_used, friendly_name)
- [x] user_totp table (did, secret_encrypted, verified, created_at, last_used)
- [x] WebAuthn registration challenge generation and attestation verification
- [x] TOTP secret generation with QR code setup flow
- [x] Backup codes (hashed, one-time use) with recovery flow
- [x] OAuth authorize flow: password -> 2FA (if enabled) -> passkey (as alternative)
- [ ] Passkey-only account creation (no password)
- [ ] Settings UI for managing passkeys, TOTP, backup codes
- [x] Settings UI for managing passkeys, TOTP, backup codes
- [ ] Trusted devices option (remember this browser)
- [ ] Rate limit 2FA attempts
- [x] Rate limit 2FA attempts
- [ ] Re-auth for sensitive actions (email change, adding new auth methods)
### Delegated accounts
+6 -4
View File
@@ -341,14 +341,14 @@
/>
</div>
{#if securityStatusChecked && passkeySupported}
{#if passkeySupported && username.length >= 3}
<button
type="button"
class="passkey-btn"
class:passkey-unavailable={!hasPasskeys}
class:passkey-unavailable={!hasPasskeys || checkingSecurityStatus || !securityStatusChecked}
onclick={handlePasskeyLogin}
disabled={submitting || !hasPasskeys || !username}
title={hasPasskeys ? 'Sign in with your passkey' : 'No passkeys registered for this account'}
disabled={submitting || !hasPasskeys || !username || checkingSecurityStatus || !securityStatusChecked}
title={checkingSecurityStatus ? 'Checking passkey status...' : hasPasskeys ? 'Sign in with your passkey' : 'No passkeys registered for this account'}
>
<svg class="passkey-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M15 7a4 4 0 1 0-8 0 4 4 0 0 0 8 0z" />
@@ -358,6 +358,8 @@
<span class="passkey-text">
{#if submitting}
Authenticating...
{:else if checkingSecurityStatus || !securityStatusChecked}
Checking passkey...
{:else if hasPasskeys}
Sign in with passkey
{:else}
+9 -1
View File
@@ -50,8 +50,11 @@
}
}
let handleHasDot = $derived(handle.includes('.'))
function validateForm(): string | null {
if (!handle.trim()) return 'Handle is required'
if (handle.includes('.')) return 'Handle cannot contain dots. You can set up a custom domain handle after creating your account.'
if (!password) return 'Password is required'
if (password.length < 8) return 'Password must be at least 8 characters'
if (password !== confirmPassword) return 'Passwords do not match'
@@ -152,7 +155,9 @@
disabled={submitting}
required
/>
{#if fullHandle()}
{#if handleHasDot}
<p class="hint warning">Custom domain handles can be set up after account creation in Settings.</p>
{:else if fullHandle()}
<p class="hint">Your full handle will be: @{fullHandle()}</p>
{/if}
</div>
@@ -390,6 +395,9 @@
color: var(--text-secondary);
margin: 0.25rem 0 0 0;
}
.hint.warning {
color: var(--warning-text, #856404);
}
.verification-section {
border: 1px solid var(--border-color-light);
border-radius: 6px;
+90 -19
View File
@@ -1,6 +1,6 @@
CREATE TYPE notification_channel AS ENUM ('email', 'discord', 'telegram', 'signal');
CREATE TYPE notification_status AS ENUM ('pending', 'processing', 'sent', 'failed');
CREATE TYPE notification_type AS ENUM (
CREATE TYPE comms_channel AS ENUM ('email', 'discord', 'telegram', 'signal');
CREATE TYPE comms_status AS ENUM ('pending', 'processing', 'sent', 'failed');
CREATE TYPE comms_type AS ENUM (
'welcome',
'email_verification',
'password_reset',
@@ -8,7 +8,8 @@ CREATE TYPE notification_type AS ENUM (
'account_deletion',
'admin_email',
'plc_operation',
'two_factor_code'
'two_factor_code',
'channel_verification'
);
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -21,26 +22,26 @@ CREATE TABLE IF NOT EXISTS users (
deactivated_at TIMESTAMPTZ,
invites_disabled BOOLEAN DEFAULT FALSE,
takedown_ref TEXT,
preferred_notification_channel notification_channel NOT NULL DEFAULT 'email',
preferred_comms_channel comms_channel NOT NULL DEFAULT 'email',
password_reset_code TEXT,
password_reset_code_expires_at TIMESTAMPTZ,
email_pending_verification TEXT,
email_confirmation_code TEXT,
email_confirmation_code_expires_at TIMESTAMPTZ,
email_confirmed BOOLEAN NOT NULL DEFAULT FALSE,
email_verified BOOLEAN NOT NULL DEFAULT FALSE,
two_factor_enabled BOOLEAN NOT NULL DEFAULT FALSE,
discord_id TEXT,
discord_verified BOOLEAN NOT NULL DEFAULT FALSE,
telegram_username TEXT,
telegram_verified BOOLEAN NOT NULL DEFAULT FALSE,
signal_number TEXT,
signal_verified BOOLEAN NOT NULL DEFAULT FALSE
signal_verified BOOLEAN NOT NULL DEFAULT FALSE,
is_admin BOOLEAN NOT NULL DEFAULT FALSE,
migrated_to_pds TEXT,
migrated_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_users_password_reset_code ON users(password_reset_code) WHERE password_reset_code IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_users_email_confirmation_code ON users(email_confirmation_code) WHERE email_confirmation_code IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_users_discord_id ON users(discord_id) WHERE discord_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_users_telegram_username ON users(telegram_username) WHERE telegram_username IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_users_signal_number ON users(signal_number) WHERE signal_number IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email) WHERE email IS NOT NULL;
CREATE TABLE IF NOT EXISTS invite_codes (
code TEXT PRIMARY KEY,
available_uses INT NOT NULL DEFAULT 1,
@@ -48,6 +49,7 @@ CREATE TABLE IF NOT EXISTS invite_codes (
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
disabled BOOLEAN DEFAULT FALSE
);
CREATE INDEX IF NOT EXISTS idx_invite_codes_created_by ON invite_codes(created_by_user);
CREATE TABLE IF NOT EXISTS invite_code_uses (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code TEXT NOT NULL REFERENCES invite_codes(code),
@@ -86,6 +88,8 @@ CREATE TABLE IF NOT EXISTS records (
UNIQUE(repo_id, collection, rkey)
);
CREATE INDEX idx_records_repo_rev ON records(repo_rev);
CREATE INDEX IF NOT EXISTS idx_records_repo_collection ON records(repo_id, collection);
CREATE INDEX IF NOT EXISTS idx_records_repo_collection_created ON records(repo_id, collection, created_at DESC);
CREATE TABLE IF NOT EXISTS blobs (
cid TEXT PRIMARY KEY,
mime_type TEXT NOT NULL,
@@ -95,6 +99,7 @@ CREATE TABLE IF NOT EXISTS blobs (
takedown_ref TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_blobs_created_by_user ON blobs(created_by_user, created_at DESC);
CREATE TABLE IF NOT EXISTS app_passwords (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
@@ -104,6 +109,7 @@ CREATE TABLE IF NOT EXISTS app_passwords (
privileged BOOLEAN NOT NULL DEFAULT FALSE,
UNIQUE(user_id, name)
);
CREATE INDEX IF NOT EXISTS idx_app_passwords_user_id ON app_passwords(user_id);
CREATE TABLE reports (
id BIGINT PRIMARY KEY,
reason_type TEXT NOT NULL,
@@ -118,12 +124,12 @@ CREATE TABLE IF NOT EXISTS account_deletion_requests (
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS notification_queue (
CREATE TABLE IF NOT EXISTS comms_queue (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
channel notification_channel NOT NULL DEFAULT 'email',
notification_type notification_type NOT NULL,
status notification_status NOT NULL DEFAULT 'pending',
channel comms_channel NOT NULL DEFAULT 'email',
comms_type comms_type NOT NULL,
status comms_status NOT NULL DEFAULT 'pending',
recipient TEXT NOT NULL,
subject TEXT,
body TEXT NOT NULL,
@@ -136,10 +142,10 @@ CREATE TABLE IF NOT EXISTS notification_queue (
scheduled_for TIMESTAMPTZ NOT NULL DEFAULT NOW(),
processed_at TIMESTAMPTZ
);
CREATE INDEX idx_notification_queue_status_scheduled
ON notification_queue(status, scheduled_for)
CREATE INDEX idx_comms_queue_status_scheduled
ON comms_queue(status, scheduled_for)
WHERE status = 'pending';
CREATE INDEX idx_notification_queue_user_id ON notification_queue(user_id);
CREATE INDEX idx_comms_queue_user_id ON comms_queue(user_id);
CREATE TABLE IF NOT EXISTS reserved_signing_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
did TEXT,
@@ -160,10 +166,15 @@ CREATE TABLE repo_seq (
prev_cid TEXT,
ops JSONB,
blobs TEXT[],
blocks_cids TEXT[]
blocks_cids TEXT[],
prev_data_cid TEXT,
handle TEXT,
active BOOLEAN,
status TEXT
);
CREATE INDEX idx_repo_seq_seq ON repo_seq(seq);
CREATE INDEX idx_repo_seq_did ON repo_seq(did);
CREATE INDEX IF NOT EXISTS idx_repo_seq_did_seq ON repo_seq(did, seq DESC);
CREATE TABLE IF NOT EXISTS session_tokens (
id SERIAL PRIMARY KEY,
did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE,
@@ -275,3 +286,63 @@ CREATE TABLE oauth_2fa_challenge (
);
CREATE INDEX idx_oauth_2fa_challenge_request_uri ON oauth_2fa_challenge(request_uri);
CREATE INDEX idx_oauth_2fa_challenge_expires ON oauth_2fa_challenge(expires_at);
CREATE TABLE IF NOT EXISTS channel_verifications (
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
channel comms_channel NOT NULL,
code TEXT NOT NULL,
pending_identifier TEXT,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (user_id, channel)
);
CREATE INDEX IF NOT EXISTS idx_channel_verifications_expires ON channel_verifications(expires_at);
CREATE TABLE oauth_scope_preference (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE,
client_id TEXT NOT NULL,
scope TEXT NOT NULL,
granted BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(did, client_id, scope)
);
CREATE INDEX idx_oauth_scope_pref_lookup ON oauth_scope_preference(did, client_id);
CREATE TABLE user_totp (
did TEXT PRIMARY KEY REFERENCES users(did) ON DELETE CASCADE,
secret_encrypted BYTEA NOT NULL,
encryption_version INTEGER NOT NULL DEFAULT 1,
verified BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_used TIMESTAMPTZ
);
CREATE TABLE backup_codes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE,
code_hash TEXT NOT NULL,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_backup_codes_did ON backup_codes(did);
CREATE TABLE passkeys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE,
credential_id BYTEA NOT NULL UNIQUE,
public_key BYTEA NOT NULL,
sign_count INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_used TIMESTAMPTZ,
friendly_name TEXT,
aaguid BYTEA,
transports TEXT[]
);
CREATE INDEX idx_passkeys_did ON passkeys(did);
CREATE TABLE webauthn_challenges (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
did TEXT NOT NULL,
challenge BYTEA NOT NULL,
challenge_type TEXT NOT NULL,
state_json TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX idx_webauthn_challenges_did ON webauthn_challenges(did);
@@ -1,15 +0,0 @@
CREATE INDEX IF NOT EXISTS idx_records_repo_collection
ON records(repo_id, collection);
CREATE INDEX IF NOT EXISTS idx_records_repo_collection_created
ON records(repo_id, collection, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_users_email
ON users(email)
WHERE email IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_blobs_created_by_user
ON blobs(created_by_user, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_repo_seq_did_seq
ON repo_seq(did, seq DESC);
CREATE INDEX IF NOT EXISTS idx_app_passwords_user_id
ON app_passwords(user_id);
CREATE INDEX IF NOT EXISTS idx_invite_codes_created_by
ON invite_codes(created_by_user);
@@ -1 +0,0 @@
ALTER TABLE repo_seq ADD COLUMN IF NOT EXISTS prev_data_cid TEXT;
@@ -1,3 +0,0 @@
ALTER TABLE repo_seq ADD COLUMN IF NOT EXISTS handle TEXT;
ALTER TABLE repo_seq ADD COLUMN IF NOT EXISTS active BOOLEAN;
ALTER TABLE repo_seq ADD COLUMN IF NOT EXISTS status TEXT;
@@ -1,12 +0,0 @@
ALTER TYPE notification_type ADD VALUE IF NOT EXISTS 'channel_verification';
CREATE TABLE IF NOT EXISTS channel_verifications (
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
channel notification_channel NOT NULL,
code TEXT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (user_id, channel)
);
CREATE INDEX IF NOT EXISTS idx_channel_verifications_expires ON channel_verifications(expires_at);
@@ -1,11 +0,0 @@
ALTER TABLE channel_verifications ADD COLUMN pending_identifier TEXT;
INSERT INTO channel_verifications (user_id, channel, code, pending_identifier, expires_at)
SELECT id, 'email', email_confirmation_code, email_pending_verification, email_confirmation_code_expires_at
FROM users
WHERE email_confirmation_code IS NOT NULL AND email_confirmation_code_expires_at IS NOT NULL;
ALTER TABLE users
DROP COLUMN email_confirmation_code,
DROP COLUMN email_confirmation_code_expires_at,
DROP COLUMN email_pending_verification;
-1
View File
@@ -1 +0,0 @@
ALTER TABLE users ADD COLUMN is_admin BOOLEAN NOT NULL DEFAULT FALSE;
@@ -1,6 +0,0 @@
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'email_confirmed') THEN
ALTER TABLE users RENAME COLUMN email_confirmed TO email_verified;
END IF;
END $$;
@@ -1,27 +0,0 @@
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_type WHERE typname = 'notification_channel') THEN
ALTER TYPE notification_channel RENAME TO comms_channel;
END IF;
IF EXISTS (SELECT 1 FROM pg_type WHERE typname = 'notification_status') THEN
ALTER TYPE notification_status RENAME TO comms_status;
END IF;
IF EXISTS (SELECT 1 FROM pg_type WHERE typname = 'notification_type') THEN
ALTER TYPE notification_type RENAME TO comms_type;
END IF;
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'notification_queue') THEN
ALTER TABLE notification_queue RENAME TO comms_queue;
END IF;
IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'comms_queue' AND column_name = 'notification_type') THEN
ALTER TABLE comms_queue RENAME COLUMN notification_type TO comms_type;
END IF;
IF EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_notification_queue_status_scheduled') THEN
ALTER INDEX idx_notification_queue_status_scheduled RENAME TO idx_comms_queue_status_scheduled;
END IF;
IF EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_notification_queue_user_id') THEN
ALTER INDEX idx_notification_queue_user_id RENAME TO idx_comms_queue_user_id;
END IF;
IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'preferred_notification_channel') THEN
ALTER TABLE users RENAME COLUMN preferred_notification_channel TO preferred_comms_channel;
END IF;
END $$;
@@ -1,12 +0,0 @@
CREATE TABLE oauth_scope_preference (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE,
client_id TEXT NOT NULL,
scope TEXT NOT NULL,
granted BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(did, client_id, scope)
);
CREATE INDEX idx_oauth_scope_pref_lookup ON oauth_scope_preference(did, client_id);
@@ -1,2 +0,0 @@
ALTER TABLE users ADD COLUMN migrated_to_pds TEXT;
ALTER TABLE users ADD COLUMN migrated_at TIMESTAMPTZ;
-42
View File
@@ -1,42 +0,0 @@
CREATE TABLE user_totp (
did TEXT PRIMARY KEY REFERENCES users(did) ON DELETE CASCADE,
secret_encrypted BYTEA NOT NULL,
encryption_version INTEGER NOT NULL DEFAULT 1,
verified BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_used TIMESTAMPTZ
);
CREATE TABLE backup_codes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE,
code_hash TEXT NOT NULL,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_backup_codes_did ON backup_codes(did);
CREATE TABLE passkeys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE,
credential_id BYTEA NOT NULL UNIQUE,
public_key BYTEA NOT NULL,
sign_count INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_used TIMESTAMPTZ,
friendly_name TEXT,
aaguid BYTEA,
transports TEXT[]
);
CREATE INDEX idx_passkeys_did ON passkeys(did);
CREATE TABLE webauthn_challenges (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
did TEXT NOT NULL,
challenge BYTEA NOT NULL,
challenge_type TEXT NOT NULL,
state_json TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX idx_webauthn_challenges_did ON webauthn_challenges(did);
+9 -3
View File
@@ -67,15 +67,15 @@ pub async fn update_account_handle(
Json(input): Json<UpdateAccountHandleInput>,
) -> Response {
let did = input.did.trim();
let handle = input.handle.trim();
if did.is_empty() || handle.is_empty() {
let input_handle = input.handle.trim();
if did.is_empty() || input_handle.is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidRequest", "message": "did and handle are required"})),
)
.into_response();
}
if !handle
if !input_handle
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_')
{
@@ -87,6 +87,12 @@ pub async fn update_account_handle(
)
.into_response();
}
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let handle = if !input_handle.contains('.') {
format!("{}.{}", input_handle, hostname)
} else {
input_handle.to_string()
};
let old_handle = sqlx::query_scalar!("SELECT handle FROM users WHERE did = $1", did)
.fetch_optional(&state.db)
.await
+45 -24
View File
@@ -139,15 +139,35 @@ pub async fn create_account(
info!(did = %migration_did, "Processing account migration");
}
if input.handle.contains('!') || input.handle.contains('@') {
return (
StatusCode::BAD_REQUEST,
Json(
json!({"error": "InvalidHandle", "message": "Handle contains invalid characters"}),
),
)
.into_response();
}
let hostname_for_validation = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let pds_suffix = format!(".{}", hostname_for_validation);
let validated_short_handle = if !input.handle.contains('.') || input.handle.ends_with(&pds_suffix) {
let handle_to_validate = if input.handle.ends_with(&pds_suffix) {
input.handle.strip_suffix(&pds_suffix).unwrap_or(&input.handle)
} else {
&input.handle
};
match crate::api::validation::validate_short_handle(handle_to_validate) {
Ok(h) => h,
Err(e) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidHandle", "message": e.to_string()})),
)
.into_response();
}
}
} else {
if input.handle.contains(' ') || input.handle.contains('\t') {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidHandle", "message": "Handle cannot contain spaces"})),
)
.into_response();
}
input.handle.to_lowercase()
};
let email: Option<String> = input
.email
.as_ref()
@@ -212,12 +232,13 @@ pub async fn create_account(
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let pds_endpoint = format!("https://{}", hostname);
let suffix = format!(".{}", hostname);
let short_handle = if input.handle.ends_with(&suffix) {
input.handle.strip_suffix(&suffix).unwrap_or(&input.handle)
let handle = if input.handle.ends_with(&suffix) {
format!("{}.{}", validated_short_handle, hostname)
} else if input.handle.contains('.') {
validated_short_handle.clone()
} else {
&input.handle
format!("{}.{}", validated_short_handle, hostname)
};
let full_handle = format!("{}.{}", short_handle, hostname);
let (secret_key_bytes, reserved_key_id): (Vec<u8>, Option<uuid::Uuid>) =
if let Some(signing_key_did) = &input.signing_key {
let reserved = sqlx::query!(
@@ -298,7 +319,7 @@ pub async fn create_account(
)
.into_response();
}
if let Err(e) = verify_did_web(d, &hostname, &input.handle).await {
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})),
@@ -314,7 +335,7 @@ pub async fn create_account(
info!(did = %d, "Migration with existing did:plc");
d.clone()
} else if d.starts_with("did:web:") {
if let Err(e) = verify_did_web(d, &hostname, &input.handle).await {
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})),
@@ -334,7 +355,7 @@ pub async fn create_account(
let genesis_result = match create_genesis_operation(
&signing_key,
&rotation_key,
&full_handle,
&handle,
&pds_endpoint,
) {
Ok(r) => r,
@@ -371,7 +392,7 @@ pub async fn create_account(
let genesis_result = match create_genesis_operation(
&signing_key,
&rotation_key,
&full_handle,
&handle,
&pds_endpoint,
) {
Ok(r) => r,
@@ -424,10 +445,10 @@ pub async fn create_account(
.unwrap_or(None);
if let Some((account_id, old_handle, deactivated_at)) = existing_account {
if deactivated_at.is_some() {
info!(did = %did, old_handle = %old_handle, new_handle = %short_handle, "Preparing existing account for inbound migration");
info!(did = %did, old_handle = %old_handle, new_handle = %handle, "Preparing existing account for inbound migration");
let update_result: Result<_, sqlx::Error> =
sqlx::query("UPDATE users SET handle = $1 WHERE id = $2")
.bind(short_handle)
.bind(&handle)
.bind(account_id)
.execute(&mut *tx)
.await;
@@ -536,7 +557,7 @@ pub async fn create_account(
return (
StatusCode::OK,
Json(CreateAccountOutput {
handle: full_handle.clone(),
handle: handle.clone(),
did,
access_jwt: Some(access_meta.token),
refresh_jwt: Some(refresh_meta.token),
@@ -556,7 +577,7 @@ pub async fn create_account(
}
let exists_result: Option<(i32,)> =
sqlx::query_as("SELECT 1 FROM users WHERE handle = $1 AND deactivated_at IS NULL")
.bind(short_handle)
.bind(&handle)
.fetch_optional(&mut *tx)
.await
.unwrap_or(None);
@@ -660,7 +681,7 @@ pub async fn create_account(
is_admin, deactivated_at, email_verified
) VALUES ($1, $2, $3, $4, $5::comms_channel, $6, $7, $8, $9, $10, $11) RETURNING id"#,
)
.bind(short_handle)
.bind(&handle)
.bind(&email)
.bind(&did)
.bind(&password_hash)
@@ -898,7 +919,7 @@ pub async fn create_account(
}
if !is_migration {
if let Err(e) =
crate::api::repo::record::sequence_identity_event(&state, &did, Some(&full_handle))
crate::api::repo::record::sequence_identity_event(&state, &did, Some(&handle))
.await
{
warn!("Failed to sequence identity event for {}: {}", did, e);
@@ -991,7 +1012,7 @@ pub async fn create_account(
(
StatusCode::OK,
Json(CreateAccountOutput {
handle: full_handle.clone(),
handle: handle.clone(),
did,
access_jwt,
refresh_jwt,
+64 -56
View File
@@ -36,14 +36,7 @@ pub async fn resolve_handle(
if let Some(did) = state.cache.get(&cache_key).await {
return (StatusCode::OK, Json(json!({ "did": did }))).into_response();
}
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let suffix = format!(".{}", hostname);
let short_handle = if handle.ends_with(&suffix) {
handle.strip_suffix(&suffix).unwrap_or(handle)
} else {
handle
};
let user = sqlx::query!("SELECT did FROM users WHERE handle = $1", short_handle)
let user = sqlx::query!("SELECT did FROM users WHERE handle = $1", handle)
.fetch_optional(&state.db)
.await;
match user {
@@ -139,9 +132,10 @@ pub async fn well_known_did(State(state): State<AppState>, headers: HeaderMap) -
}
async fn serve_subdomain_did_doc(state: &AppState, handle: &str, hostname: &str) -> Response {
let full_handle = format!("{}.{}", handle, hostname);
let user = sqlx::query!(
"SELECT id, did, migrated_to_pds FROM users WHERE handle = $1",
handle
full_handle
)
.fetch_optional(&state.db)
.await;
@@ -212,11 +206,6 @@ async fn serve_subdomain_did_doc(state: &AppState, handle: &str, hostname: &str)
.into_response();
}
};
let full_handle = if handle.contains('.') {
handle.to_string()
} else {
format!("{}.{}", handle, hostname)
};
let service_endpoint = migrated_to_pds.unwrap_or_else(|| format!("https://{}", hostname));
Json(json!({
"@context": [
@@ -225,7 +214,7 @@ async fn serve_subdomain_did_doc(state: &AppState, handle: &str, hostname: &str)
"https://w3id.org/security/suites/secp256k1-2019/v1"
],
"id": did,
"alsoKnownAs": [format!("at://{}", full_handle)],
"alsoKnownAs": [format!("at://{}", handle)],
"verificationMethod": [{
"id": format!("{}#atproto", did),
"type": "Multikey",
@@ -243,9 +232,10 @@ async fn serve_subdomain_did_doc(state: &AppState, handle: &str, hostname: &str)
pub async fn user_did_doc(State(state): State<AppState>, Path(handle): Path<String>) -> Response {
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let full_handle = format!("{}.{}", handle, hostname);
let user = sqlx::query!(
"SELECT id, did, migrated_to_pds FROM users WHERE handle = $1",
handle
full_handle
)
.fetch_optional(&state.db)
.await;
@@ -318,11 +308,6 @@ pub async fn user_did_doc(State(state): State<AppState>, Path(handle): Path<Stri
.into_response();
}
};
let full_handle = if handle.contains('.') {
handle.clone()
} else {
format!("{}.{}", handle, hostname)
};
let service_endpoint = migrated_to_pds.unwrap_or_else(|| format!("https://{}", hostname));
Json(json!({
"@context": [
@@ -331,7 +316,7 @@ pub async fn user_did_doc(State(state): State<AppState>, Path(handle): Path<Stri
"https://w3id.org/security/suites/secp256k1-2019/v1"
],
"id": did,
"alsoKnownAs": [format!("at://{}", full_handle)],
"alsoKnownAs": [format!("at://{}", handle)],
"verificationMethod": [{
"id": format!("{}#atproto", did),
"type": "Multikey",
@@ -347,7 +332,12 @@ pub async fn user_did_doc(State(state): State<AppState>, Path(handle): Path<Stri
.into_response()
}
pub async fn verify_did_web(did: &str, hostname: &str, handle: &str) -> Result<(), String> {
pub async fn verify_did_web(
did: &str,
hostname: &str,
handle: &str,
expected_signing_key: Option<&str>,
) -> Result<(), String> {
let subdomain_host = format!("{}.{}", handle, hostname);
let encoded_subdomain = subdomain_host.replace(':', "%3A");
let expected_subdomain_did = format!("did:web:{}", encoded_subdomain);
@@ -371,6 +361,9 @@ pub async fn verify_did_web(did: &str, hostname: &str, handle: &str) -> Result<(
));
}
}
let expected_signing_key = expected_signing_key.ok_or_else(|| {
"External did:web requires a pre-reserved signing key. Call com.atproto.server.reserveSigningKey first, configure your DID document with the returned key, then provide the signingKey in createAccount.".to_string()
})?;
let parts: Vec<&str> = did.split(':').collect();
if parts.len() < 3 || parts[0] != "did" || parts[1] != "web" {
return Err("Invalid did:web format".into());
@@ -411,14 +404,31 @@ pub async fn verify_did_web(did: &str, hostname: &str, handle: &str) -> Result<(
let has_valid_service = services
.iter()
.any(|s| s["type"] == "AtprotoPersonalDataServer" && s["serviceEndpoint"] == pds_endpoint);
if has_valid_service {
Ok(())
} else {
Err(format!(
if !has_valid_service {
return Err(format!(
"DID document does not list this PDS ({}) as AtprotoPersonalDataServer",
pds_endpoint
))
));
}
let verification_methods = doc["verificationMethod"]
.as_array()
.ok_or("No verificationMethod found in DID doc")?;
let expected_multibase = expected_signing_key
.strip_prefix("did:key:")
.ok_or("Invalid signing key format")?;
let has_matching_key = verification_methods.iter().any(|vm| {
vm["publicKeyMultibase"]
.as_str()
.map(|pk| pk == expected_multibase)
.unwrap_or(false)
});
if !has_matching_key {
return Err(format!(
"DID document verification key does not match reserved signing key. Expected publicKeyMultibase: {}",
expected_multibase
));
}
Ok(())
}
#[derive(serde::Serialize)]
@@ -492,11 +502,6 @@ pub async fn get_recommended_did_credentials(
};
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let pds_endpoint = format!("https://{}", hostname);
let full_handle = if user.handle.contains('.') {
user.handle.clone()
} else {
format!("{}.{}", user.handle, hostname)
};
let signing_key = match k256::ecdsa::SigningKey::from_slice(&key_bytes) {
Ok(k) => k,
Err(_) => return ApiError::InternalError.into_response(),
@@ -511,7 +516,7 @@ pub async fn get_recommended_did_credentials(
StatusCode::OK,
Json(GetRecommendedDidCredentialsOutput {
rotation_keys,
also_known_as: vec![format!("at://{}", full_handle)],
also_known_as: vec![format!("at://{}", user.handle)],
verification_methods: VerificationMethods { atproto: did_key },
services: Services {
atproto_pds: AtprotoPds {
@@ -577,18 +582,29 @@ pub async fn update_handle(
.into_response();
}
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let suffix = format!(".{}", hostname);
let is_service_domain = crate::handle::is_service_domain_handle(new_handle, &hostname);
let (handle_to_store, full_handle) = if is_service_domain {
let suffix = format!(".{}", hostname);
let short_handle = if new_handle.ends_with(&suffix) {
let handle = if is_service_domain {
let short_part = if new_handle.ends_with(&suffix) {
new_handle.strip_suffix(&suffix).unwrap_or(new_handle)
} else {
new_handle
};
(
short_handle.to_string(),
format!("{}.{}", short_handle, hostname),
)
if short_part.contains('.') {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "InvalidHandle",
"message": "Nested subdomains are not allowed. Use a simple handle without dots."
})),
)
.into_response();
}
if new_handle.ends_with(&suffix) {
new_handle.to_string()
} else {
format!("{}.{}", new_handle, hostname)
}
} else {
match crate::handle::verify_handle_ownership(new_handle, &did).await {
Ok(()) => {}
@@ -625,7 +641,7 @@ pub async fn update_handle(
.into_response();
}
}
(new_handle.to_string(), new_handle.to_string())
new_handle.to_string()
};
let old_handle = sqlx::query_scalar!("SELECT handle FROM users WHERE id = $1", user_id)
.fetch_optional(&state.db)
@@ -634,7 +650,7 @@ pub async fn update_handle(
.flatten();
let existing = sqlx::query!(
"SELECT id FROM users WHERE handle = $1 AND id != $2",
handle_to_store,
handle,
user_id
)
.fetch_optional(&state.db)
@@ -648,7 +664,7 @@ pub async fn update_handle(
}
let result = sqlx::query!(
"UPDATE users SET handle = $1 WHERE id = $2",
handle_to_store,
handle,
user_id
)
.execute(&state.db)
@@ -660,16 +676,15 @@ pub async fn update_handle(
}
let _ = state
.cache
.delete(&format!("handle:{}", handle_to_store))
.delete(&format!("handle:{}", handle))
.await;
let _ = state.cache.delete(&format!("handle:{}", full_handle)).await;
if let Err(e) =
crate::api::repo::record::sequence_identity_event(&state, &did, Some(&full_handle))
crate::api::repo::record::sequence_identity_event(&state, &did, Some(&handle))
.await
{
warn!("Failed to sequence identity event for handle update: {}", e);
}
if let Err(e) = update_plc_handle(&state, &did, &full_handle).await {
if let Err(e) = update_plc_handle(&state, &did, &handle).await {
warn!("Failed to update PLC handle: {}", e);
}
(StatusCode::OK, Json(json!({}))).into_response()
@@ -723,15 +738,8 @@ pub async fn well_known_atproto_did(State(state): State<AppState>, headers: Head
Some(h) => h,
None => return (StatusCode::BAD_REQUEST, "Missing host header").into_response(),
};
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let suffix = format!(".{}", hostname);
let handle = host.split(':').next().unwrap_or(host);
let short_handle = if handle.ends_with(&suffix) {
handle.strip_suffix(&suffix).unwrap_or(handle)
} else {
return (StatusCode::NOT_FOUND, "Handle not found").into_response();
};
let user = sqlx::query!("SELECT did FROM users WHERE handle = $1", short_handle)
let user = sqlx::query!("SELECT did FROM users WHERE handle = $1", handle)
.fetch_optional(&state.db)
.await;
match user {
+2 -2
View File
@@ -16,8 +16,8 @@ use sha2::{Digest, Sha256};
use std::str::FromStr;
use tracing::{debug, error};
const MAX_BLOB_SIZE: usize = 1_000_000;
const MAX_VIDEO_BLOB_SIZE: usize = 100_000_000;
const MAX_BLOB_SIZE: usize = 10_000_000_000;
const MAX_VIDEO_BLOB_SIZE: usize = 10_000_000_000;
pub async fn upload_blob(
State(state): State<AppState>,
-24
View File
@@ -318,30 +318,6 @@ pub async fn import_repo(
records.len(),
did
);
if is_migration {
if let Err(e) =
sqlx::query!("UPDATE users SET deactivated_at = NULL WHERE did = $1", did)
.execute(&state.db)
.await
{
error!("Failed to reactivate account after import: {:?}", e);
}
let _ = state.cache.delete(&format!("handle:{}", user.handle)).await;
if let Err(e) = crate::api::repo::record::sequence_identity_event(
&state,
did,
Some(&user.handle),
)
.await
{
warn!("Failed to sequence identity event after import: {:?}", e);
}
if let Err(e) =
crate::api::repo::record::sequence_account_event(&state, did, true, None).await
{
warn!("Failed to sequence account event after import: {:?}", e);
}
}
if let Err(e) = sequence_import_event(&state, did, &root.to_string()).await {
warn!("Failed to sequence import event: {:?}", e);
}
+7 -1
View File
@@ -17,6 +17,7 @@ pub async fn describe_repo(
State(state): State<AppState>,
Query(input): Query<DescribeRepoInput>,
) -> Response {
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let user_row = if input.repo.starts_with("did:") {
sqlx::query!(
"SELECT id, handle, did FROM users WHERE did = $1",
@@ -26,9 +27,14 @@ pub async fn describe_repo(
.await
.map(|opt| opt.map(|r| (r.id, r.handle, r.did)))
} else {
let handle = if !input.repo.contains('.') {
format!("{}.{}", input.repo, hostname)
} else {
input.repo.clone()
};
sqlx::query!(
"SELECT id, handle, did FROM users WHERE handle = $1",
input.repo
handle
)
.fetch_optional(&state.db)
.await
+8 -10
View File
@@ -34,13 +34,12 @@ pub async fn get_record(
.await
.map(|opt| opt.map(|r| r.id))
} else {
let suffix = format!(".{}", hostname);
let short_handle = if input.repo.ends_with(&suffix) {
input.repo.strip_suffix(&suffix).unwrap_or(&input.repo)
let handle = if !input.repo.contains('.') {
format!("{}.{}", input.repo, hostname)
} else {
&input.repo
input.repo.clone()
};
sqlx::query!("SELECT id FROM users WHERE handle = $1", short_handle)
sqlx::query!("SELECT id FROM users WHERE handle = $1", handle)
.fetch_optional(&state.db)
.await
.map(|opt| opt.map(|r| r.id))
@@ -212,13 +211,12 @@ pub async fn list_records(
.await
.map(|opt| opt.map(|r| r.id))
} else {
let suffix = format!(".{}", hostname);
let short_handle = if input.repo.ends_with(&suffix) {
input.repo.strip_suffix(&suffix).unwrap_or(&input.repo)
let handle = if !input.repo.contains('.') {
format!("{}.{}", input.repo, hostname)
} else {
&input.repo
input.repo.clone()
};
sqlx::query!("SELECT id FROM users WHERE handle = $1", short_handle)
sqlx::query!("SELECT id FROM users WHERE handle = $1", handle)
.fetch_optional(&state.db)
.await
.map(|opt| opt.map(|r| r.id))
+10 -12
View File
@@ -29,21 +29,18 @@ fn extract_client_ip(headers: &HeaderMap) -> String {
}
fn normalize_handle(identifier: &str, pds_hostname: &str) -> String {
let suffix = format!(".{}", pds_hostname);
if identifier.ends_with(&suffix) {
identifier[..identifier.len() - suffix.len()].to_string()
} else {
let identifier = identifier.trim();
if identifier.contains('@') || identifier.starts_with("did:") {
identifier.to_string()
} else if !identifier.contains('.') {
format!("{}.{}", identifier.to_lowercase(), pds_hostname)
} else {
identifier.to_lowercase()
}
}
fn full_handle(stored_handle: &str, pds_hostname: &str) -> String {
let suffix = format!(".{}", pds_hostname);
if stored_handle.ends_with(&suffix) || stored_handle.ends_with(pds_hostname) {
stored_handle.to_string()
} else {
format!("{}.{}", stored_handle, pds_hostname)
}
fn full_handle(stored_handle: &str, _pds_hostname: &str) -> String {
stored_handle.to_string()
}
#[derive(Deserialize)]
@@ -66,7 +63,7 @@ pub async fn create_session(
headers: HeaderMap,
Json(input): Json<CreateSessionInput>,
) -> Response {
info!("create_session called");
info!("create_session called with identifier: {}", input.identifier);
let client_ip = extract_client_ip(&headers);
if !state
.check_rate_limit(RateLimitKind::Login, &client_ip)
@@ -84,6 +81,7 @@ pub async fn create_session(
}
let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let normalized_identifier = normalize_handle(&input.identifier, &pds_hostname);
info!("Normalized identifier: {} -> {}", input.identifier, normalized_identifier);
let row = match sqlx::query!(
r#"SELECT
u.id, u.did, u.handle, u.password_hash,
+100
View File
@@ -4,6 +4,72 @@ pub const MAX_DOMAIN_LENGTH: usize = 253;
pub const MAX_DOMAIN_LABEL_LENGTH: usize = 63;
const EMAIL_LOCAL_SPECIAL_CHARS: &str = ".!#$%&'*+/=?^_`{|}~-";
pub const MIN_HANDLE_LENGTH: usize = 3;
pub const MAX_HANDLE_LENGTH: usize = 253;
#[derive(Debug, PartialEq)]
pub enum HandleValidationError {
Empty,
TooShort,
TooLong,
InvalidCharacters,
StartsWithInvalidChar,
EndsWithInvalidChar,
ContainsSpaces,
}
impl std::fmt::Display for HandleValidationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Empty => write!(f, "Handle cannot be empty"),
Self::TooShort => write!(f, "Handle must be at least {} characters", MIN_HANDLE_LENGTH),
Self::TooLong => write!(f, "Handle exceeds maximum length of {} characters", MAX_HANDLE_LENGTH),
Self::InvalidCharacters => write!(f, "Handle contains invalid characters. Only alphanumeric, hyphens, and underscores are allowed"),
Self::StartsWithInvalidChar => write!(f, "Handle cannot start with a hyphen or underscore"),
Self::EndsWithInvalidChar => write!(f, "Handle cannot end with a hyphen or underscore"),
Self::ContainsSpaces => write!(f, "Handle cannot contain spaces"),
}
}
}
pub fn validate_short_handle(handle: &str) -> Result<String, HandleValidationError> {
let handle = handle.trim();
if handle.is_empty() {
return Err(HandleValidationError::Empty);
}
if handle.contains(' ') || handle.contains('\t') || handle.contains('\n') {
return Err(HandleValidationError::ContainsSpaces);
}
if handle.len() < MIN_HANDLE_LENGTH {
return Err(HandleValidationError::TooShort);
}
if handle.len() > MAX_HANDLE_LENGTH {
return Err(HandleValidationError::TooLong);
}
let first_char = handle.chars().next().unwrap();
if first_char == '-' || first_char == '_' {
return Err(HandleValidationError::StartsWithInvalidChar);
}
let last_char = handle.chars().last().unwrap();
if last_char == '-' || last_char == '_' {
return Err(HandleValidationError::EndsWithInvalidChar);
}
for c in handle.chars() {
if !c.is_ascii_alphanumeric() && c != '-' && c != '_' {
return Err(HandleValidationError::InvalidCharacters);
}
}
Ok(handle.to_lowercase())
}
pub fn is_valid_email(email: &str) -> bool {
let email = email.trim();
if email.is_empty() || email.len() > MAX_EMAIL_LENGTH {
@@ -54,6 +120,40 @@ pub fn is_valid_email(email: &str) -> bool {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_valid_handles() {
assert_eq!(validate_short_handle("alice"), Ok("alice".to_string()));
assert_eq!(validate_short_handle("bob123"), Ok("bob123".to_string()));
assert_eq!(validate_short_handle("user-name"), Ok("user-name".to_string()));
assert_eq!(validate_short_handle("user_name"), Ok("user_name".to_string()));
assert_eq!(validate_short_handle("UPPERCASE"), Ok("uppercase".to_string()));
assert_eq!(validate_short_handle("MixedCase123"), Ok("mixedcase123".to_string()));
assert_eq!(validate_short_handle("abc"), Ok("abc".to_string()));
}
#[test]
fn test_invalid_handles() {
assert_eq!(validate_short_handle(""), Err(HandleValidationError::Empty));
assert_eq!(validate_short_handle(" "), Err(HandleValidationError::Empty));
assert_eq!(validate_short_handle("ab"), Err(HandleValidationError::TooShort));
assert_eq!(validate_short_handle("a"), Err(HandleValidationError::TooShort));
assert_eq!(validate_short_handle("test spaces"), Err(HandleValidationError::ContainsSpaces));
assert_eq!(validate_short_handle("test\ttab"), Err(HandleValidationError::ContainsSpaces));
assert_eq!(validate_short_handle("-starts"), Err(HandleValidationError::StartsWithInvalidChar));
assert_eq!(validate_short_handle("_starts"), Err(HandleValidationError::StartsWithInvalidChar));
assert_eq!(validate_short_handle("ends-"), Err(HandleValidationError::EndsWithInvalidChar));
assert_eq!(validate_short_handle("ends_"), Err(HandleValidationError::EndsWithInvalidChar));
assert_eq!(validate_short_handle("test@user"), Err(HandleValidationError::InvalidCharacters));
assert_eq!(validate_short_handle("test!user"), Err(HandleValidationError::InvalidCharacters));
assert_eq!(validate_short_handle("test.user"), Err(HandleValidationError::InvalidCharacters));
}
#[test]
fn test_handle_trimming() {
assert_eq!(validate_short_handle(" alice "), Ok("alice".to_string()));
}
#[test]
fn test_valid_emails() {
assert!(is_valid_email("user@example.com"));
+15 -17
View File
@@ -426,10 +426,10 @@ pub async fn authorize_post(
let normalized_username = normalized_username
.strip_prefix('@')
.unwrap_or(normalized_username);
let normalized_username = if let Some(bare_handle) =
normalized_username.strip_suffix(&format!(".{}", pds_hostname))
{
bare_handle.to_string()
let normalized_username = if normalized_username.contains('@') {
normalized_username.to_string()
} else if !normalized_username.contains('.') {
format!("{}.{}", normalized_username, pds_hostname)
} else {
normalized_username.to_string()
};
@@ -1585,16 +1585,14 @@ pub async fn check_user_security_status(
Query(query): Query<CheckPasskeysQuery>,
) -> Response {
let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let normalized_identifier = query.identifier.trim();
let normalized_identifier = normalized_identifier
.strip_prefix('@')
.unwrap_or(normalized_identifier);
let normalized_identifier = if let Some(bare_handle) =
normalized_identifier.strip_suffix(&format!(".{}", pds_hostname))
{
bare_handle.to_string()
let identifier = query.identifier.trim();
let identifier = identifier.strip_prefix('@').unwrap_or(identifier);
let normalized_identifier = if identifier.contains('@') || identifier.starts_with("did:") {
identifier.to_string()
} else if !identifier.contains('.') {
format!("{}.{}", identifier.to_lowercase(), pds_hostname)
} else {
normalized_identifier.to_string()
identifier.to_lowercase()
};
let user = sqlx::query!(
@@ -1695,10 +1693,10 @@ pub async fn passkey_start(
let normalized_username = normalized_username
.strip_prefix('@')
.unwrap_or(normalized_username);
let normalized_username = if let Some(bare_handle) =
normalized_username.strip_suffix(&format!(".{}", pds_hostname))
{
bare_handle.to_string()
let normalized_username = if normalized_username.contains('@') {
normalized_username.to_string()
} else if !normalized_username.contains('.') {
format!("{}.{}", normalized_username, pds_hostname)
} else {
normalized_username.to_string()
};
+30 -29
View File
@@ -17,7 +17,7 @@ async fn create_verified_account(
base_url: &str,
handle: &str,
email: &str,
) -> String {
) -> (String, String) {
let res = client
.post(format!(
"{}/xrpc/com.atproto.server.createAccount",
@@ -33,8 +33,9 @@ async fn create_verified_account(
.expect("Failed to create account");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.expect("Invalid JSON");
let did = body["did"].as_str().expect("No did");
common::verify_new_account(client, did).await
let did = body["did"].as_str().expect("No did").to_string();
let jwt = common::verify_new_account(client, &did).await;
(jwt, did)
}
#[tokio::test]
@@ -44,7 +45,7 @@ async fn test_email_update_flow_success() {
let pool = get_pool().await;
let handle = format!("emailup_{}", uuid::Uuid::new_v4());
let email = format!("{}@example.com", handle);
let access_jwt = create_verified_account(&client, &base_url, &handle, &email).await;
let (access_jwt, did) = create_verified_account(&client, &base_url, &handle, &email).await;
let new_email = format!("new_{}@example.com", handle);
let res = client
.post(format!(
@@ -61,8 +62,8 @@ async fn test_email_update_flow_success() {
assert_eq!(body["tokenRequired"], true);
let verification = sqlx::query!(
"SELECT pending_identifier, code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE handle = $1) AND channel = 'email'",
handle
"SELECT pending_identifier, code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE did = $1) AND channel = 'email'",
did
)
.fetch_one(&pool)
.await
@@ -84,15 +85,15 @@ async fn test_email_update_flow_success() {
.await
.expect("Failed to confirm email");
assert_eq!(res.status(), StatusCode::OK);
let user = sqlx::query!("SELECT email FROM users WHERE handle = $1", handle)
let user = sqlx::query!("SELECT email FROM users WHERE did = $1", did)
.fetch_one(&pool)
.await
.expect("User not found");
assert_eq!(user.email, Some(new_email));
let verification = sqlx::query!(
"SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE handle = $1) AND channel = 'email'",
handle
"SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE did = $1) AND channel = 'email'",
did
)
.fetch_optional(&pool)
.await
@@ -106,10 +107,10 @@ async fn test_request_email_update_taken_email() {
let base_url = common::base_url().await;
let handle1 = format!("emailup_taken1_{}", uuid::Uuid::new_v4());
let email1 = format!("{}@example.com", handle1);
let _ = create_verified_account(&client, &base_url, &handle1, &email1).await;
let (_, _) = create_verified_account(&client, &base_url, &handle1, &email1).await;
let handle2 = format!("emailup_taken2_{}", uuid::Uuid::new_v4());
let email2 = format!("{}@example.com", handle2);
let access_jwt2 = create_verified_account(&client, &base_url, &handle2, &email2).await;
let (access_jwt2, _) = create_verified_account(&client, &base_url, &handle2, &email2).await;
let res = client
.post(format!(
"{}/xrpc/com.atproto.server.requestEmailUpdate",
@@ -131,7 +132,7 @@ async fn test_confirm_email_invalid_token() {
let base_url = common::base_url().await;
let handle = format!("emailup_inv_{}", uuid::Uuid::new_v4());
let email = format!("{}@example.com", handle);
let access_jwt = create_verified_account(&client, &base_url, &handle, &email).await;
let (access_jwt, _) = create_verified_account(&client, &base_url, &handle, &email).await;
let new_email = format!("new_{}@example.com", handle);
let res = client
.post(format!(
@@ -166,7 +167,7 @@ async fn test_confirm_email_wrong_email() {
let pool = get_pool().await;
let handle = format!("emailup_wrong_{}", uuid::Uuid::new_v4());
let email = format!("{}@example.com", handle);
let access_jwt = create_verified_account(&client, &base_url, &handle, &email).await;
let (access_jwt, did) = create_verified_account(&client, &base_url, &handle, &email).await;
let new_email = format!("new_{}@example.com", handle);
let res = client
.post(format!(
@@ -180,8 +181,8 @@ async fn test_confirm_email_wrong_email() {
.expect("Failed to request email update");
assert_eq!(res.status(), StatusCode::OK);
let verification = sqlx::query!(
"SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE handle = $1) AND channel = 'email'",
handle
"SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE did = $1) AND channel = 'email'",
did
)
.fetch_one(&pool)
.await
@@ -209,7 +210,7 @@ async fn test_update_email_success_no_token_required() {
let pool = get_pool().await;
let handle = format!("emailup_direct_{}", uuid::Uuid::new_v4());
let email = format!("{}@example.com", handle);
let access_jwt = create_verified_account(&client, &base_url, &handle, &email).await;
let (access_jwt, did) = create_verified_account(&client, &base_url, &handle, &email).await;
let new_email = format!("direct_{}@example.com", handle);
let res = client
.post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url))
@@ -219,7 +220,7 @@ async fn test_update_email_success_no_token_required() {
.await
.expect("Failed to update email");
assert_eq!(res.status(), StatusCode::OK);
let user = sqlx::query!("SELECT email FROM users WHERE handle = $1", handle)
let user = sqlx::query!("SELECT email FROM users WHERE did = $1", did)
.fetch_one(&pool)
.await
.expect("User not found");
@@ -232,7 +233,7 @@ async fn test_update_email_same_email_noop() {
let base_url = common::base_url().await;
let handle = format!("emailup_same_{}", uuid::Uuid::new_v4());
let email = format!("{}@example.com", handle);
let access_jwt = create_verified_account(&client, &base_url, &handle, &email).await;
let (access_jwt, _) = create_verified_account(&client, &base_url, &handle, &email).await;
let res = client
.post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url))
.bearer_auth(&access_jwt)
@@ -253,7 +254,7 @@ async fn test_update_email_requires_token_after_pending() {
let base_url = common::base_url().await;
let handle = format!("emailup_token_{}", uuid::Uuid::new_v4());
let email = format!("{}@example.com", handle);
let access_jwt = create_verified_account(&client, &base_url, &handle, &email).await;
let (access_jwt, _) = create_verified_account(&client, &base_url, &handle, &email).await;
let new_email = format!("pending_{}@example.com", handle);
let res = client
.post(format!(
@@ -285,7 +286,7 @@ async fn test_update_email_with_valid_token() {
let pool = get_pool().await;
let handle = format!("emailup_valid_{}", uuid::Uuid::new_v4());
let email = format!("{}@example.com", handle);
let access_jwt = create_verified_account(&client, &base_url, &handle, &email).await;
let (access_jwt, did) = create_verified_account(&client, &base_url, &handle, &email).await;
let new_email = format!("valid_{}@example.com", handle);
let res = client
.post(format!(
@@ -299,8 +300,8 @@ async fn test_update_email_with_valid_token() {
.expect("Failed to request email update");
assert_eq!(res.status(), StatusCode::OK);
let verification = sqlx::query!(
"SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE handle = $1) AND channel = 'email'",
handle
"SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE did = $1) AND channel = 'email'",
did
)
.fetch_one(&pool)
.await
@@ -317,14 +318,14 @@ async fn test_update_email_with_valid_token() {
.await
.expect("Failed to update email");
assert_eq!(res.status(), StatusCode::OK);
let user = sqlx::query!("SELECT email FROM users WHERE handle = $1", handle)
let user = sqlx::query!("SELECT email FROM users WHERE did = $1", did)
.fetch_one(&pool)
.await
.expect("User not found");
assert_eq!(user.email, Some(new_email));
let verification = sqlx::query!(
"SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE handle = $1) AND channel = 'email'",
handle
"SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE did = $1) AND channel = 'email'",
did
)
.fetch_optional(&pool)
.await
@@ -338,7 +339,7 @@ async fn test_update_email_invalid_token() {
let base_url = common::base_url().await;
let handle = format!("emailup_badtok_{}", uuid::Uuid::new_v4());
let email = format!("{}@example.com", handle);
let access_jwt = create_verified_account(&client, &base_url, &handle, &email).await;
let (access_jwt, _) = create_verified_account(&client, &base_url, &handle, &email).await;
let new_email = format!("badtok_{}@example.com", handle);
let res = client
.post(format!(
@@ -372,10 +373,10 @@ async fn test_update_email_already_taken() {
let base_url = common::base_url().await;
let handle1 = format!("emailup_dup1_{}", uuid::Uuid::new_v4());
let email1 = format!("{}@example.com", handle1);
let _ = create_verified_account(&client, &base_url, &handle1, &email1).await;
let (_, _) = create_verified_account(&client, &base_url, &handle1, &email1).await;
let handle2 = format!("emailup_dup2_{}", uuid::Uuid::new_v4());
let email2 = format!("{}@example.com", handle2);
let access_jwt2 = create_verified_account(&client, &base_url, &handle2, &email2).await;
let (access_jwt2, _) = create_verified_account(&client, &base_url, &handle2, &email2).await;
let res = client
.post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url))
.bearer_auth(&access_jwt2)
@@ -412,7 +413,7 @@ async fn test_update_email_invalid_format() {
let base_url = common::base_url().await;
let handle = format!("emailup_fmt_{}", uuid::Uuid::new_v4());
let email = format!("{}@example.com", handle);
let access_jwt = create_verified_account(&client, &base_url, &handle, &email).await;
let (access_jwt, _) = create_verified_account(&client, &base_url, &handle, &email).await;
let res = client
.post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url))
.bearer_auth(&access_jwt)
+5 -4
View File
@@ -8,10 +8,10 @@ use wiremock::{Mock, MockServer, ResponseTemplate};
#[tokio::test]
async fn test_resolve_handle_success() {
let client = client();
let handle = format!("resolvetest_{}", uuid::Uuid::new_v4());
let short_handle = format!("resolvetest_{}", uuid::Uuid::new_v4());
let payload = json!({
"handle": handle,
"email": format!("{}@example.com", handle),
"handle": short_handle,
"email": format!("{}@example.com", short_handle),
"password": "password"
});
let res = client
@@ -26,7 +26,8 @@ async fn test_resolve_handle_success() {
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.expect("Invalid JSON");
let did = body["did"].as_str().expect("No DID").to_string();
let params = [("handle", handle.as_str())];
let full_handle = body["handle"].as_str().expect("No handle in response").to_string();
let params = [("handle", full_handle.as_str())];
let res = client
.get(format!(
"{}/xrpc/com.atproto.identity.resolveHandle",