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:
77
app/auth.py
77
app/auth.py
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user