primary email change functionality
Build LinkLog Development Image / development-image (push) Successful in 12s
Build LinkLog Development Image / development-image (push) Successful in 12s
This commit is contained in:
@@ -14,6 +14,7 @@ from backend.app.services.email_verification import verify_email
|
||||
from backend.app.services.password_reset import create_reset_token, reset_password
|
||||
from backend.app.services.token_service import issue_token, revoke_token, validate_token
|
||||
from backend.app.services.otp_service import verify_code
|
||||
from backend.app.services.email_addresses import verify_user_email_address
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -66,6 +67,14 @@ def verify_email_address(token: str):
|
||||
return {'status': 'verified', 'message': 'Email address verified. You can now sign in.'}
|
||||
|
||||
|
||||
@router.get('/verify-additional-email')
|
||||
def verify_additional_email_address(token: str):
|
||||
if not verify_user_email_address(token):
|
||||
raise HTTPException(status_code=400, detail='Email verification link is invalid or expired')
|
||||
return {'status': 'verified', 'message': 'Email address verified. You can now sign in.'}
|
||||
|
||||
|
||||
|
||||
@router.post('/reset-password')
|
||||
def reset_password_endpoint(payload: PasswordResetRequest):
|
||||
if len(payload.password) < 8:
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
## SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
||||
@@ -11,12 +12,14 @@ from backend.app.api.dependencies import get_current_user
|
||||
from backend.app.database import AVATARS_DIR, get_connection, hash_password, verify_password
|
||||
from backend.app.services.link_service import create_label, delete_label, list_user_labels, update_label
|
||||
from backend.app.services.otp_service import create_secret, provisioning_uri, verify_code
|
||||
from backend.app.services.email_addresses import add_user_email_address, create_email_verification, list_user_email_addresses
|
||||
from backend.app.services.email_service import send_verification_email, smtp_configured
|
||||
from backend.app.core.config import settings
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class UserConfigUpdate(BaseModel):
|
||||
email: str | None = None
|
||||
bio: str | None = None
|
||||
|
||||
|
||||
@@ -30,6 +33,11 @@ class OtpUpdate(BaseModel):
|
||||
code: str | None = None
|
||||
|
||||
|
||||
class AdditionalEmail(BaseModel):
|
||||
email: str
|
||||
|
||||
|
||||
|
||||
class UserPluginConfigUpdate(BaseModel):
|
||||
instance: str | None = None
|
||||
access_token: str | None = None
|
||||
@@ -66,7 +74,6 @@ def update_current_user_profile(
|
||||
if current is None:
|
||||
raise HTTPException(status_code=404, detail='User not found')
|
||||
|
||||
email = payload.email or current['email']
|
||||
bio = payload.bio if payload.bio is not None else current['bio']
|
||||
|
||||
conn.execute(
|
||||
@@ -75,7 +82,7 @@ def update_current_user_profile(
|
||||
SET email = ?, bio = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
''',
|
||||
(email, bio, user['id']),
|
||||
(current['email'], bio, user['id']),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
@@ -129,6 +136,121 @@ def update_otp(payload: OtpUpdate, user: dict = Depends(get_current_user)):
|
||||
return {'status': 'updated', 'enabled': payload.action == 'enable'}
|
||||
|
||||
|
||||
@router.get('/emails')
|
||||
def get_additional_emails(user: dict = Depends(get_current_user)):
|
||||
return [{'email': user['email'], 'verified': bool(user['email_verified']), 'primary': True}] + list_user_email_addresses(user['id'])
|
||||
|
||||
|
||||
@router.post('/emails', status_code=201)
|
||||
def add_additional_email(payload: AdditionalEmail, user: dict = Depends(get_current_user)):
|
||||
email = payload.email.strip().lower()
|
||||
if email == user['email'].lower():
|
||||
raise HTTPException(status_code=409, detail='This is already the primary email address')
|
||||
with get_connection() as conn:
|
||||
if conn.execute('SELECT 1 FROM users WHERE lower(email) = ?', (email,)).fetchone():
|
||||
raise HTTPException(status_code=409, detail='Email address already exists')
|
||||
additional_count = conn.execute(
|
||||
'SELECT COUNT(*) AS count FROM user_email_addresses WHERE user_id = ?',
|
||||
(user['id'],),
|
||||
).fetchone()['count']
|
||||
if additional_count >= 5:
|
||||
raise HTTPException(status_code=422, detail='You can add at most five additional email addresses')
|
||||
try:
|
||||
address = add_user_email_address(user['id'], email)
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=409, detail=str(error)) from error
|
||||
if smtp_configured():
|
||||
email_address, token = create_email_verification(user['id'], address['id'])
|
||||
verification_url = f'{settings.public_url}/api/auth/verify-additional-email?token={token}'
|
||||
try:
|
||||
send_verification_email(email_address, user['username'], verification_url)
|
||||
except Exception as error:
|
||||
raise HTTPException(status_code=503, detail=f'Email address added but verification email could not be sent: {error}') from error
|
||||
with get_connection() as conn:
|
||||
conn.execute(
|
||||
'''INSERT INTO app_settings (name, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(name) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP''',
|
||||
(f'email_verify_rate:{address["id"]}', json.dumps({'sends': 1, 'last_sent': datetime.now(timezone.utc).isoformat()})),
|
||||
)
|
||||
conn.commit()
|
||||
return address
|
||||
|
||||
|
||||
@router.post('/emails/{address_id}/resend')
|
||||
def resend_additional_email(address_id: str, user: dict = Depends(get_current_user)):
|
||||
now = datetime.now(timezone.utc)
|
||||
setting_name = f'email_verify_rate:{address_id}'
|
||||
with get_connection() as conn:
|
||||
row = conn.execute('SELECT value FROM app_settings WHERE name = ?', (setting_name,)).fetchone()
|
||||
address = conn.execute('SELECT email, verified FROM user_email_addresses WHERE id = ? AND user_id = ?', (address_id, user['id'])).fetchone()
|
||||
if address is None:
|
||||
raise HTTPException(status_code=404, detail='Email address not found')
|
||||
if address['verified']:
|
||||
raise HTTPException(status_code=409, detail='Email address is already verified')
|
||||
rate = json.loads(row['value']) if row else {}
|
||||
last_sent = datetime.fromisoformat(rate['last_sent']) if rate.get('last_sent') else None
|
||||
cooldown_until = datetime.fromisoformat(rate['cooldown_until']) if rate.get('cooldown_until') else None
|
||||
if cooldown_until and now < cooldown_until:
|
||||
retry_after = int((cooldown_until - now).total_seconds()) + 1
|
||||
raise HTTPException(status_code=429, detail=f'Please wait {retry_after} seconds before resending verification email.', headers={'Retry-After': str(retry_after)})
|
||||
if last_sent and now - last_sent < timedelta(seconds=20):
|
||||
retry_after = int((timedelta(seconds=20) - (now - last_sent)).total_seconds()) + 1
|
||||
raise HTTPException(status_code=429, detail=f'Please wait {retry_after} seconds before resending verification email.', headers={'Retry-After': str(retry_after)})
|
||||
email, token = create_email_verification(user['id'], address_id)
|
||||
verification_url = f'{settings.public_url}/api/auth/verify-additional-email?token={token}'
|
||||
try:
|
||||
send_verification_email(email, user['username'], verification_url)
|
||||
except Exception as error:
|
||||
raise HTTPException(status_code=503, detail=f'Verification email could not be sent: {error}') from error
|
||||
sends = int(rate.get('sends', 0)) + 1
|
||||
updated = {'sends': sends, 'last_sent': now.isoformat()}
|
||||
if sends >= 5:
|
||||
updated['cooldown_until'] = (now + timedelta(minutes=2)).isoformat()
|
||||
with get_connection() as conn:
|
||||
conn.execute('''INSERT INTO app_settings (name, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(name) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP''', (setting_name, json.dumps(updated)))
|
||||
conn.commit()
|
||||
return {'status': 'sent', 'message': f'Verification email sent to {email}.', 'next_allowed_at': (now + timedelta(seconds=20)).isoformat()}
|
||||
|
||||
|
||||
@router.delete('/emails/{address_id}')
|
||||
def remove_additional_email(address_id: str, user: dict = Depends(get_current_user)):
|
||||
with get_connection() as conn:
|
||||
cursor = conn.execute('DELETE FROM user_email_addresses WHERE id = ? AND user_id = ?', (address_id, user['id']))
|
||||
conn.commit()
|
||||
if cursor.rowcount == 0:
|
||||
raise HTTPException(status_code=404, detail='Email address not found')
|
||||
return {'status': 'deleted', 'id': address_id}
|
||||
|
||||
|
||||
@router.post('/emails/{address_id}/make-primary')
|
||||
def make_email_primary(address_id: str, user: dict = Depends(get_current_user)):
|
||||
with get_connection() as conn:
|
||||
address = conn.execute(
|
||||
'SELECT email, verified FROM user_email_addresses WHERE id = ? AND user_id = ?',
|
||||
(address_id, user['id']),
|
||||
).fetchone()
|
||||
if address is None:
|
||||
raise HTTPException(status_code=404, detail='Email address not found')
|
||||
if not address['verified']:
|
||||
raise HTTPException(status_code=400, detail='Email address must be validated before it can become primary')
|
||||
old_email = user['email']
|
||||
conn.execute(
|
||||
'DELETE FROM user_email_addresses WHERE id = ? AND user_id = ?',
|
||||
(address_id, user['id']),
|
||||
)
|
||||
conn.execute(
|
||||
'INSERT INTO user_email_addresses (id, user_id, email, verified) VALUES (?, ?, ?, 1)',
|
||||
(str(uuid4()), user['id'], old_email),
|
||||
)
|
||||
conn.execute(
|
||||
'UPDATE users SET email = ?, email_verified = 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||
(address['email'], user['id']),
|
||||
)
|
||||
conn.commit()
|
||||
return {'status': 'updated', 'email': address['email']}
|
||||
|
||||
|
||||
@router.get('/labels')
|
||||
def get_labels(user: dict = Depends(get_current_user)):
|
||||
return list_user_labels(user['id'])
|
||||
|
||||
@@ -192,6 +192,38 @@ WHERE mastodon_post_ids IS NULL;
|
||||
ALTER TABLE users ADD COLUMN otp_secret TEXT;
|
||||
ALTER TABLE users ADD COLUMN otp_enabled INTEGER NOT NULL DEFAULT 0;
|
||||
'''),
|
||||
(12, '''
|
||||
CREATE TABLE IF NOT EXISTS user_email_addresses (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
verified INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS email_address_verification_tokens (
|
||||
id TEXT PRIMARY KEY,
|
||||
email_address_id TEXT NOT NULL,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(email_address_id) REFERENCES user_email_addresses(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_email_addresses_user_id ON user_email_addresses(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_email_address_verification_tokens_address_id ON email_address_verification_tokens(email_address_id);
|
||||
'''),
|
||||
(13, '''
|
||||
CREATE TABLE IF NOT EXISTS pending_primary_email_changes (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL UNIQUE,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
''')
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -7,7 +7,15 @@ from backend.app.database import get_connection, hash_password, verify_password
|
||||
|
||||
def authenticate_user(email: str, password: str):
|
||||
with get_connection() as conn:
|
||||
row = conn.execute('SELECT * FROM users WHERE email = ?', (email,)).fetchone()
|
||||
row = conn.execute(
|
||||
'''SELECT * FROM users WHERE email = ?
|
||||
UNION ALL
|
||||
SELECT users.* FROM users JOIN user_email_addresses
|
||||
ON user_email_addresses.user_id = users.id
|
||||
WHERE user_email_addresses.email = ? AND user_email_addresses.verified = 1
|
||||
LIMIT 1''',
|
||||
(email, email),
|
||||
).fetchone()
|
||||
if row is None or not verify_password(password, row['password_hash']):
|
||||
return None
|
||||
user = dict(row)
|
||||
@@ -26,4 +34,11 @@ def authenticate_user(email: str, password: str):
|
||||
def find_user(email: str):
|
||||
with get_connection() as conn:
|
||||
row = conn.execute('SELECT * FROM users WHERE email = ?', (email,)).fetchone()
|
||||
if row is None:
|
||||
row = conn.execute(
|
||||
'''SELECT users.* FROM users JOIN user_email_addresses
|
||||
ON user_email_addresses.user_id = users.id
|
||||
WHERE user_email_addresses.email = ?''',
|
||||
(email,),
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
## Copyright © 2026 Olaf Kolkman
|
||||
## SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from hashlib import sha256
|
||||
from secrets import token_urlsafe
|
||||
from uuid import uuid4
|
||||
|
||||
from backend.app.core.config import settings
|
||||
from backend.app.database import get_connection
|
||||
from backend.app.services.email_service import send_verification_email
|
||||
|
||||
|
||||
def list_user_email_addresses(user_id: str) -> list[dict]:
|
||||
with get_connection() as conn:
|
||||
rows = conn.execute(
|
||||
'SELECT id, email, verified, created_at FROM user_email_addresses WHERE user_id = ? ORDER BY created_at',
|
||||
(user_id,),
|
||||
).fetchall()
|
||||
return [dict(row) | {'verified': bool(row['verified']), 'can_be_primary': bool(row['verified'])} for row in rows]
|
||||
|
||||
|
||||
def add_user_email_address(user_id: str, email: str) -> dict:
|
||||
email = email.strip().lower()
|
||||
if not email:
|
||||
raise ValueError('Email address is required')
|
||||
with get_connection() as conn:
|
||||
try:
|
||||
row = conn.execute(
|
||||
'INSERT INTO user_email_addresses (id, user_id, email) VALUES (?, ?, ?) RETURNING id, email, verified, created_at',
|
||||
(str(uuid4()), user_id, email),
|
||||
).fetchone()
|
||||
conn.commit()
|
||||
except Exception as error:
|
||||
if 'UNIQUE constraint failed' in str(error):
|
||||
raise ValueError('Email address already exists') from error
|
||||
raise
|
||||
return dict(row) | {'verified': bool(row['verified']), 'can_be_primary': bool(row['verified'])}
|
||||
|
||||
|
||||
def create_email_verification(user_id: str, address_id: str) -> tuple[str, str]:
|
||||
token = token_urlsafe(32)
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(hours=settings.email_verification_expiry_hours)
|
||||
with get_connection() as conn:
|
||||
address = conn.execute(
|
||||
'SELECT email FROM user_email_addresses WHERE id = ? AND user_id = ?',
|
||||
(address_id, user_id),
|
||||
).fetchone()
|
||||
if address is None:
|
||||
raise ValueError('Email address not found')
|
||||
conn.execute('DELETE FROM email_address_verification_tokens WHERE email_address_id = ?', (address_id,))
|
||||
conn.execute(
|
||||
'INSERT INTO email_address_verification_tokens (id, email_address_id, token_hash, expires_at) VALUES (?, ?, ?, ?)',
|
||||
(str(uuid4()), address_id, sha256(token.encode()).hexdigest(), expires_at.isoformat()),
|
||||
)
|
||||
conn.commit()
|
||||
return address['email'], token
|
||||
|
||||
|
||||
def verify_user_email_address(token: str) -> bool:
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
with get_connection() as conn:
|
||||
row = conn.execute(
|
||||
'''SELECT email_address_id FROM email_address_verification_tokens
|
||||
WHERE token_hash = ? AND expires_at > ?''',
|
||||
(sha256(token.encode()).hexdigest(), now),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return False
|
||||
conn.execute('UPDATE user_email_addresses SET verified = 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?', (row['email_address_id'],))
|
||||
conn.execute('DELETE FROM email_address_verification_tokens WHERE email_address_id = ?', (row['email_address_id'],))
|
||||
conn.commit()
|
||||
return True
|
||||
@@ -10,7 +10,7 @@ def test_database_migrations_are_versioned_and_idempotent():
|
||||
connection = sqlite3.connect(':memory:')
|
||||
|
||||
apply_migrations(connection)
|
||||
assert get_schema_version(connection) == 11
|
||||
assert get_schema_version(connection) == 13
|
||||
tables = {
|
||||
row[0]
|
||||
for row in connection.execute(
|
||||
@@ -27,6 +27,6 @@ def test_database_migrations_are_versioned_and_idempotent():
|
||||
assert set(DEFAULT_TAGS) <= seeded_tags
|
||||
|
||||
apply_migrations(connection)
|
||||
assert get_schema_version(connection) == 11
|
||||
assert get_schema_version(connection) == 13
|
||||
|
||||
connection.close()
|
||||
@@ -2,8 +2,10 @@
|
||||
## SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from unittest.mock import patch
|
||||
|
||||
from backend.app.main import app
|
||||
from backend.app.database import get_connection
|
||||
from backend.app.services.otp_service import current_code
|
||||
|
||||
|
||||
@@ -45,6 +47,7 @@ def test_user_config_api_and_profile_page():
|
||||
assert '<a id="auth-username" class="user-name" href="/">' in page_response.text
|
||||
assert 'id="auth-avatar"' not in page_response.text
|
||||
assert 'name="new_password_confirmation"' in page_response.text
|
||||
assert 'id="additional-email-form"' in page_response.text
|
||||
|
||||
bob_login = client.post('/api/auth/login', json={
|
||||
'email': 'bob@example.com',
|
||||
@@ -104,3 +107,38 @@ def test_user_can_enable_and_use_otp():
|
||||
})
|
||||
assert disabled.status_code == 200
|
||||
assert disabled.json()['enabled'] is False
|
||||
|
||||
|
||||
def test_verified_alternative_can_become_primary():
|
||||
login = client.post('/api/auth/login', json={'email': 'alice@example.com', 'password': 'secret123'}).json()
|
||||
headers = {'Authorization': f"Bearer {login['access_token']}"}
|
||||
with get_connection() as conn:
|
||||
address = conn.execute(
|
||||
'INSERT INTO user_email_addresses (id, user_id, email, verified) VALUES (?, ?, ?, 1) RETURNING id',
|
||||
('alternative-test', 'user-1', 'alice-alternative@example.com'),
|
||||
).fetchone()
|
||||
conn.commit()
|
||||
promoted = client.post(f"/api/user/emails/{address['id']}/make-primary", headers=headers)
|
||||
assert promoted.status_code == 200
|
||||
assert client.post('/api/auth/login', json={'email': 'alice-alternative@example.com', 'password': 'secret123'}).status_code == 200
|
||||
assert client.post('/api/auth/login', json={'email': 'alice@example.com', 'password': 'secret123'}).status_code == 200
|
||||
with get_connection() as conn:
|
||||
conn.execute('DELETE FROM user_email_addresses WHERE email IN (?, ?)', ('alice@example.com', 'alice-alternative@example.com'))
|
||||
conn.execute('UPDATE users SET email = ?, email_verified = 1 WHERE id = ?', ('alice@example.com', 'user-1'))
|
||||
conn.commit()
|
||||
|
||||
|
||||
def test_unverified_alternative_cannot_become_primary():
|
||||
login = client.post('/api/auth/login', json={'email': 'alice@example.com', 'password': 'secret123'}).json()
|
||||
headers = {'Authorization': f"Bearer {login['access_token']}"}
|
||||
with get_connection() as conn:
|
||||
address = conn.execute(
|
||||
'INSERT INTO user_email_addresses (id, user_id, email, verified) VALUES (?, ?, ?, 0) RETURNING id',
|
||||
('unverified-alternative-test', 'user-1', 'alice-unverified@example.com'),
|
||||
).fetchone()
|
||||
conn.commit()
|
||||
rejected = client.post(f"/api/user/emails/{address['id']}/make-primary", headers=headers)
|
||||
assert rejected.status_code == 400
|
||||
with get_connection() as conn:
|
||||
conn.execute('DELETE FROM user_email_addresses WHERE id = ?', (address['id'],))
|
||||
conn.commit()
|
||||
|
||||
Reference in New Issue
Block a user