mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-08-16 06:16:06 +00:00
Better handles
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 +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;
|
||||
@@ -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);
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user