created new role
This commit is contained in:
160
roles/trade_hub/files/trade-hub/backend/app/auth.py
Normal file
160
roles/trade_hub/files/trade-hub/backend/app/auth.py
Normal file
@@ -0,0 +1,160 @@
|
||||
"""Authentication routes: register, login, logout and current-user lookup.
|
||||
|
||||
Identity is a self-hosted username/password pair. Passwords are hashed with
|
||||
Argon2; sessions are opaque random tokens whose SHA-256 is persisted, delivered
|
||||
in an httpOnly cookie.
|
||||
"""
|
||||
from datetime import timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session as DbSession
|
||||
|
||||
from app import config, security
|
||||
from app.db import get_db
|
||||
from app.deps import get_current_user, require_same_origin
|
||||
from app.models import User, UserSession, utcnow
|
||||
from app.schemas import AuthResponse, LoginRequest, RegisterRequest, UserOut
|
||||
|
||||
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
|
||||
|
||||
throttle = security.LoginThrottle(
|
||||
config.LOGIN_MAX_ATTEMPTS, config.LOGIN_WINDOW_SECONDS
|
||||
)
|
||||
|
||||
_COOKIE_MAX_AGE = config.SESSION_TTL_DAYS * 24 * 60 * 60
|
||||
|
||||
|
||||
def _set_session_cookie(response: Response, token: str) -> None:
|
||||
response.set_cookie(
|
||||
key=config.COOKIE_NAME,
|
||||
value=token,
|
||||
max_age=_COOKIE_MAX_AGE,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
secure=config.COOKIE_SECURE,
|
||||
path="/",
|
||||
)
|
||||
|
||||
|
||||
def _issue_session(db: DbSession, user: User) -> str:
|
||||
token = security.new_session_token()
|
||||
now = utcnow()
|
||||
# Drop this user's expired sessions while we are here.
|
||||
db.execute(
|
||||
delete(UserSession).where(
|
||||
UserSession.user_id == user.id, UserSession.expires_at <= now
|
||||
)
|
||||
)
|
||||
db.add(
|
||||
UserSession(
|
||||
token_hash=security.hash_token(token),
|
||||
user_id=user.id,
|
||||
created_at=now,
|
||||
expires_at=now + timedelta(days=config.SESSION_TTL_DAYS),
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
return token
|
||||
|
||||
|
||||
@router.post(
|
||||
"/register",
|
||||
status_code=201,
|
||||
response_model=AuthResponse,
|
||||
dependencies=[Depends(require_same_origin)],
|
||||
)
|
||||
def register(payload: RegisterRequest, response: Response, db: DbSession = Depends(get_db)):
|
||||
username = payload.username.strip()
|
||||
if not security.valid_username(username):
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="Username must be 3-32 characters: letters, digits, '.', '_' or '-'",
|
||||
)
|
||||
|
||||
username_key = security.normalize_username(username)
|
||||
existing = db.execute(
|
||||
select(User).where(User.username_key == username_key)
|
||||
).scalar_one_or_none()
|
||||
if existing is not None:
|
||||
raise HTTPException(status_code=409, detail="Username is already taken")
|
||||
|
||||
user = User(
|
||||
username=username,
|
||||
username_key=username_key,
|
||||
password_hash=security.hash_password(payload.password),
|
||||
)
|
||||
db.add(user)
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=409, detail="Username is already taken")
|
||||
|
||||
db.refresh(user)
|
||||
_set_session_cookie(response, _issue_session(db, user))
|
||||
return {"user": UserOut.model_validate(user)}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/login",
|
||||
response_model=AuthResponse,
|
||||
dependencies=[Depends(require_same_origin)],
|
||||
)
|
||||
def login(payload: LoginRequest, request: Request, response: Response, db: DbSession = Depends(get_db)):
|
||||
username_key = security.normalize_username(payload.username)
|
||||
ip = security.client_ip(request)
|
||||
|
||||
wait = throttle.retry_after(username_key, ip)
|
||||
if wait:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="Too many failed login attempts. Try again later.",
|
||||
headers={"Retry-After": str(wait)},
|
||||
)
|
||||
|
||||
user = db.execute(
|
||||
select(User).where(User.username_key == username_key)
|
||||
).scalar_one_or_none()
|
||||
|
||||
if user is None:
|
||||
security.burn_password_check(payload.password)
|
||||
throttle.record_failure(username_key, ip)
|
||||
raise HTTPException(status_code=401, detail="Invalid username or password")
|
||||
|
||||
if not security.verify_password(payload.password, user.password_hash):
|
||||
throttle.record_failure(username_key, ip)
|
||||
raise HTTPException(status_code=401, detail="Invalid username or password")
|
||||
|
||||
throttle.reset(username_key, ip)
|
||||
if security.needs_rehash(user.password_hash):
|
||||
user.password_hash = security.hash_password(payload.password)
|
||||
db.commit()
|
||||
|
||||
_set_session_cookie(response, _issue_session(db, user))
|
||||
return {"user": UserOut.model_validate(user)}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/logout",
|
||||
status_code=204,
|
||||
dependencies=[Depends(require_same_origin)],
|
||||
)
|
||||
def logout(request: Request, db: DbSession = Depends(get_db)):
|
||||
token = request.cookies.get(config.COOKIE_NAME)
|
||||
if token:
|
||||
db.execute(
|
||||
delete(UserSession).where(
|
||||
UserSession.token_hash == security.hash_token(token)
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
response = Response(status_code=204)
|
||||
response.delete_cookie(config.COOKIE_NAME, path="/")
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/me", response_model=AuthResponse)
|
||||
def me(user: User = Depends(get_current_user)):
|
||||
return {"user": UserOut.model_validate(user)}
|
||||
Reference in New Issue
Block a user