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