You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

176 lines
5.7 KiB
Python

# 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}