mirror of
https://github.com/KingHWJ/WeReadBoard.git
synced 2026-11-03 18:24:58 +08:00
187 lines
6.8 KiB
Python
187 lines
6.8 KiB
Python
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
|