merge: 合并登录权限到 dev

This commit is contained in:
admin committed 2026-09-21 17:47:20 +08:00
commit 20651f275b
14 files changed
+462 -15

No files matched your search

+2
View File
@@ -37,6 +37,7 @@ except ImportError:
_ssl_context = ssl.create_default_context() _ssl_context = ssl.create_default_context()
from flask import Flask, g, jsonify, render_template, request from flask import Flask, g, jsonify, render_template, request
from auth import register_auth
from reading_api import reading_bp from reading_api import reading_bp
# ====================================================================== # ======================================================================
@@ -46,6 +47,7 @@ app = Flask(__name__, static_folder="static", template_folder="templates")
# 注册当前看板数据接口,并保留旧客户端兼容路径。 # 注册当前看板数据接口,并保留旧客户端兼容路径。
app.register_blueprint(reading_bp) app.register_blueprint(reading_bp)
register_auth(app)
# ====================================================================== # ======================================================================
# 数据库连接配置 # 数据库连接配置
+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
+2 -3
View File
@@ -73,10 +73,9 @@ import pymysql
from reading_api import MYSQL_CONFIG from reading_api import MYSQL_CONFIG
migration = Path("migrations/001_reading_goals.sql") with pymysql.connect(**MYSQL_CONFIG) as conn:
if migration.exists():
with pymysql.connect(**MYSQL_CONFIG) as conn:
with conn.cursor() as cur: with conn.cursor() as cur:
for migration in sorted(Path("migrations").glob("*.sql")):
for sql in migration.read_text(encoding="utf-8").split(";"): for sql in migration.read_text(encoding="utf-8").split(";"):
if sql.strip(): if sql.strip():
cur.execute(sql) cur.execute(sql)
+11 -1
View File
@@ -45,10 +45,20 @@
GitHub Actions 的 `.github/workflows/deploy.yml` 只监听 `master` 分支。分支 push 后,Actions 使用仓库 Secrets 中的服务器地址、用户和 SSH 私钥登录服务器,进入 `/opt/weread-board/WeReadBoard` 后调用 `deploy.sh`。手动触发也固定部署 `master`,部署脚本会拒绝其他分支。 GitHub Actions 的 `.github/workflows/deploy.yml` 只监听 `master` 分支。分支 push 后,Actions 使用仓库 Secrets 中的服务器地址、用户和 SSH 私钥登录服务器,进入 `/opt/weread-board/WeReadBoard` 后调用 `deploy.sh`。手动触发也固定部署 `master`,部署脚本会拒绝其他分支。
`deploy.sh` 固定拉取 `master`,收到其他部署分支参数时直接退出,再检查项目 `.venv`。如果 `.venv/bin/python` 不存在,脚本使用服务器上的 `python3` 或 `python` 创建虚拟环境;随后用 `.venv/bin/python -m pip install -r requirements.txt` 安装依赖,执行 `migrations/001_reading_goals.sql`,最后重启 `weread.service`。 `deploy.sh` 固定拉取 `master`,收到其他部署分支参数时直接退出,再检查项目 `.venv`。如果 `.venv/bin/python` 不存在,脚本使用服务器上的 `python3` 或 `python` 创建虚拟环境;随后用 `.venv/bin/python -m pip install -r requirements.txt` 安装依赖,按文件名顺序执行 `migrations/*.sql`,最后重启 `weread.service`。
例:用户将已验收代码合并并推送到 `master`,服务器原来没有 `.venv`。部署脚本会在 `/opt/weread-board/WeReadBoard/.venv` 创建环境,安装 Flask 和 PyMySQL,确认阅读目标表存在,再让 systemd 重新用 `.venv/bin/python app.py` 启动服务。仅推送 `dev` 不会触发部署。 例:用户将已验收代码合并并推送到 `master`,服务器原来没有 `.venv`。部署脚本会在 `/opt/weread-board/WeReadBoard/.venv` 创建环境,安装 Flask 和 PyMySQL,确认阅读目标表存在,再让 systemd 重新用 `.venv/bin/python app.py` 启动服务。仅推送 `dev` 不会触发部署。
## 正式看板登录认证
`auth.py` 注册全站请求拦截和登录路由。`/` 与所有 `/api/*` 数据接口都需要 `session["auth_user"]`;静态资源、`/login`、`/auth/login` 和首次初始化路由是唯一公开入口。页面请求未认证时重定向到登录页,API 返回 401,避免前端遮罩之外仍可直接读取数据。旧 `/api/v2/*` 路由同样由 Flask 全局拦截。
账号表 `weread_auth` 固定使用 `id=1` 保存单管理员的用户名和 Werkzeug 密码哈希。数据库无账号时,只有提交服务端 `WEREAD_AUTH_SETUP_TOKEN` 才能插入首个账号;例如提交用户名 `reader`、一次性口令正确、密码 `a-long-private-password`,数据库保存的是哈希而不是密码。插入成功后创建永久 Flask 会话,并清空当前进程中的初始化口令。后续登录从 MySQL 取哈希校验;用户名或密码错误统一返回“账号或密码不正确”。
会话签名密钥从 `WEREAD_SESSION_SECRET` 读取且必须稳定配置;缺失时服务对动态页面和 API 以 503 失败关闭。永久会话时长是固定 5 天,不因每次请求续期。Cookie 为 HttpOnly、SameSite=Lax,并默认 Secure;仅本机 HTTP 预览可以关闭 Secure。生产站点须使用 HTTPS。前端 JSON 写请求从页面 meta 标签读取 CSRF token 并放入 `X-CSRF-Token` 请求头,表单请求使用隐藏字段。
应用未内置登录尝试限流;公网部署应在可信反向代理上为 `POST /auth/login` 设置频率限制,且不要直接信任未校验的 `X-Forwarded-For`。
## 示例与真实接口的边界 ## 示例与真实接口的边界
概览图表使用固定样例;目标日期状态使用浏览器当前本地日期。笔记导出包含当前选中书目的划线和个人想法全集,不受内容类型筛选影响,不包含书签内容及公开点评。 概览图表使用固定样例;目标日期状态使用浏览器当前本地日期。笔记导出包含当前选中书目的划线和个人想法全集,不受内容类型筛选影响,不包含书签内容及公开点评。
+9 -2
View File
@@ -1,6 +1,6 @@
# master 自动部署 # master 自动部署
GitHub Actions workflow 位于 `.github/workflows/deploy.yml`。每次 `master` 分支 push 后,Actions 会通过 SSH 登录服务器,在部署目录执行根目录 `deploy.sh`,拉取最新 `master`、安装依赖、执行阅读目标表迁移并重启服务。手动触发时也固定部署 `master`,不能选择其他分支发布。 GitHub Actions workflow 位于 `.github/workflows/deploy.yml`。每次 `master` 分支 push 后,Actions 会通过 SSH 登录服务器,在部署目录执行根目录 `deploy.sh`,拉取最新 `master`、安装依赖、执行全部数据库迁移并重启服务。手动触发时也固定部署 `master`,不能选择其他分支发布。
## GitHub 配置 ## GitHub 配置
@@ -32,7 +32,7 @@ git checkout master
git pull --ff-only origin master git pull --ff-only origin master
python3 -m venv .venv # 仅 .venv 不存在时执行 python3 -m venv .venv # 仅 .venv 不存在时执行
.venv/bin/python -m pip install -r requirements.txt .venv/bin/python -m pip install -r requirements.txt
# 读取 reading_api.MYSQL_CONFIG 后执行 migrations/001_reading_goals.sql # 读取 reading_api.MYSQL_CONFIG 后按顺序执行 migrations/*.sql
sudo systemctl restart "$SERVICE_NAME" sudo systemctl restart "$SERVICE_NAME"
``` ```
@@ -56,6 +56,9 @@ Environment=WEREAD_DB_USER=weread
Environment=WEREAD_DB_PASSWORD=change-me Environment=WEREAD_DB_PASSWORD=change-me
Environment=WEREAD_DB_NAME=weread Environment=WEREAD_DB_NAME=weread
Environment=PORT=8770 Environment=PORT=8770
Environment=WEREAD_SESSION_SECRET=<服务器生成的随机密钥>
Environment=WEREAD_AUTH_SETUP_TOKEN=<仅首次初始化使用的随机口令>
Environment=WEREAD_SESSION_COOKIE_SECURE=1
ExecStart=/opt/weread-board/WeReadBoard/.venv/bin/python app.py ExecStart=/opt/weread-board/WeReadBoard/.venv/bin/python app.py
Restart=always Restart=always
RestartSec=3 RestartSec=3
@@ -65,3 +68,7 @@ WantedBy=multi-user.target
``` ```
如果服务名或部署目录不同,在仓库 Variables 中调整 `SERVICE_NAME` 和 `DEPLOY_PATH`。 如果服务名或部署目录不同,在仓库 Variables 中调整 `SERVICE_NAME` 和 `DEPLOY_PATH`。
登录认证还需配置 `WEREAD_SESSION_SECRET` 和首次使用的 `WEREAD_AUTH_SETUP_TOKEN`。在服务器分别运行两次 `python3 -c 'import secrets; print(secrets.token_urlsafe(48))'` 生成不同的值。迁移自动创建 `weread_auth` 表;首次访问 `/login` 时输入初始化口令、管理员账号和至少 12 位密码。初始化成功后,从 systemd 配置中删除 `WEREAD_AUTH_SETUP_TOKEN` 并重启服务。数据库中的密码只保存哈希。
`WEREAD_SESSION_COOKIE_SECURE=1` 要求浏览器通过 HTTPS 访问。仅本机 HTTP 验收可临时设为 `0`,不要在公网 HTTP 下传输登录密码。
+2 -1
View File
@@ -55,6 +55,7 @@
| F16 | 数据状态:加载、空、失败及重试;演示来源常驻 | 已验证(示例) | 待实现 | `web-demo/app.js:158` `renderDiscover` | | F16 | 数据状态:加载、空、失败及重试;演示来源常驻 | 已验证(示例) | 待实现 | `web-demo/app.js:158` `renderDiscover` |
| F17 | API Key 仅服务端使用;版本上报、升级中断、错误转换、分页 | 不属于本轮 Demo | 待实现 | 后续核对 `app.py` 和 `fetch_data.py`,复用现有服务端能力 | | F17 | API Key 仅服务端使用;版本上报、升级中断、错误转换、分页 | 不属于本轮 Demo | 待实现 | 后续核对 `app.py` 和 `fetch_data.py`,复用现有服务端能力 |
| F18 | 可选统计:阅读时段、作者/出版社/版权方、阅读听书比例、好友排行、勋章、年报 | 已验证(示例) | 待实现;按实际返回字段显示 | `web-demo/app.js:85` `renderOverview` | | F18 | 可选统计:阅读时段、作者/出版社/版权方、阅读听书比例、好友排行、勋章、年报 | 已验证(示例) | 待实现;按实际返回字段显示 | `web-demo/app.js:85` `renderOverview` |
| F19 | 单管理员登录保护所有页面/API;首次初始化需一次性口令;密码哈希存 MySQL;会话固定 5 天 | 不适用 | 已实现,真实 MySQL 初始化待部署验证 | `auth.py`、`migrations/002_site_auth.sql`、`templates/login.html` |
## 全部文档接口的落点 ## 全部文档接口的落点
@@ -122,6 +123,6 @@
| ID | 功能与验收标准 | Demo 状态 | 真实接入状态 | 代码位置 | | ID | 功能与验收标准 | Demo 状态 | 真实接入状态 | 代码位置 |
|---|---|---|---|---| |---|---|---|---|---|
| F19 | 待填写;涉及新规则时追加 R 编号 | 待实现 | 待实现 | 实现后填写文件、函数和行号 | | F20 | 待填写;涉及新规则时追加 R 编号 | 待实现 | 待实现 | 实现后填写文件、函数和行号 |
后续开发顺序:核对实时 `/_list` 与公开书单成员能力 → 服务端鉴权及只读接口 → 替换 Demo 数据 → 校验分页与字段口径 → 正式目标持久化。每次只将已实际验收的范围标为完成。 后续开发顺序:核对实时 `/_list` 与公开书单成员能力 → 服务端鉴权及只读接口 → 替换 Demo 数据 → 校验分页与字段口径 → 正式目标持久化。每次只将已实际验收的范围标为完成。
+26 -5
View File
@@ -68,7 +68,7 @@ node --check web-demo/demo-data.js
沿用 `reading_api.py` 的 `WEREAD_DB_HOST`、`WEREAD_DB_PORT`、`WEREAD_DB_USER`、`WEREAD_DB_PASSWORD` 和 `WEREAD_DB_NAME` 配置,不新增依赖。服务仍使用当前个人账号的数据;多个浏览器连接同一服务时共享目标,没有新增多用户隔离。 沿用 `reading_api.py` 的 `WEREAD_DB_HOST`、`WEREAD_DB_PORT`、`WEREAD_DB_USER`、`WEREAD_DB_PASSWORD` 和 `WEREAD_DB_NAME` 配置,不新增依赖。服务仍使用当前个人账号的数据;多个浏览器连接同一服务时共享目标,没有新增多用户隔离。
部署新代码前,在项目根目录执行一次迁移。下面的命令复用服务配置,只创建两张目标表,不修改已有业务表: 手动部署新代码时,在项目根目录按文件名顺序执行迁移。下面命令复用服务配置,幂等创建项目迁移定义的表:
```sh ```sh
python3 - <<'PY' python3 - <<'PY'
@@ -78,19 +78,40 @@ from reading_api import MYSQL_CONFIG
with pymysql.connect(**MYSQL_CONFIG) as conn: with pymysql.connect(**MYSQL_CONFIG) as conn:
with conn.cursor() as cur: with conn.cursor() as cur:
for sql in Path('migrations/001_reading_goals.sql').read_text().split(';'): for migration in sorted(Path('migrations').glob('*.sql')):
for sql in migration.read_text(encoding='utf-8').split(';'):
if sql.strip(): if sql.strip():
cur.execute(sql) cur.execute(sql)
print('阅读目标表已就绪') print('数据库迁移已完成')
PY PY
PORT=8080 python3 app.py PORT=8080 python3 app.py
``` ```
使用已安装 `requirements.txt` 的 Python 环境。若 8080 已有本项目实例,先停止旧实例再启动,使 Flask 加载新接口。迁移可重复执行;运行账户需要目标表的读写权限。目标表不参与 `fetch_data.py` 的采集覆盖。 使用已安装 `requirements.txt` 的 Python 环境。若 8080 已有本项目实例,先停止旧实例再启动,使 Flask 加载新接口。迁移可重复执行;运行账户需要目标表的读写权限。目标表不参与 `fetch_data.py` 的采集覆盖。
## 网站登录认证
正式页面和所有 `/api/*` 接口(包括 `/api/reading/*`、旧 `/api/v2/*` 与健康检查)都要求登录。未登录访问页面会跳转 `/login`;未登录直接请求 API 返回 401。CSS、JavaScript 等静态资源不包含个人数据,可公开加载。登录变更请求使用 CSRF token。
账号保存在 MySQL 的 `weread_auth` 表,密码以 Werkzeug 单向哈希存储。部署脚本会按文件名顺序执行 `migrations/*.sql`,其中 `002_site_auth.sql` 创建账号表。表中只允许一个管理员账号。
服务启动需要配置稳定的 `WEREAD_SESSION_SECRET`,用于签名会话;会话固定有效 5 天,Cookie 设置为 HttpOnly、SameSite=Lax,且 `WEREAD_SESSION_COOKIE_SECURE` 默认开启。生产环境必须通过 HTTPS 访问;仅本机 HTTP 预览时可设为 `WEREAD_SESSION_COOKIE_SECURE=0`。
首次部署还需在 systemd 环境中临时配置 `WEREAD_AUTH_SETUP_TOKEN`。可在服务器生成两个独立随机值:
```sh
python3 -c 'import secrets; print(secrets.token_urlsafe(48))'
```
将一个结果配置为 `WEREAD_SESSION_SECRET`,另一个配置为 `WEREAD_AUTH_SETUP_TOKEN`,重启服务后访问 `/login`。数据库尚无账号时,页面要求一次性口令、管理员用户名和至少 12 位密码;成功后只将密码哈希写入 MySQL,并关闭当前进程的初始化入口。然后从 systemd 配置中移除 `WEREAD_AUTH_SETUP_TOKEN` 并再次重启。若数据库已有账号,初始化表单不会开放。
不要把上述值写入 Git、浏览器代码或公开聊天。初始化口令不能省略:没有它,任何公网访问者都可能抢先创建管理员账号。当前应用不提供密码找回入口;需由管理员在服务器侧重置数据库中的哈希。
应用本身未实现登录尝试限流。公网部署还应在 Nginx 或其他可信入口对 `POST /auth/login` 配置频率限制,不能信任客户端自行传入的转发 IP。
## GitHub 自动部署 ## GitHub 自动部署
服务器部署使用根目录的 `deploy.sh`。脚本会先拉取指定分支,再检测 Python 环境;若项目 `.venv` 不存在,会自动用系统 `python3` 或 `python` 创建 `.venv`,随后安装 `requirements.txt`、执行阅读目标迁移,并重启 systemd 服务。 服务器部署使用根目录的 `deploy.sh`。脚本会先拉取指定分支,再检测 Python 环境;若项目 `.venv` 不存在,会自动用系统 `python3` 或 `python` 创建 `.venv`,随后安装 `requirements.txt`、按顺序执行 `migrations/*.sql`,并重启 systemd 服务。
当前服务器配置可按下列默认值使用: 当前服务器配置可按下列默认值使用:
@@ -150,6 +171,6 @@ node --check static/js/weapi.js
## master 自动部署 ## master 自动部署
`master` 分支 push 后由 `.github/workflows/deploy.yml` 触发部署。Actions 通过仓库 Secrets 登录服务器,在 `DEPLOY_PATH` 指定的目录先拉取 `origin/master`,再执行根目录 `deploy.sh`,完成加载可选 `.env`、安装 Python 依赖、执行阅读目标表迁移和重启 systemd 服务。`dev` 和其他分支不会触发部署,部署脚本也会拒绝非 `master` 分支。详细配置见 [master 自动部署](deploy-master.md)。 `master` 分支 push 后由 `.github/workflows/deploy.yml` 触发部署。Actions 通过仓库 Secrets 登录服务器,在 `DEPLOY_PATH` 指定的目录先拉取 `origin/master`,再执行根目录 `deploy.sh`,完成加载可选 `.env`、安装 Python 依赖、执行全部数据库迁移和重启 systemd 服务。`dev` 和其他分支不会触发部署,部署脚本也会拒绝非 `master` 分支。详细配置见 [master 自动部署](deploy-master.md)。
本次数据库验收已在 8080 正式看板完成:页面新建目标后刷新仍可读取,编辑后再次刷新仍可读取,并已清理临时验收目标。读取目标列表未完成时,新建和编辑入口只显示目标页的加载或错误状态,不会打开保存表单。 本次数据库验收已在 8080 正式看板完成:页面新建目标后刷新仍可读取,编辑后再次刷新仍可读取,并已清理临时验收目标。读取目标列表未完成时,新建和编辑入口只显示目标页的加载或错误状态,不会打开保存表单。
+8
View File
@@ -18,6 +18,14 @@
## 快速排查 ## 快速排查
### 登录与会话
- 登录页返回 503:检查 systemd 是否配置 `WEREAD_SESSION_SECRET`,并确认数据库迁移已创建 `weread_auth` 表。
- 首次设置提示初始化口令无效:确认 `WEREAD_AUTH_SETUP_TOKEN` 已在服务环境中配置;账号创建成功后应从服务配置移除该值。
- 输入正确密码后仍回到登录页:公网必须通过 HTTPS 访问,检查 Cookie 的 `Secure` 标记与 `WEREAD_SESSION_COOKIE_SECURE` 设置;本机 HTTP 预览才设为 `0`。
- 页面可打开但 API 返回 401:登录 Cookie 未随请求发送,先重新登录并确认页面与 API 使用同一主机名和协议。
- 登录后写操作返回 CSRF 错误:刷新页面获取新的 token 后重试,不要跨域代理前端与 API。
页面出现“请求失败”时,按下面的顺序检查。 页面出现“请求失败”时,按下面的顺序检查。
### 检查监听端口 ### 检查监听端口
+10
View File
@@ -0,0 +1,10 @@
-- 单管理员账号;密码仅保存 Werkzeug 生成的单向哈希。
CREATE TABLE IF NOT EXISTS weread_auth (
id TINYINT UNSIGNED NOT NULL,
username VARCHAR(64) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_weread_auth_username (username)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='阅间访问账号';
+1
View File
@@ -91,3 +91,4 @@
.goal-card .cover-stack{height:62px} .goal-card .cover-stack{height:62px}
.stack-more{margin-left:8px;white-space:nowrap} .stack-more{margin-left:8px;white-space:nowrap}
} }
.login-page{min-height:100vh;display:grid;place-items:center;padding:24px}.login-shell{width:min(100%,420px)}.login-logo{justify-content:center;margin-bottom:26px}.login-logo .logo-mark{border-radius:9px}.login-logo svg{width:24px;height:24px}.login-panel{padding:30px}.login-panel h1{font-size:23px;margin:0 0 8px}.login-panel>p{margin-bottom:23px}.login-form{display:grid;gap:16px;margin-top:22px}.login-form label{display:grid;gap:7px;font-size:12px;color:var(--text)}.login-form input{width:100%;min-height:42px;border:1px solid var(--line);border-radius:7px;background:var(--bg);color:var(--text);padding:10px 12px}.login-form input:focus{border-color:var(--accent)}.login-form .primary{width:100%;min-height:42px;margin-top:4px}.login-error{margin-top:16px;padding:10px 12px;border:1px solid #e6b9b5;border-radius:7px;background:#fff5f4;color:#a8443c;font-size:12px;line-height:1.6}.login-foot{text-align:center;margin-top:20px}.top-actions form{margin:0}.logout-button{white-space:nowrap}@media(max-width:600px){.login-page{padding:18px}.login-panel{padding:24px}.top-actions{gap:9px}.logout-button{font-size:10px}}
+5 -1
View File
@@ -21,7 +21,11 @@
}; };
const send = base => fetch(makeUrl(base).toString(), { const send = base => fetch(makeUrl(base).toString(), {
method, method,
headers: { Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}) }, headers: {
Accept: 'application/json',
...(body ? { 'Content-Type': 'application/json' } : {}),
...(['POST', 'PUT', 'PATCH', 'DELETE'].includes(method) ? { 'X-CSRF-Token': global.document.querySelector('meta[name="csrf-token"]')?.content || '' } : {}),
},
...(body ? { body: JSON.stringify(body) } : {}), ...(body ? { body: JSON.stringify(body) } : {}),
}); });
let response = await send(BASE); let response = await send(BASE);
+2 -2
View File
@@ -1,7 +1,7 @@
<!doctype html> <!doctype html>
<html lang="zh-CN" data-theme="light"> <html lang="zh-CN" data-theme="light">
<head> <head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"> <meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="csrf-token" content="{{ csrf_token() }}">
<title>阅间 · 微信读书看板</title> <title>阅间 · 微信读书看板</title>
<link rel="stylesheet" href="/static/css/styles.css"> <link rel="stylesheet" href="/static/css/styles.css">
</head> </head>
@@ -10,7 +10,7 @@
<symbol id="book" viewBox="0 0 24 24"><path d="M3 4c4-1 6 0 9 2 3-2 5-3 9-2v15c-4-1-6 0-9 2-3-2-5-3-9-2zM12 6v15"/></symbol><symbol id="grid" viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/></symbol><symbol id="note" viewBox="0 0 24 24"><path d="M13 4H5v16h14v-8M11 13l1-4 7-7 3 3-7 7z"/></symbol><symbol id="search-icon" viewBox="0 0 24 24"><circle cx="10" cy="10" r="6.5"/><path d="m15 15 6 6"/></symbol><symbol id="chart" viewBox="0 0 24 24"><path d="M4 3v17h17M8 15v-4m5 4V7m5 8V4"/></symbol><symbol id="clock" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 6v6l4 2"/></symbol><symbol id="moon" viewBox="0 0 24 24"><path d="M20 14A9 9 0 0 1 10 3a9 9 0 1 0 10 11Z"/></symbol><symbol id="sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2v2m0 16v2M2 12h2m16 0h2M5 5l1 1m12 12 1 1M5 19l1-1M18 6l1-1"/></symbol><symbol id="sync" viewBox="0 0 24 24"><path d="M20 7v5h-5M4 17v-5h5M6 6a8 8 0 0 1 14 6M4 12a8 8 0 0 0 14 6"/></symbol><symbol id="flag" viewBox="0 0 24 24"><path d="M5 21V3c5-4 9 4 14 0v11c-5 4-9-4-14 0"/></symbol> <symbol id="book" viewBox="0 0 24 24"><path d="M3 4c4-1 6 0 9 2 3-2 5-3 9-2v15c-4-1-6 0-9 2-3-2-5-3-9-2zM12 6v15"/></symbol><symbol id="grid" viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/></symbol><symbol id="note" viewBox="0 0 24 24"><path d="M13 4H5v16h14v-8M11 13l1-4 7-7 3 3-7 7z"/></symbol><symbol id="search-icon" viewBox="0 0 24 24"><circle cx="10" cy="10" r="6.5"/><path d="m15 15 6 6"/></symbol><symbol id="chart" viewBox="0 0 24 24"><path d="M4 3v17h17M8 15v-4m5 4V7m5 8V4"/></symbol><symbol id="clock" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 6v6l4 2"/></symbol><symbol id="moon" viewBox="0 0 24 24"><path d="M20 14A9 9 0 0 1 10 3a9 9 0 1 0 10 11Z"/></symbol><symbol id="sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2v2m0 16v2M2 12h2m16 0h2M5 5l1 1m12 12 1 1M5 19l1-1M18 6l1-1"/></symbol><symbol id="sync" viewBox="0 0 24 24"><path d="M20 7v5h-5M4 17v-5h5M6 6a8 8 0 0 1 14 6M4 12a8 8 0 0 0 14 6"/></symbol><symbol id="flag" viewBox="0 0 24 24"><path d="M5 21V3c5-4 9 4 14 0v11c-5 4-9-4-14 0"/></symbol>
</defs></svg> </defs></svg>
<aside class="sidebar"><div class="logo"><div class="logo-mark"><svg><use href="#book"/></svg></div><div>阅间<small>READING SPACE</small></div></div><div class="navlabel">我的阅读空间</div><nav class="nav" aria-label="主导航"><button class="active" data-page="overview" title="阅读概览"><svg><use href="#grid"/></svg><span>阅读概览</span></button><button data-page="shelf" title="我的书架"><svg><use href="#book"/></svg><span>我的书架</span><em>8</em></button><button data-page="goals" title="阅读目标"><svg><use href="#flag"/></svg><span>阅读目标</span></button><button data-page="notes" title="笔记与划线"><svg><use href="#note"/></svg><span>笔记与划线</span></button><button data-page="discover" title="发现好书"><svg><use href="#search-icon"/></svg><span>发现好书</span></button></nav><div class="sidebar-bottom"><div class="connection"><span class="dot"></span>微信读书 · 个人空间<small>让每一页,都留下痕迹</small></div><div class="profile"><div class="avatar">读</div><div>我的阅读空间<small>数据来自你的微信读书账号</small></div></div></div></aside> <aside class="sidebar"><div class="logo"><div class="logo-mark"><svg><use href="#book"/></svg></div><div>阅间<small>READING SPACE</small></div></div><div class="navlabel">我的阅读空间</div><nav class="nav" aria-label="主导航"><button class="active" data-page="overview" title="阅读概览"><svg><use href="#grid"/></svg><span>阅读概览</span></button><button data-page="shelf" title="我的书架"><svg><use href="#book"/></svg><span>我的书架</span><em>8</em></button><button data-page="goals" title="阅读目标"><svg><use href="#flag"/></svg><span>阅读目标</span></button><button data-page="notes" title="笔记与划线"><svg><use href="#note"/></svg><span>笔记与划线</span></button><button data-page="discover" title="发现好书"><svg><use href="#search-icon"/></svg><span>发现好书</span></button></nav><div class="sidebar-bottom"><div class="connection"><span class="dot"></span>微信读书 · 个人空间<small>让每一页,都留下痕迹</small></div><div class="profile"><div class="avatar">读</div><div>我的阅读空间<small>数据来自你的微信读书账号</small></div></div></div></aside>
<main class="main"><header class="topbar"><div class="breadcrumb">我的空间 <span>/ &nbsp; <b id="crumb" style="font-weight:400">阅读概览</b></span></div><div class="top-actions"><label class="searchbox"><svg><use href="#search-icon"/></svg><input id="global-search" placeholder="搜索书籍、书单…" aria-label="全局搜索"><span class="key">⌘ K</span></label><button class="iconbtn" id="theme" aria-label="切换至暗色主题"><svg><use href="#moon"/></svg></button><div class="avatar">L</div></div></header><div class="demo-banner"><span><span class="dot"></span><span id="conn-state">正在连接微信读书…</span></span><span>阅读目标跨浏览器保存</span></div><div id="page-content"></div><footer class="footer"><span>阅间 · 与好书相伴,与自己相遇</span><span id="footer-state">数据来自微信读书个人账号</span></footer></main> <main class="main"><header class="topbar"><div class="breadcrumb">我的空间 <span>/ &nbsp; <b id="crumb" style="font-weight:400">阅读概览</b></span></div><div class="top-actions"><label class="searchbox"><svg><use href="#search-icon"/></svg><input id="global-search" placeholder="搜索书籍、书单…" aria-label="全局搜索"><span class="key">⌘ K</span></label><button class="iconbtn" id="theme" aria-label="切换至暗色主题"><svg><use href="#moon"/></svg></button><div class="avatar">L</div><form method="post" action="{{ url_for('auth.logout') }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="text-button logout-button" type="submit">退出</button></form></div></header><div class="demo-banner"><span><span class="dot"></span><span id="conn-state">正在连接微信读书…</span></span><span>阅读目标跨浏览器保存</span></div><div id="page-content"></div><footer class="footer"><span>阅间 · 与好书相伴,与自己相遇</span><span id="footer-state">数据来自微信读书个人账号</span></footer></main>
<dialog id="detail" aria-label="内容详情"><div class="dialog-top"><h2 id="detail-title">书籍详情</h2><button class="iconbtn" data-action="close-detail" aria-label="关闭详情">✕</button></div><div id="detail-content"></div></dialog> <dialog id="detail" aria-label="内容详情"><div class="dialog-top"><h2 id="detail-title">书籍详情</h2><button class="iconbtn" data-action="close-detail" aria-label="关闭详情">✕</button></div><div id="detail-content"></div></dialog>
<dialog id="goal-editor" aria-label="编辑阅读目标"><form id="goal-form"><div class="dialog-top"><div><div class="eyebrow">MAKE ROOM FOR READING</div><h2 id="editor-title">新建阅读目标</h2></div><button type="button" class="iconbtn" data-action="close-editor" aria-label="关闭目标编辑">✕</button></div><label class="field">目标名称<input id="goal-name" maxlength="60" placeholder="例如:秋季文学计划" required></label><div class="date-fields"><label class="field">开始日期<input id="goal-start" type="date" required></label><label class="field">结束日期<input id="goal-end" type="date" required></label></div><div class="section-head picker-head"><h3>想读些什么?</h3><span id="selection-count" class="pill"></span></div><p class="hint">选择书籍或加入书单当前书目,重复书籍只算一本,已读完直接计入完成。</p><label class="field"><input id="picker-search" placeholder="筛选书名、作者或书单…" aria-label="筛选目标书目"></label><div id="goal-picker"></div><p id="goal-error" class="form-error" role="alert"></p><div class="editor-footer"><span>保存后可在其他浏览器查看</span><button class="primary" type="submit">保存阅读目标</button></div></form></dialog> <dialog id="goal-editor" aria-label="编辑阅读目标"><form id="goal-form"><div class="dialog-top"><div><div class="eyebrow">MAKE ROOM FOR READING</div><h2 id="editor-title">新建阅读目标</h2></div><button type="button" class="iconbtn" data-action="close-editor" aria-label="关闭目标编辑">✕</button></div><label class="field">目标名称<input id="goal-name" maxlength="60" placeholder="例如:秋季文学计划" required></label><div class="date-fields"><label class="field">开始日期<input id="goal-start" type="date" required></label><label class="field">结束日期<input id="goal-end" type="date" required></label></div><div class="section-head picker-head"><h3>想读些什么?</h3><span id="selection-count" class="pill"></span></div><p class="hint">选择书籍或加入书单当前书目,重复书籍只算一本,已读完直接计入完成。</p><label class="field"><input id="picker-search" placeholder="筛选书名、作者或书单…" aria-label="筛选目标书目"></label><div id="goal-picker"></div><p id="goal-error" class="form-error" role="alert"></p><div class="editor-footer"><span>保存后可在其他浏览器查看</span><button class="primary" type="submit">保存阅读目标</button></div></form></dialog>
<div id="toast" class="hidden" role="status"></div><script src="/static/js/weapi.js"></script><script src="/static/js/app.js"></script></body></html> <div id="toast" class="hidden" role="status"></div><script src="/static/js/weapi.js"></script><script src="/static/js/app.js"></script></body></html>
+39
View File
@@ -0,0 +1,39 @@
<!doctype html>
<html lang="zh-CN" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>登录 · 阅间</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
</head>
<body class="login-page">
<main class="login-shell">
<div class="logo login-logo"><div class="logo-mark"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 4c4-1 6 0 8 2 2-2 4-3 8-2v15c-4-1-6 0-8 2-2-2-4-3-8-2zM12 6v15" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg></div><div>阅间<small>READING SPACE</small></div></div>
<section class="login-panel card">
<div class="eyebrow">YOUR READING SPACE</div>
<h1>{{ '服务暂不可用' if unavailable else ('首次设置' if not initialized else '欢迎回来') }}</h1>
<p>{{ '请检查服务配置或数据库连接。' if unavailable else ('创建管理员账号以启用访问保护。' if not initialized else '登录后继续你的阅读旅程。') }}</p>
{% if error %}<div class="login-error" role="alert">{{ error }}</div>{% endif %}
{% if not unavailable and not initialized %}
{% if not setup_enabled %}<div class="login-error" role="alert">尚未配置一次性初始化口令,请联系管理员。</div>{% endif %}
<form method="post" action="{{ url_for('auth.initialize_account') }}" class="login-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<label>一次性初始化口令<input name="setup_token" type="password" autocomplete="off" required autofocus></label>
<label>管理员账号<input name="username" maxlength="64" autocomplete="username" required></label>
<label>设置密码<input name="password" type="password" minlength="12" maxlength="256" autocomplete="new-password" required></label>
<button class="primary" type="submit" {% if not setup_enabled %}disabled{% endif %}>创建账号并进入</button>
</form>
{% elif not unavailable %}
<form method="post" action="{{ url_for('auth.login') }}" class="login-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<label>账号<input name="username" maxlength="64" autocomplete="username" required autofocus></label>
<label>密码<input name="password" type="password" autocomplete="current-password" required></label>
<button class="primary" type="submit">登录</button>
</form>
{% endif %}
</section>
<p class="login-foot">阅间 · 与好书相伴,与自己相遇</p>
</main>
</body>
</html>
+159
View File
@@ -0,0 +1,159 @@
import re
import importlib
import os
import unittest
from datetime import timedelta
from unittest.mock import patch
from flask import Flask, jsonify
import auth
class AuthTests(unittest.TestCase):
def setUp(self):
self.app = Flask(__name__, template_folder="../templates")
self.app.config.update(
SECRET_KEY="test-secret",
AUTH_SETUP_TOKEN="one-time-token",
SESSION_COOKIE_SECURE=False,
)
self.accounts = {}
@self.app.get("/private")
def private_page():
return "private"
@self.app.get("/api/private")
def private_api():
return jsonify(ok=True)
self.create_patcher = patch.object(
auth, "create_account", side_effect=self.create_account
)
self.account_patcher = patch.object(
auth, "get_account", side_effect=lambda: self.accounts.get("account")
)
self.create_patcher.start()
self.account_patcher.start()
self.addCleanup(self.create_patcher.stop)
self.addCleanup(self.account_patcher.stop)
auth.register_auth(self.app)
self.app.config.update(
SECRET_KEY="test-secret",
AUTH_SETUP_TOKEN="one-time-token",
SESSION_COOKIE_SECURE=False,
)
self.client = self.app.test_client()
def create_account(self, username, password_hash):
if "account" in self.accounts:
return False
self.accounts["account"] = {
"username": username,
"password_hash": password_hash,
}
return True
@staticmethod
def csrf(response):
match = re.search(rb'name="csrf_token" value="([^"]+)"', response.data)
if not match:
raise AssertionError("login page did not contain a CSRF token")
return match.group(1).decode()
def test_anonymous_page_and_api_are_blocked(self):
page = self.client.get("/private")
api = self.client.get("/api/private")
self.assertEqual(page.status_code, 302)
self.assertEqual(page.location, "/login")
self.assertEqual(api.status_code, 401)
self.assertEqual(api.json["error"], "需要登录")
def test_current_and_legacy_reading_apis_are_blocked_by_real_app_guard(self):
with patch.dict(os.environ, {"WEREAD_SESSION_SECRET": "test-secret"}):
web_app = importlib.import_module("app").app
web_app.config.update(SECRET_KEY="test-secret", SESSION_COOKIE_SECURE=False)
with patch("app.pymysql.connect", side_effect=RuntimeError("test avoids database access")):
client = web_app.test_client()
self.assertEqual(client.get("/").status_code, 302)
self.assertEqual(client.get("/api/reading/status").status_code, 401)
self.assertEqual(client.get("/api/v2/status").status_code, 401)
def test_first_account_requires_setup_token_and_is_stored_as_hash(self):
page = self.client.get("/login")
csrf = self.csrf(page)
denied = self.client.post("/auth/initialize", data={
"csrf_token": csrf,
"setup_token": "wrong-token",
"username": "reader",
"password": "long-enough-password",
})
self.assertEqual(denied.status_code, 403)
self.assertNotIn("account", self.accounts)
created = self.client.post("/auth/initialize", data={
"csrf_token": csrf,
"setup_token": "one-time-token",
"username": "reader",
"password": "long-enough-password",
})
self.assertEqual(created.status_code, 302)
saved = self.accounts["account"]
self.assertEqual(saved["username"], "reader")
self.assertNotEqual(saved["password_hash"], "long-enough-password")
self.assertTrue(auth.check_password_hash(saved["password_hash"], "long-enough-password"))
with self.client.session_transaction() as session:
self.assertTrue(session.permanent)
self.assertEqual(session["auth_user"], "reader")
self.assertEqual(self.app.permanent_session_lifetime, timedelta(days=5))
def test_login_rejects_wrong_password_and_accepts_matching_hash(self):
saved_hash = auth.generate_password_hash("long-enough-password")
self.accounts["account"] = {"username": "阅读者", "password_hash": saved_hash}
page = self.client.get("/login")
csrf = self.csrf(page)
denied = self.client.post("/auth/login", data={
"csrf_token": csrf,
"username": "阅读者",
"password": "wrong-password",
})
self.assertEqual(denied.status_code, 401)
accepted = self.client.post("/auth/login", data={
"csrf_token": csrf,
"username": "阅读者",
"password": "long-enough-password",
})
self.assertEqual(accepted.status_code, 302)
self.assertEqual(self.client.get("/private").data, b"private")
def test_missing_session_secret_fails_closed(self):
self.app.config["SECRET_KEY"] = None
response = self.client.get("/login")
self.assertEqual(response.status_code, 503)
self.assertIn("服务未配置会话密钥".encode(), response.data)
def test_mutating_api_requires_csrf_token(self):
@self.app.post("/api/write")
def write_api():
return jsonify(ok=True)
self.client.get("/login")
self.accounts["account"] = {
"username": "reader",
"password_hash": auth.generate_password_hash("long-enough-password"),
}
# Simulate a session established through the regular authenticated login flow.
with self.client.session_transaction() as session:
session.permanent = True
session["auth_user"] = "reader"
session["csrf_token"] = "test-csrf"
self.assertEqual(self.client.post("/api/write").status_code, 400)
self.assertEqual(self.client.post("/api/write", headers={"X-CSRF-Token": "test-csrf"}).status_code, 200)
if __name__ == "__main__":
unittest.main()