feat(session-verify): 添加 TOTP 支持 (#34)

* chore(deps): add pyotp

* feat(auth): implement TOTP verification

feat(auth): implement TOTP verification and email verification services

- Added TOTP keys management with a new database model `TotpKeys`.
- Introduced `EmailVerification` and `LoginSession` models for email verification.
- Created `verification_service` to handle email verification logic and TOTP processes.
- Updated user response models to include session verification methods.
- Implemented routes for TOTP creation, verification, and fallback to email verification.
- Enhanced login session management to support new location checks and verification methods.
- Added migration script to create `totp_keys` table in the database.

* feat(config): update config example

* docs(totp): complete creating TOTP flow

* refactor(totp): resolve review

* feat(api): forbid unverified request

* fix(totp): trace session by token id to avoid other sessions are forbidden

* chore(linter): make pyright happy

* fix(totp): only mark sessions with a specified token id
This commit is contained in:
MingxuanGame
2025-09-21 19:50:11 +08:00
committed by GitHub
parent 7b4ff1224d
commit 1527e23b43
25 changed files with 684 additions and 235 deletions

View File

@@ -5,7 +5,7 @@ from typing import Annotated
from app.auth import get_token_by_access_token
from app.config import settings
from app.database import User
from app.database.auth import V1APIKeys
from app.database.auth import OAuthToken, V1APIKeys
from app.models.oauth import OAuth2ClientCredentialsBearer
from .database import Database
@@ -75,10 +75,10 @@ async def v1_authorize(
raise HTTPException(status_code=401, detail="Invalid API key")
async def get_client_user(
async def get_client_user_and_token(
db: Database,
token: Annotated[str, Depends(oauth2_password)],
):
) -> tuple[User, OAuthToken]:
token_record = await get_token_by_access_token(db, token)
if not token_record:
raise HTTPException(status_code=401, detail="Invalid or expired token")
@@ -87,17 +87,33 @@ async def get_client_user(
if not user:
raise HTTPException(status_code=401, detail="Invalid or expired token")
await db.refresh(user)
return user, token_record
UserAndToken = tuple[User, OAuthToken]
async def get_client_user_no_verified(user_and_token: UserAndToken = Depends(get_client_user_and_token)):
return user_and_token[0]
async def get_client_user(db: Database, user_and_token: UserAndToken = Depends(get_client_user_and_token)):
from app.service.verification_service import LoginSessionService
user, token = user_and_token
if await LoginSessionService.check_is_need_verification(db, user.id, token.id):
raise HTTPException(status_code=403, detail="User not verified")
return user
async def get_current_user(
async def get_current_user_and_token(
db: Database,
security_scopes: SecurityScopes,
token_pw: Annotated[str | None, Depends(oauth2_password)] = None,
token_code: Annotated[str | None, Depends(oauth2_code)] = None,
token_client_credentials: Annotated[str | None, Depends(oauth2_client_credentials)] = None,
) -> User:
) -> UserAndToken:
"""获取当前认证用户"""
token = token_pw or token_code or token_client_credentials
if not token:
@@ -120,6 +136,10 @@ async def get_current_user(
user = (await db.exec(select(User).where(User.id == token_record.user_id))).first()
if not user:
raise HTTPException(status_code=401, detail="Invalid or expired token")
return user, token_record
await db.refresh(user)
return user
async def get_current_user(
user_and_token: UserAndToken = Depends(get_current_user_and_token),
) -> User:
return user_and_token[0]