diff --git a/backend/trip/models/models.py b/backend/trip/models/models.py index 74506e1..f099b46 100644 --- a/backend/trip/models/models.py +++ b/backend/trip/models/models.py @@ -91,6 +91,11 @@ class Token(BaseModel): refresh_token: str +class PendingTOTP(BaseModel): + pending_code: str + username: str + + class ImageBase(SQLModel): filename: str diff --git a/backend/trip/requirements.txt b/backend/trip/requirements.txt index 7eeec4b..bd3aa80 100644 --- a/backend/trip/requirements.txt +++ b/backend/trip/requirements.txt @@ -7,3 +7,4 @@ pydantic_settings~=2.9 Pillow~=11.2 authlib~=1.6 alembic~=1.16 +pyotp~=2.9 \ No newline at end of file diff --git a/backend/trip/routers/auth.py b/backend/trip/routers/auth.py index 4668ba6..ca7def4 100644 --- a/backend/trip/routers/auth.py +++ b/backend/trip/routers/auth.py @@ -5,12 +5,16 @@ from fastapi.responses import JSONResponse from ..config import settings from ..db.core import init_user_data from ..deps import SessionDep -from ..models.models import AuthParams, LoginRegisterModel, Token, User -from ..security import (create_access_token, create_tokens, get_oidc_client, - get_oidc_config, hash_password, verify_password) +from ..models.models import (AuthParams, LoginRegisterModel, PendingTOTP, + Token, User) +from ..security import (create_access_token, create_tokens, + generate_totp_secret, get_oidc_client, get_oidc_config, + hash_password, verify_password, verify_totp_code) +from ..utils.date import dt_utc, dt_utc_offset from ..utils.utils import generate_filename router = APIRouter(prefix="/api/auth", tags=["auth"]) +pending_totp_usernames = {} @router.get("/params", response_model=AuthParams) @@ -104,8 +108,8 @@ async def oidc_login( return create_tokens(data={"sub": username}) -@router.post("/login", response_model=Token) -def login(req: LoginRegisterModel, session: SessionDep) -> Token: +@router.post("/login", response_model=Token | PendingTOTP) +def login(req: LoginRegisterModel, session: SessionDep) -> Token | PendingTOTP: if settings.OIDC_CLIENT_ID or settings.OIDC_CLIENT_SECRET: raise HTTPException(status_code=400, detail="OIDC is configured") @@ -113,9 +117,39 @@ def login(req: LoginRegisterModel, session: SessionDep) -> Token: if not db_user or not verify_password(req.password, db_user.password): raise HTTPException(status_code=401, detail="Invalid credentials") + if db_user.totp_enabled: + pending_totp_secret = generate_totp_secret() # A random code to track for verify fn + pending_totp_usernames[db_user.username] = { + "pending_code": pending_totp_secret, + "exp": dt_utc_offset(5), + } + return {"pending_code": pending_totp_secret, "username": db_user.username} + return create_tokens(data={"sub": db_user.username}) +@router.post("/login_totp", response_model=Token) +async def login_verify_totp( + session: SessionDep, + username: str = Body(..., embed=True), + pending_code: str = Body(..., embed=True), + code: str = Body(..., embed=True), +) -> Token: + user = session.get(User, username) + if not user or not user.totp_enabled: + raise HTTPException(status_code=401, detail="Invalid TOTP flow") + + record = pending_totp_usernames.get(username) + if not record or record["exp"] < dt_utc() or record["pending_code"] != pending_code: + pending_totp_usernames.pop(username, None) + raise HTTPException(status_code=401, detail="Unauthorized") + + if not verify_totp_code(user.totp_secret, code): + raise HTTPException(status_code=403, detail="Invalid TOTP code") + + return create_tokens({"sub": user.username}) + + @router.post("/register", response_model=Token) def register(req: LoginRegisterModel, session: SessionDep) -> Token: if not settings.REGISTER_ENABLE: diff --git a/backend/trip/routers/settings.py b/backend/trip/routers/settings.py index a94f38c..52bded6 100644 --- a/backend/trip/routers/settings.py +++ b/backend/trip/routers/settings.py @@ -1,8 +1,8 @@ from pathlib import Path from typing import Annotated -from fastapi import (APIRouter, BackgroundTasks, Depends, File, HTTPException, - UploadFile) +from fastapi import (APIRouter, BackgroundTasks, Body, Depends, File, + HTTPException, UploadFile) from fastapi.responses import FileResponse from sqlmodel import select @@ -10,6 +10,7 @@ from ..config import settings from ..deps import SessionDep, get_current_username from ..models.models import (Backup, BackupRead, BackupStatus, User, UserRead, UserUpdate) +from ..security import generate_totp_secret, verify_totp_code from ..utils.utils import check_update from ..utils.zip import (process_backup_export, process_backup_import, process_legacy_import) @@ -46,6 +47,71 @@ def put_user_settings( return UserRead.serialize(db_user) +@router.post("/totp") +async def enable_totp(session: SessionDep, current_user: Annotated[str, Depends(get_current_username)]): + db_user = session.get(User, current_user) + if not db_user: + raise HTTPException(status_code=404, detail="The resource does not exist") + + if db_user.totp_enabled: + raise HTTPException(status_code=400, detail="Bad request") + + totp_secret = generate_totp_secret() + db_user.totp_secret = totp_secret + session.add(db_user) + session.commit() + + return {"secret": totp_secret} + + +@router.post("/totp/verify") +async def verify_totp( + session: SessionDep, + current_user: Annotated[str, Depends(get_current_username)], + code: str = Body(..., embed=True), +): + db_user = session.get(User, current_user) + if not db_user: + raise HTTPException(status_code=404, detail="The resource does not exist") + + if not db_user.totp_secret or db_user.totp_enabled: + raise HTTPException(status_code=400, detail="Bad request") + + success = verify_totp_code(db_user.totp_secret, code) + if not success: + db_user.totp_secret = None + session.add(db_user) + session.commit() + raise HTTPException(status_code=403, detail="Invalid code") + + db_user.totp_enabled = True + session.add(db_user) + session.commit() + + return {} + + +@router.delete("/totp/{code}") +async def delete_totp( + session: SessionDep, code: str, current_user: Annotated[str, Depends(get_current_username)] +): + db_user = session.get(User, current_user) + if not db_user or not db_user.totp_enabled or not db_user.totp_secret: + raise HTTPException(status_code=400, detail="Bad request") + + success = verify_totp_code(db_user.totp_secret, code) + if not success: + raise HTTPException(status_code=403, detail="Invalid code") + + db_user.totp_secret = None + db_user.totp_enabled = False + + session.add(db_user) + session.commit() + + return {} + + @router.get("/checkversion") async def check_version(session: SessionDep, current_user: Annotated[str, Depends(get_current_username)]): return await check_update() diff --git a/backend/trip/security.py b/backend/trip/security.py index b3b9590..9b9c583 100644 --- a/backend/trip/security.py +++ b/backend/trip/security.py @@ -1,6 +1,7 @@ from datetime import UTC, datetime, timedelta import jwt +import pyotp from argon2 import PasswordHasher from argon2 import exceptions as argon_exceptions from authlib.integrations.httpx_client import OAuth2Client @@ -14,6 +15,15 @@ ph = PasswordHasher() OIDC_CONFIG = {} +def generate_totp_secret() -> str: + return pyotp.random_base32() + + +def verify_totp_code(secret: str, code: str) -> bool: + totp = pyotp.TOTP(secret) + return totp.verify(code) + + def hash_password(password: str) -> str: return ph.hash(password) diff --git a/src/src/app/modals/totp-verify-modal/totp-verify-modal.component.html b/src/src/app/modals/totp-verify-modal/totp-verify-modal.component.html new file mode 100644 index 0000000..1e420f5 --- /dev/null +++ b/src/src/app/modals/totp-verify-modal/totp-verify-modal.component.html @@ -0,0 +1,18 @@ +@if (token) { +
{{ message }}
+ ++{{ token }}
+ Enter your TOTP code to continue +
+