feat(auth): 增加登录验证和五天会话

This commit is contained in:
admin committed 2026-09-21 17:46:43 +08:00
1 parent ecd16bcefc
commit 810b52a051
14 files changed
+467 -20

No files matched your search

+186
View File
@@ -0,0 +1,186 @@
import hmac
import os
import secrets
from datetime import timedelta
import pymysql
from flask import Blueprint, current_app, jsonify, redirect, render_template, request, session, url_for
from werkzeug.security import check_password_hash, generate_password_hash
from reading_api import MYSQL_CONFIG
auth_bp = Blueprint("auth", __name__)
_DUMMY_HASH = generate_password_hash("not-a-real-password")
def get_account():
with pymysql.connect(**MYSQL_CONFIG) as conn:
with conn.cursor() as cur:
cur.execute("SELECT username, password_hash FROM weread_auth WHERE id = 1")
return cur.fetchone()
def create_account(username, password_hash):
try:
with pymysql.connect(**MYSQL_CONFIG) as conn:
with conn.cursor() as cur:
cur.execute(
"INSERT INTO weread_auth (id, username, password_hash) VALUES (1, %s, %s)",
(username, password_hash),
)
conn.commit()
return True
except pymysql.IntegrityError:
return False
def valid_csrf(token):
expected = session.get("csrf_token", "")
return bool(expected and token and hmac.compare_digest(expected.encode(), token.encode()))
def csrf_token():
if not current_app.secret_key:
return ""
if "csrf_token" not in session:
session["csrf_token"] = secrets.token_urlsafe(32)
return session["csrf_token"]
def start_session(username):
session.clear()
session.permanent = True
session["auth_user"] = username
csrf_token()
def database_error():
return render_template(
"login.html", error="登录数据库暂时不可用,请稍后重试。", unavailable=True,
), 503
@auth_bp.get("/login")
def login_page():
if not current_app.secret_key:
return render_template(
"login.html", error="服务未配置会话密钥,请联系管理员。", unavailable=True,
), 503
try:
initialized = get_account() is not None
except pymysql.MySQLError:
return database_error()
return render_template(
"login.html",
initialized=initialized,
setup_enabled=bool(current_app.config.get("AUTH_SETUP_TOKEN")),
error=request.args.get("error", ""),
)
@auth_bp.post("/auth/initialize")
def initialize_account():
if not valid_csrf(request.form.get("csrf_token")):
return render_template(
"login.html", initialized=False, setup_enabled=bool(current_app.config.get("AUTH_SETUP_TOKEN")),
error="页面已过期,请刷新后重试。",
), 400
try:
if get_account() is not None:
return redirect(url_for("auth.login_page"))
except pymysql.MySQLError:
return database_error()
setup_token = current_app.config.get("AUTH_SETUP_TOKEN", "")
supplied_token = request.form.get("setup_token", "")
username = request.form.get("username", "").strip()
password = request.form.get("password", "")
if not setup_token or not hmac.compare_digest(setup_token.encode(), supplied_token.encode()):
return render_template("login.html", initialized=False, setup_enabled=bool(setup_token), error="初始化口令无效或尚未配置。"), 403
if not username or len(username) > 64 or len(password) < 12 or len(password) > 256:
return render_template(
"login.html", initialized=False, setup_enabled=True,
error="用户名需为 1-64 个字符,密码需为 12-256 个字符。",
), 400
try:
if not create_account(username, generate_password_hash(password)):
return redirect(url_for("auth.login_page"))
except pymysql.MySQLError:
return database_error()
current_app.config["AUTH_SETUP_TOKEN"] = ""
start_session(username)
return redirect("/")
@auth_bp.post("/auth/login")
def login():
if not valid_csrf(request.form.get("csrf_token")):
return render_template("login.html", initialized=True, error="页面已过期,请刷新后重试。"), 400
username = request.form.get("username", "").strip()
password = request.form.get("password", "")
if len(password) > 256:
return render_template("login.html", initialized=True, error="账号或密码不正确。"), 401
try:
account = get_account()
except pymysql.MySQLError:
return database_error()
stored_hash = account["password_hash"] if account else _DUMMY_HASH
password_matches = check_password_hash(stored_hash, password)
username_matches = account and hmac.compare_digest(account["username"].encode(), username.encode())
if not account or not username_matches or not password_matches:
return render_template("login.html", initialized=True, error="账号或密码不正确。"), 401
start_session(account["username"])
return redirect("/")
@auth_bp.post("/auth/logout")
def logout():
if not valid_csrf(request.form.get("csrf_token")):
return "页面已过期,请刷新后重试。", 400
session.clear()
return redirect(url_for("auth.login_page"))
def register_auth(app):
app.config.update(
SECRET_KEY=os.environ.get("WEREAD_SESSION_SECRET") or None,
AUTH_SETUP_TOKEN=os.environ.get("WEREAD_AUTH_SETUP_TOKEN", ""),
PERMANENT_SESSION_LIFETIME=timedelta(days=5),
SESSION_REFRESH_EACH_REQUEST=False,
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE="Lax",
SESSION_COOKIE_SECURE=(
os.environ.get("WEREAD_SESSION_COOKIE_SECURE", "1").lower()
not in {"0", "false", "no"}
),
)
app.register_blueprint(auth_bp)
app.context_processor(lambda: {"csrf_token": csrf_token})
@app.before_request
def require_login_and_csrf():
if request.endpoint == "static":
return None
if not app.secret_key:
if request.endpoint == "auth.login_page":
return None
if request.path.startswith("/api/"):
return jsonify(error="服务未配置会话密钥"), 503
return render_template(
"login.html", unavailable=True, error="服务未配置会话密钥,请联系管理员。",
), 503
if request.endpoint in {"auth.login_page", "auth.login", "auth.initialize_account", "auth.logout"}:
return None
if not session.get("auth_user"):
if request.path.startswith("/api/"):
return jsonify(error="需要登录"), 401
return redirect(url_for("auth.login_page"))
if request.method in {"POST", "PUT", "PATCH", "DELETE"}:
if not valid_csrf(request.headers.get("X-CSRF-Token")):
if request.path.startswith("/api/"):
return jsonify(error="CSRF token 无效"), 400
return "页面已过期,请刷新后重试。", 400
return None