OTP security hardened
This commit is contained in:
@@ -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'])
|
||||
|
||||
Reference in New Issue
Block a user