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

@@ -7,16 +7,20 @@ import secrets
import string
from app.config import settings
from app.const import BACKUP_CODE_LENGTH
from app.database import (
OAuthToken,
User,
)
from app.database.auth import TotpKeys
from app.log import logger
from app.models.totp import FinishStatus, StartCreateTotpKeyResp
from app.utils import utcnow
import bcrypt
from jose import JWTError, jwt
from passlib.context import CryptContext
import pyotp
from redis.asyncio import Redis
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
@@ -277,3 +281,76 @@ async def get_user_by_authorization_code(
await db.refresh(user)
return (user, scopes.split(","))
return None
def totp_redis_key(user: User) -> str:
return f"totp:setup:{user.email}"
async def start_create_totp_key(user: User, redis: Redis) -> StartCreateTotpKeyResp:
secret = pyotp.random_base32()
await redis.hset(totp_redis_key(user), mapping={"secret": secret, "fails": 0}) # pyright: ignore[reportGeneralTypeIssues]
await redis.expire(totp_redis_key(user), 300)
return StartCreateTotpKeyResp(
secret=secret,
uri=pyotp.totp.TOTP(secret).provisioning_uri(name=user.email, issuer_name=settings.totp_issuer),
)
def verify_totp_key(secret: str, code: str) -> bool:
return pyotp.TOTP(secret).verify(code, valid_window=1)
def _generate_backup_codes(count=10, length=BACKUP_CODE_LENGTH) -> list[str]:
alphabet = string.ascii_uppercase + string.digits
return ["".join(secrets.choice(alphabet) for _ in range(length)) for _ in range(count)]
async def _store_totp_key(user: User, secret: str, db: AsyncSession) -> list[str]:
backup_codes = _generate_backup_codes()
hashed_codes = [bcrypt.hashpw(code.encode(), bcrypt.gensalt()) for code in backup_codes]
totp_secret = TotpKeys(user_id=user.id, secret=secret, backup_keys=[code.decode() for code in hashed_codes])
db.add(totp_secret)
await db.commit()
return backup_codes
async def finish_create_totp_key(
user: User, code: str, redis: Redis, db: AsyncSession
) -> tuple[FinishStatus, list[str]]:
data = await redis.hgetall(totp_redis_key(user)) # pyright: ignore[reportGeneralTypeIssues]
if not data or "secret" not in data or "fails" not in data:
return FinishStatus.INVALID, []
secret = data["secret"]
fails = int(data["fails"])
if fails >= 3:
await redis.delete(totp_redis_key(user)) # pyright: ignore[reportGeneralTypeIssues]
return FinishStatus.TOO_MANY_ATTEMPTS, []
if verify_totp_key(secret, code):
await redis.delete(totp_redis_key(user)) # pyright: ignore[reportGeneralTypeIssues]
backup_codes = await _store_totp_key(user, secret, db)
return FinishStatus.SUCCESS, backup_codes
else:
fails += 1
await redis.hset(totp_redis_key(user), "fails", str(fails)) # pyright: ignore[reportGeneralTypeIssues]
return FinishStatus.FAILED, []
async def disable_totp(user: User, db: AsyncSession) -> None:
totp = await db.get(TotpKeys, user.id)
if totp:
await db.delete(totp)
await db.commit()
def check_totp_backup_code(totp: TotpKeys, code: str) -> bool:
for hashed_code in totp.backup_keys:
if bcrypt.checkpw(code.encode(), hashed_code.encode()):
copy = totp.backup_keys[:]
copy.remove(hashed_code)
totp.backup_keys = copy
return True
return False