mirror of
https://github.com/KingHWJ/WeReadBoard.git
synced 2026-11-03 18:24:58 +08:00
feat(auth): 增加登录验证和五天会话
This commit is contained in:
1 parent
ecd16bcefc
commit
810b52a051
14 files changed
+467
-20
No files matched your search
@@ -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
|
||||
Reference in new issue
Block a user