# backend/app/routers/auth.py — 로그인/로그아웃/세션/내보내기/토큰 (phase-15) import uuid from datetime import UTC, datetime, timedelta from fastapi import APIRouter, Depends, HTTPException, Request, Response from fastapi.responses import StreamingResponse from sqlmodel import Session, select from ..auth.deps import current_user from ..auth.password import verify_password from ..auth.tokens import ( hash_token, new_api_token, new_session_id, sign_session_id, unsign_session_id, ) from ..config import get_settings from ..db import get_session from ..models import ApiToken, AuthSession, Person, UserCredential from ..schemas import LoginIn, MeOut, ProfileUpdateIn, TokenInfoOut, TokenOut from ..security.audit import audit from ..services.export_service import build_export router = APIRouter() _LOGIN_FAIL = "이메일 또는 비밀번호가 올바르지 않아요" def _me(session: Session, user: Person) -> MeOut: cred = session.exec(select(UserCredential).where(UserCredential.user_id == user.id)).first() return MeOut( id=user.id, name=user.name, initial=user.initial, email=cred.email if cred else (user.email or ""), role=cred.role if cred else (user.role or "member"), ) @router.post("/auth/login", response_model=MeOut) def login(body: LoginIn, request: Request, response: Response, s: Session = Depends(get_session)): st = get_settings() ip = request.client.host if request.client else None cred = s.exec(select(UserCredential).where(UserCredential.email == body.email)).first() if not cred or not cred.is_active or not verify_password(body.password, cred.password_hash): audit(s, cred.user_id if cred else None, "login.fail", ip=ip, detail=body.email) raise HTTPException(401, _LOGIN_FAIL) now = datetime.now(UTC) sid = new_session_id() s.add( AuthSession( id=sid, user_id=cred.user_id, created_at=now, expires_at=now + timedelta(seconds=st.session_ttl_s), user_agent=request.headers.get("User-Agent"), ip=ip, ) ) cred.last_login_at = now s.add(cred) s.commit() response.set_cookie( st.session_cookie_name, sign_session_id(sid), max_age=st.session_ttl_s, httponly=True, secure=st.cookie_secure, samesite="lax", ) audit(s, cred.user_id, "login", ip=ip) user = s.get(Person, cred.user_id) return _me(s, user) @router.post("/auth/logout") def logout(request: Request, response: Response, s: Session = Depends(get_session)): st = get_settings() raw = request.cookies.get(st.session_cookie_name) uid = None if raw: sid = unsign_session_id(raw) if sid: sess = s.get(AuthSession, sid) if sess: sess.revoked = True uid = sess.user_id s.add(sess) s.commit() response.delete_cookie(st.session_cookie_name) audit(s, uid, "logout") return {"ok": True} @router.get("/me", response_model=MeOut) def me(user: Person = Depends(current_user), s: Session = Depends(get_session)): return _me(s, user) @router.patch("/me", response_model=MeOut) def update_me( body: ProfileUpdateIn, user: Person = Depends(current_user), s: Session = Depends(get_session), ): """표시 이름/역할 갱신. role 은 자격(UserCredential)이 있으면 거기에, 없으면 Person 에 저장.""" if body.name is not None and body.name.strip(): user.name = body.name.strip() user.initial = body.name.strip()[0] cred = s.exec(select(UserCredential).where(UserCredential.user_id == user.id)).first() if body.role is not None: role = body.role.strip() if cred: cred.role = role s.add(cred) else: user.role = role s.add(user) s.commit() s.refresh(user) audit(s, user.id, "profile.update") return _me(s, user) @router.get("/me/export") def export_me(user: Person = Depends(current_user), s: Session = Depends(get_session)): data = build_export(s, user) audit(s, user.id, "export") return StreamingResponse( iter([data]), media_type="application/zip", headers={"Content-Disposition": f'attachment; filename="ari-export-{user.id}.zip"'}, ) @router.get("/me/tokens", response_model=list[TokenInfoOut]) def list_tokens(user: Person = Depends(current_user), s: Session = Depends(get_session)): rows = s.exec( select(ApiToken).where(ApiToken.user_id == user.id).order_by(ApiToken.created_at.desc()) ).all() return [ TokenInfoOut( id=t.id, name=t.name, created_at=t.created_at.isoformat(), last_used_at=t.last_used_at.isoformat() if t.last_used_at else None, revoked=t.revoked, ) for t in rows ] @router.post("/me/tokens", response_model=TokenOut) def create_token( name: str = "API 토큰", user: Person = Depends(current_user), s: Session = Depends(get_session) ): plain = new_api_token() tid = "tok-" + uuid.uuid4().hex[:8] s.add(ApiToken(id=tid, user_id=user.id, name=name, token_hash=hash_token(plain))) s.commit() audit(s, user.id, "api_token.create", target=tid) return TokenOut(id=tid, name=name, token=plain, created_at=datetime.now(UTC).isoformat()) @router.delete("/me/tokens/{tid}") def revoke_token(tid: str, user: Person = Depends(current_user), s: Session = Depends(get_session)): tok = s.get(ApiToken, tid) if not tok or tok.user_id != user.id: raise HTTPException(404, "not found") tok.revoked = True s.add(tok) s.commit() audit(s, user.id, "api_token.revoke", target=tid) return {"ok": True}