Imported from hiennguyen9874/api-base-project (
api/app/src/authen/AGENTS.md). Install upstream withnpx skills add hiennguyen9874/api-base-project --skill authen. Copyright stays with the author.
authen — Authentication module
Owner of email/password login, JWT access/refresh issuance, refresh-token rotation and revocation, password recovery/reset, and the request guards other features depend on. Mounted at /api/v0/authen (router prefix /v0/authen + API_PREFIX=/api; see app/src/route.py, app/core/app_factory.py).
It owns no Postgres table. Identity lives in the users table (app/src/users/db_models.py → User); live refresh sessions live in Redis. Token crypto lives in app/core/auth/security.py; do not reimplement it here.
File map
| File | Role — read it when… |
|---|---|
router/v0.py |
HTTP surface. Read to change endpoints, cookies, rate limits, request/response shape. |
router/__init__.py |
Mounts v0.router at prefix /v0/authen, tag Authentication. |
services.py |
Business rules (AuthenService + singleton authen_service). Read to change login/refresh/logout/reset logic. |
dependencies.py |
Request guards (get_current_*, get_refresh_token, OAuth2PasswordBearerWithCookie). Read to protect a new route. |
refresh_token_repository.py |
Redis revocation store (RefreshTokenRepository + singleton). Read to change session storage. |
schemas.py |
Token, TokenPayload, OIDCUser. |
errors.py |
Error helpers mapping to app.errors.AppException. |
__init__.py |
Empty. |
Cross-module inputs this code assumes: app/src/users/services.py (user_service.get_by_email/update_password), app/src/dependencies.py (get_db, get_async_cache), app/core/settings/token.py|email.py|ratelimit.py + PROTECT_MEDIA in app/core/settings/app.py, app/core/messaging/emails.py (reset token + mailer). Note: root api/AGENTS.md cites a refresh-token repository under app/core/auth/ — the actual implementation is this module's refresh_token_repository.py.
Token and credential model
- JWTs are HS256 (
settings.TOKEN.ALGORITHM), payload{sub: <email>, exp: …}. Access and refresh tokens use separate secrets (ACCESS_TOKEN_SECRET_KEYvsREFRESH_TOKEN_SECRET_KEY). Lifetimes: accessACCESS_TOKEN_EXPIRE_DURATION(default 11520 min = 8 days), refreshREFRESH_TOKEN_EXPIRE_DURATION(default 21600 min = 15 days). - Password hashing/verify:
get_password_hash/verify_passwordinapp/core/auth/security.py(passlib bcrypt wrapper + raw bcrypt). Gotcha:verify_passworddiscards thepwd_context.verify()return value whenusing_bcrypt=Falseand falls through tobcrypt.checkpw— login therefore effectively validates viabcrypt.checkpwagainst hashes produced bypwd_context.hash(bcrypt-compatible). Preserve this pairing when touching password code. - Password-reset tokens are a third JWT family (
app/core/messaging/emails.py:generate_password_reset_token/verify_password_reset_token), signed withEMAIL.RESET_TOKEN_SECRET_KEY, 60 min expiry. They never touch Redis.
Storage
- Postgres
usersrow (viaUserService): lookup key isemail(subclaim). Relevant columns:email(unique),hashed_password,is_active.login()andreset_password()rejectis_active=Falsewithinactive_user(FORBIDDEN); unknown email raisesusersuser_not_found; bad password raiseswrong_password. - Redis refresh sessions: key
RefreshToken:{email}, a sorted set where member = refresh-token string, score = expiry timestamp.add()usesZADD … GT,EXPIREAT, plus lazyZREMRANGEBYSCORE -inf … now-1mincleanup;check()runs the same cleanup thenZSCORE;delete()=ZREM+ cleanup;delete_all()=DELkey. Everycheck/add/deleteprunes expired entries, so stale members disappear without a sweeper.
Flows
- Login —
POST /login→AuthenService.login(db, cache, email, password):get_by_email→verify_password→is_activecheck →create_token()mints both JWTs →refresh_token_repository.add()stores refresh in Redis → router setsaccess_token+refresh_tokenHttpOnly cookies (flags/expiry fromTOKEN.COOKIE_*) and returnsToken{access_token, refresh_token, token_type:"bearer"}. Form fieldusernamecarries the email (OAuth2PasswordRequestForm). Rate-limited byRATELIMIT.LOGIN_RATELIMIT1/2. - Authenticated request —
OAuth2PasswordBearerWithCookie.__call__reads theaccess_tokencookie first, elseAuthorization: Bearer. Guards:get_current_user(token →get_user_from_token→get_by_email; raisesnot_authenticated/ app-level not-found) andget_current_active_user(addsis_activecheck). This last one is whatitems,usersrouters depend on — use it for new protected routes. - Refresh rotation —
POST /refreshwith refresh token from cookie orrefresh_tokenheader (get_refresh_tokendependency; raisesrefresh_token_not_foundif neither):parse_token(refresh secret)→check()in Redis (miss →refresh_token_not_found) →delete(old)→ re-lookup user →create_token()+add(new)→ fresh cookies + new pair in body. Old token is single-use; concurrent reuse fails closed. - Logout / logout-all —
POST /logout(logout(): parse refresh token,deletethat member) andPOST /logout-all(logout_all_with_token(): parse thendelete_allfor thesubemail). Both 204 and clear both cookies.logout_all(email)variant exists for server-side use (takes email directly). - Password recovery/reset —
POST /password-recovery/{email}:recover_passwordlooks up user (unknown → generic not-found, no mail) thengenerate_password_reset_token(email)+send_reset_password_email(...)(link built fromEMAIL.SERVER_HOST, validity text fromRESET_TOKEN_EXPIRE_HOURS).POST /reset-password({token, new_password}in body):verify_password_reset_token(None →invalid_reset_password_token) → user lookup +is_activecheck →user_service.update_password. - OIDC exchange —
exchange_oidc_token(cache, user)mints + stores a pair for an already-validatedUser. No OIDC validation happens here and no v0 route currently calls it;schemas.OIDCUser(active fields:sub,email, optionalname/username/preferred_username/email_verified) is likewise unused by the router — check callers before assuming an OIDC login endpoint. - Media/static probe —
GET /auth-staticwithget_current_media_user: returnsNone(anonymous allowed) whenAPP.PROTECT_MEDIA=False; otherwise requires a valid access token. Handler body ispass— it exists forauth_request-style gating, not data.
API (all under /api/v0/authen)
| Method + path | Auth input | Success |
|---|---|---|
POST /login |
OAuth2 form (username=email, password) |
200 Token, sets both cookies |
POST /refresh |
refresh_token cookie or header |
200 {data: Token} envelope, rotates cookies |
POST /logout |
refresh_token cookie or header |
204, clears cookies |
POST /logout-all |
refresh_token cookie or header |
204, revokes all sessions for sub, clears cookies |
POST /password-recovery/{email} |
path email | 200 {msg} |
POST /reset-password |
JSON body {token, new_password} |
200 {msg} |
GET /auth-static |
optional access token (see above) | 200 empty (gate only) |
Key functions (search targets, not full-file reads)
services.py—AuthenService.login,.refresh_token(rotation),.create_token(mint pair + expiries),.parse_token(decode + mapExpiredSignatureError→expired_jwt_token,InvalidTokenError|ValidationError→invalid_jwt_token),.get_user_from_token(access-secret decode →get_by_email),.logout/.logout_all/.logout_all_with_token,.recover_password/.reset_password,.exchange_oidc_token.dependencies.py—OAuth2PasswordBearerWithCookie.__call__(cookie-first extraction,token_url=/api/v0/authen/login),get_current_active_user(default guard),get_current_media_user(PROTECT_MEDIAbranch),get_refresh_token(cookie-then-header).refresh_token_repository.py—_key(email),.check(cleanup +ZSCOREpresence test),.add(upsert-if-greater +EXPIREAT+ prune),.delete,.delete_all.errors.py—not_authenticated,invalid/expired_jwt_token,invalid_jwt_claims,token_not_found,refresh_token_not_set/not_found,invalid_reset_password_token,wrong_password(all UNAUTHORIZED);inactive_user(FORBIDDEN).
Settings knobs
TOKEN.* (algorithm, both secrets/expiries, COOKIE_HTTPONLY/SECURE/SAMESITE), EMAIL.RESET_TOKEN_* + EMAIL.SERVER_HOST/ENABLED, RATELIMIT.LOGIN_*, APP.PROTECT_MEDIA, APP.API_PREFIX. Secrets/expiries change token validity immediately for newly minted tokens; already-issued tokens keep old claims.
Where to change what
- New protected endpoint →
Depends(get_current_active_user)fromapp.src.authen.dependencies; scope rows tocurrent_user(repo convention). - Login/refresh/logout semantics →
services.py+ cookies inrouter/v0.py; session persistence →refresh_token_repository.py. - Token lifetime/secret/cookie flags →
app/core/settings/token.py(+ env), never hardcode in this module. - User fields/lookup →
app/src/users/(db_models.py,services.py); this module only callsget_by_email/update_password. - Error shape →
errors.py+ global handlers (app.errors); keep UNAUTHORIZED vs FORBIDDEN split forinactive_user.