feat(auth): add remember me option with persistent DB sessions

This commit is contained in:
2026-07-05 21:28:58 +08:00
parent 2bd914532e
commit 9692ff8ade
4 changed files with 60 additions and 21 deletions

View File

@@ -1,14 +1,15 @@
from datetime import datetime, timezone from datetime import datetime, timedelta, timezone
from uuid import uuid4 from uuid import uuid4
import bcrypt import bcrypt
from fastapi import Cookie, Depends, HTTPException, Request from fastapi import Cookie, Depends, HTTPException, Request
from fastapi.responses import RedirectResponse 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.config import SECRET_KEY, SESSION_COOKIE_NAME
from app.database import get_db from app.database import get_db
from app.models import ApiKey, User from app.models import ApiKey, User, UserSession
# ---------- Password ---------- # ---------- Password ----------
@@ -22,34 +23,49 @@ def verify_password(password: str, password_hash: str) -> bool:
# ---------- Session ---------- # ---------- Session ----------
# In-memory session store: token -> user_id def create_session(user_id: int, db: DbSession, remember_me: bool = False) -> str:
_sessions: dict[str, int] = {}
def create_session(user_id: int) -> str:
token = uuid4().hex 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 return token
def destroy_session(token: str) -> None: def destroy_session(token: str, db: DbSession) -> None:
_sessions.pop(token, 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: if token is None:
return 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 ---------- # ---------- Web Dependencies ----------
def get_current_user_web( def get_current_user_web(
request: Request, request: Request,
db: Session = Depends(get_db), db: DbSession = Depends(get_db),
) -> User: ) -> User:
token = request.cookies.get(SESSION_COOKIE_NAME) token = request.cookies.get(SESSION_COOKIE_NAME)
uid = get_session_user_id(token) uid = get_session_user_id(token, db)
if uid is None: if uid is None:
raise HTTPException(status_code=303, headers={"Location": "/login"}) raise HTTPException(status_code=303, headers={"Location": "/login"})
user = db.query(User).filter(User.id == uid).first() user = db.query(User).filter(User.id == uid).first()
@@ -58,10 +74,10 @@ def get_current_user_web(
return user 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.""" """Return user if logged in, else None — caller decides redirect."""
token = request.cookies.get(SESSION_COOKIE_NAME) token = request.cookies.get(SESSION_COOKIE_NAME)
uid = get_session_user_id(token) uid = get_session_user_id(token, db)
if uid is None: if uid is None:
return None return None
return db.query(User).filter(User.id == uid).first() 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( def get_current_user_api(
request: Request, request: Request,
db: Session = Depends(get_db), db: DbSession = Depends(get_db),
) -> User: ) -> User:
auth_header = request.headers.get("Authorization", "") auth_header = request.headers.get("Authorization", "")
if not auth_header.startswith("Bearer "): if not auth_header.startswith("Bearer "):

View File

@@ -21,6 +21,18 @@ class User(Base):
api_keys = relationship("ApiKey", back_populates="user", cascade="all, delete-orphan") api_keys = relationship("ApiKey", back_populates="user", cascade="all, delete-orphan")
profiles = relationship("Profile", 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): class ApiKey(Base):

View File

@@ -42,15 +42,23 @@ def login_submit(
request: Request, request: Request,
username: str = Form(...), username: str = Form(...),
password: str = Form(...), password: str = Form(...),
remember_me: str = Form(""),
db: Session = Depends(get_db), 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() user = db.query(User).filter(User.username == username).first()
if not user or not verify_password(password, user.password_hash): if not user or not verify_password(password, user.password_hash):
return templates.TemplateResponse( return templates.TemplateResponse(
"login.html", {"request": request, "error": "用户名或密码错误"}, status_code=401 "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 = _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 return resp
@@ -84,9 +92,9 @@ def register_submit(
@router.get("/logout") @router.get("/logout")
def logout(request: Request): def logout(request: Request, db: Session = Depends(get_db)):
token = request.cookies.get(SESSION_COOKIE_NAME) token = request.cookies.get(SESSION_COOKIE_NAME)
destroy_session(token or "") destroy_session(token or "", db)
resp = _redirect("/login") resp = _redirect("/login")
resp.delete_cookie(SESSION_COOKIE_NAME) resp.delete_cookie(SESSION_COOKIE_NAME)
return resp return resp

View File

@@ -11,6 +11,9 @@
<input type="text" name="username" required autofocus> <input type="text" name="username" required autofocus>
<label>密码</label> <label>密码</label>
<input type="password" name="password" required> <input type="password" name="password" required>
<label class="checkbox-label" style="margin-top:1rem;display:flex;align-items:center;gap:.4rem;cursor:pointer">
<input type="checkbox" name="remember_me" style="width:auto;margin:0"> 记住我
</label>
<button type="submit" class="btn btn-primary">登录</button> <button type="submit" class="btn btn-primary">登录</button>
</form> </form>
<p class="auth-link">没有账号?<a href="/register">注册</a></p> <p class="auth-link">没有账号?<a href="/register">注册</a></p>