OTP security hardened

This commit is contained in:
2026-08-26 18:24:02 +02:00
parent c3c3c8e1a6
commit 580c2a4257
13 changed files with 227 additions and 13 deletions
+35 -3
View File
@@ -11,7 +11,7 @@ from pydantic import BaseModel
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.otp_service import consume_recovery_code, create_recovery_codes, 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
@@ -32,12 +32,19 @@ class PasswordUpdate(BaseModel):
class OtpUpdate(BaseModel):
action: str
code: str | None = None
current_password: str | None = None
recovery_code: str | None = None
class AdditionalEmail(BaseModel):
email: str
class OtpRecovery(BaseModel):
current_password: str
recovery_code: str
class UserPluginConfigUpdate(BaseModel):
instance: str | None = None
@@ -119,14 +126,24 @@ def setup_otp(user: dict = Depends(get_current_user)):
with get_connection() as conn:
conn.execute('UPDATE users SET otp_secret = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', (encrypt_secret(secret), user['id']))
conn.commit()
return {'secret': secret, 'otpauth_url': provisioning_uri(secret, user['username'])}
return {
'secret': secret,
'otpauth_url': provisioning_uri(secret, user['username']),
'recovery_codes': create_recovery_codes(user['id']),
}
@router.post('/otp')
def update_otp(payload: OtpUpdate, user: dict = Depends(get_current_user)):
if payload.action not in {'enable', 'disable'}:
raise HTTPException(status_code=422, detail='OTP action must be enable or disable')
if not verify_code(decrypt_secret(user['otp_secret']), payload.code):
if payload.action == 'disable' and not payload.current_password:
raise HTTPException(status_code=400, detail='Current password is required to disable one-time password')
if payload.action == 'disable' and not verify_password(payload.current_password, user['password_hash']):
raise HTTPException(status_code=400, detail='Current password is incorrect')
valid_code = verify_code(decrypt_secret(user['otp_secret']), payload.code)
valid_recovery_code = payload.action == 'disable' and payload.recovery_code and consume_recovery_code(user['id'], payload.recovery_code)
if not valid_code and not valid_recovery_code:
raise HTTPException(status_code=400, detail='Invalid one-time password')
with get_connection() as conn:
if payload.action == 'enable':
@@ -137,6 +154,21 @@ def update_otp(payload: OtpUpdate, user: dict = Depends(get_current_user)):
return {'status': 'updated', 'enabled': payload.action == 'enable'}
@router.post('/otp/recover')
def recover_otp(payload: OtpRecovery, user: dict = Depends(get_current_user)):
if not verify_password(payload.current_password, user['password_hash']):
raise HTTPException(status_code=400, detail='Current password is incorrect')
if not consume_recovery_code(user['id'], payload.recovery_code):
raise HTTPException(status_code=400, detail='Recovery code is invalid or already used')
with get_connection() as conn:
conn.execute(
'UPDATE users SET otp_enabled = 0, otp_secret = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
(user['id'],),
)
conn.commit()
return {'status': 'otp_recovered', 'enabled': False}
@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'])