feat(auth): add remember me option with persistent DB sessions
This commit is contained in:
52
app/auth.py
52
app/auth.py
@@ -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 "):
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user