diff --git a/app/auth.py b/app/auth.py index 8e0fd4d..46d8116 100644 --- a/app/auth.py +++ b/app/auth.py @@ -1,14 +1,15 @@ -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from uuid import uuid4 import bcrypt from fastapi import Cookie, Depends, HTTPException, Request from fastapi.responses import RedirectResponse -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session as DbSession from app.config import SECRET_KEY, SESSION_COOKIE_NAME from app.database import get_db -from app.models import ApiKey, User +from app.models import ApiKey, User, UserSession + # ---------- Password ---------- @@ -22,34 +23,49 @@ def verify_password(password: str, password_hash: str) -> bool: # ---------- Session ---------- -# In-memory session store: token -> user_id -_sessions: dict[str, int] = {} - - -def create_session(user_id: int) -> str: +def create_session(user_id: int, db: DbSession, remember_me: bool = False) -> str: token = uuid4().hex - _sessions[token] = user_id + expires_at = (datetime.now(timezone.utc) + timedelta(days=365)) if remember_me else None + db.add(UserSession(token=token, user_id=user_id, expires_at=expires_at)) + db.commit() return token -def destroy_session(token: str) -> None: - _sessions.pop(token, None) +def destroy_session(token: str, db: DbSession) -> None: + db.query(UserSession).filter(UserSession.token == token).delete() + db.commit() -def get_session_user_id(token: str | None) -> int | None: +def get_session_user_id(token: str | None, db: DbSession) -> int | None: if token is None: return None - return _sessions.get(token) + s = db.query(UserSession).filter(UserSession.token == token).first() + if s is None: + return None + if s.expires_at and s.expires_at.replace(tzinfo=timezone.utc) < datetime.now(timezone.utc): + db.delete(s) + db.commit() + return None + return s.user_id + + +def cleanup_expired_sessions(db: DbSession) -> None: + now = datetime.now(timezone.utc) + db.query(UserSession).filter( + UserSession.expires_at.isnot(None), + UserSession.expires_at < now, + ).delete() + db.commit() # ---------- Web Dependencies ---------- def get_current_user_web( request: Request, - db: Session = Depends(get_db), + db: DbSession = Depends(get_db), ) -> User: token = request.cookies.get(SESSION_COOKIE_NAME) - uid = get_session_user_id(token) + uid = get_session_user_id(token, db) if uid is None: raise HTTPException(status_code=303, headers={"Location": "/login"}) user = db.query(User).filter(User.id == uid).first() @@ -58,10 +74,10 @@ def get_current_user_web( return user -def redirect_if_not_logged_in(request: Request, db: Session) -> User | None: +def redirect_if_not_logged_in(request: Request, db: DbSession) -> User | None: """Return user if logged in, else None — caller decides redirect.""" token = request.cookies.get(SESSION_COOKIE_NAME) - uid = get_session_user_id(token) + uid = get_session_user_id(token, db) if uid is None: return None return db.query(User).filter(User.id == uid).first() @@ -71,7 +87,7 @@ def redirect_if_not_logged_in(request: Request, db: Session) -> User | None: def get_current_user_api( request: Request, - db: Session = Depends(get_db), + db: DbSession = Depends(get_db), ) -> User: auth_header = request.headers.get("Authorization", "") if not auth_header.startswith("Bearer "): diff --git a/app/models.py b/app/models.py index 84c4a65..8a3f74d 100644 --- a/app/models.py +++ b/app/models.py @@ -21,6 +21,18 @@ class User(Base): api_keys = relationship("ApiKey", back_populates="user", cascade="all, delete-orphan") profiles = relationship("Profile", back_populates="user", cascade="all, delete-orphan") + sessions = relationship("UserSession", back_populates="user", cascade="all, delete-orphan") + + +class UserSession(Base): + __tablename__ = "user_sessions" + + token = Column(String(64), primary_key=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + created_at = Column(DateTime, default=_utcnow) + expires_at = Column(DateTime, nullable=True) + + user = relationship("User", back_populates="sessions") class ApiKey(Base): diff --git a/app/routers/web.py b/app/routers/web.py index 267b1b5..1f3bcb8 100644 --- a/app/routers/web.py +++ b/app/routers/web.py @@ -42,15 +42,23 @@ def login_submit( request: Request, username: str = Form(...), password: str = Form(...), + remember_me: str = Form(""), db: Session = Depends(get_db), ): + from app.auth import cleanup_expired_sessions + cleanup_expired_sessions(db) user = db.query(User).filter(User.username == username).first() if not user or not verify_password(password, user.password_hash): return templates.TemplateResponse( "login.html", {"request": request, "error": "用户名或密码错误"}, status_code=401 ) + is_remember = remember_me == "on" + token = create_session(user.id, db, remember_me=is_remember) resp = _redirect("/dashboard") - resp.set_cookie(SESSION_COOKIE_NAME, create_session(user.id), httponly=True) + cookie_kwargs = {"httponly": True, "samesite": "lax"} + if is_remember: + cookie_kwargs["max_age"] = 365 * 24 * 3600 + resp.set_cookie(SESSION_COOKIE_NAME, token, **cookie_kwargs) return resp @@ -84,9 +92,9 @@ def register_submit( @router.get("/logout") -def logout(request: Request): +def logout(request: Request, db: Session = Depends(get_db)): token = request.cookies.get(SESSION_COOKIE_NAME) - destroy_session(token or "") + destroy_session(token or "", db) resp = _redirect("/login") resp.delete_cookie(SESSION_COOKIE_NAME) return resp diff --git a/app/templates/login.html b/app/templates/login.html index de97472..a7c2bf6 100644 --- a/app/templates/login.html +++ b/app/templates/login.html @@ -11,6 +11,9 @@ +