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
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 "):

View File

@@ -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):

View File

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

View File

@@ -11,6 +11,9 @@
<input type="text" name="username" required autofocus>
<label>密码</label>
<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>
</form>
<p class="auth-link">没有账号?<a href="/register">注册</a></p>