This repository has been archived on 2026-10-02. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
NeteaseUrl/web/routers/auth.py
T
snowy-yang 1973d6a0e8 fix: 启用会话过期(服务端校验签发时间,堵住旧 Cookie 永不过期)
为什么:签发侧 Cookie 的 max_age 是 7 天,但那只是**浏览器**行为——被保存、
复制或从日志里捞出来的旧 Cookie 仍带着有效签名,而解析侧 loads 从不校验签发
时间(max_age=None),等于「登录态永不过期」。登出/顶号靠递增 login_seq 兜底,
但拿不到库的一方(如泄露的 Cookie)无从失效。

改法:
- core/session.py 新增 COOKIE_NAME / SESSION_MAX_AGE,作为会话名与有效期的
  唯一来源。此前 'session' 在 web/deps、web/routers/auth、web/routers/register
  各写一遍,7 天在两处各写一遍——三处同名常量各改各的,正是漂移温床。
- web/deps._parse_cookie 传入 max_age=SESSION_MAX_AGE,过期判定落在服务端。
- 两个签发侧改引用共享常量(保留模块级别名,对外名字不变)。
- loads() 的签名与语义**不变**(max_age=None 仍不校验):它是通用工具,策略
  属于调用点,这样 test_session 里「None 接受旧时间戳」的语义继续成立。

行为变更(本轮唯一一处):签发时间超过 7 天的会话 Cookie 现在按未登录处理。
正常浏览器用户无感——浏览器本来就按 max_age 丢弃它;受影响的是手工保存、
跨 7 天复用的旧 Cookie,正是要堵的那类。

验证:
- 新增 tests/test_deps.py::test_expired_cookie_rejected:用 monkeypatch.context()
  把时钟拨回 SESSION_MAX_AGE 之前签发(itsdangerous 时间戳取自 dumps 时刻),
  再以当前时间解析 → 必须拒绝;同时断言有效期内不受影响。用 context 而非
  undo(),后者会连带撤销 site fixture 的设置。
- 变异验证:把 max_age 参数摘掉后**恰好**该用例变红、其余 10 例全绿——证明测试
  精确盯着这一处,不是碰巧通过。
- ruff 全过 · pyright 0/0 · pytest 214 passed(213→214)。golden 快照与
  test_download_billing(真实登录拿 Cookie 打 API)均通过,端到端链路未受影响。
2026-09-27 07:32:05 +08:00

300 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""认证操作端点。
端点:
- POST /api/v1/auth/logout ↔ Flask /auth/logout(顶号:递增 login_seq + 删 Cookie)
- POST /api/v1/auth/heartbeat ↔ Flask /auth/heartbeat
- POST /api/v1/auth/ann_seen ↔ Flask /auth/ann_seen
- POST /api/v1/auth/login ↔ Flask /auth/login(密码校验 + 失败锁定 + 管理员 2FA 转票据)
设计:
- 登录失败锁定与管理员 2FA 票据表**复用 web.auth_state 的模块级状态**,全进程共享同一份
计数/票据,避免「换个框架打就绕过锁定」。
- 密码校验(PBKDF2,10 万次迭代,CPU 密集)与库操作经 asyncio.to_thread 执行,
不阻塞事件循环。
- 会话 Cookie 由 core.session 签发(沿用同一格式与密钥,存量会话继续有效),删 Cookie 亦同口径。
"""
import asyncio
import os
import time
from typing import Annotated
from fastapi import APIRouter, Depends, Request
from fastapi.responses import JSONResponse
import auth_system as auth
import web.deps as deps
from core import db, repo
from core import session as cs
from web import ratelimit
from web.deps import SiteUser, current_user
from web.request_utils import (
request_data as _request_data,
)
from web.response import error, success
from web.serializers import format_credits as _format_credits
from web.serializers import user_public as _user_public
router = APIRouter()
SESSION_COOKIE = cs.COOKIE_NAME
_SESSION_MAX_AGE = cs.SESSION_MAX_AGE
def _cookie_kwargs() -> dict:
secure = os.environ.get('ENABLE_SECURE_COOKIE', '').strip().lower() in ('1', 'true', 'yes')
return {'max_age': _SESSION_MAX_AGE, 'httponly': True, 'samesite': 'lax',
'path': '/', 'secure': secure}
def _login_response(payload: dict, message: str, uid: int, seq: int) -> JSONResponse:
"""构造带会话 Cookie 的成功响应(沿用同一 Cookie 格式)。"""
resp = JSONResponse(content={'status': 200, 'success': True, 'message': message, 'data': payload})
cookie = cs.dumps(deps.secret_key(), {
'site_uid': uid, 'site_uid_seq': seq, 'site_uid_ts': int(time.time()),
})
resp.set_cookie(SESSION_COOKIE, cookie, **_cookie_kwargs())
return resp
@router.post('/auth/logout')
async def logout(user: Annotated[SiteUser | None, Depends(current_user)]):
"""退出登录:递增 login_seq 使本账号全部已签发会话失效,并删除本机 Cookie。"""
if user is not None:
try:
await db.execute(
'UPDATE users SET login_seq = COALESCE(login_seq, 0) + 1 WHERE id = ?',
(user.id,))
except Exception:
pass
resp = JSONResponse(content={'status': 200, 'success': True, 'message': '已退出登录'})
resp.delete_cookie(SESSION_COOKIE)
return resp
@router.post('/auth/heartbeat')
async def heartbeat(user: Annotated[SiteUser | None, Depends(current_user)]):
"""会话保活:返回最新次数;超限/失效给 401(expired 语义不变)。"""
if user is None:
return error('登录已失效', 401, error_code='unauthorized')
row = await repo.get_user_by_id(user.id)
if row is None:
return error('登录已失效', 401, error_code='unauthorized')
credits = int(row.get('duration_seconds') or 0)
if row.get('role') != 'admin' and credits != -1 and credits <= 0:
return error('剩余次数已用完', 401, error_code='expired')
return success({'duration_seconds': row.get('duration_seconds'),
'duration_text': _format_credits(credits)})
@router.post('/auth/ann_seen')
async def ann_seen(user: Annotated[SiteUser | None, Depends(current_user)]):
"""标记公告已读(记录当前公告版本)。"""
if user is not None:
ann = await repo.get_announcement()
await db.execute('UPDATE users SET ann_seen = ? WHERE id = ?',
(ann.get('updated_at', ''), user.id))
return success()
@router.post('/auth/login')
async def login(request: Request):
"""登录:密码校验(含邮箱登录)、失败锁定、管理员两步验证转票据。"""
from web.auth_state import (
_admin2fa_issue,
_admin2fa_mask,
_login_fail_check,
_login_fail_clear,
_login_fail_locked_log_once,
_login_fail_record,
)
data = await _request_data(request)
username = str(data.get('username') or '').strip()
password = str(data.get('password') or '')
ip = ratelimit.client_ip(request)
if len(password) > 128:
# 超长密码不做 PBKDF2(CPU 放大器),直接按密码错误拒绝
return error('用户名或密码错误')
try:
# 邮箱登录:含 @ 按归一化邮箱解析真实账号;锁定 key 用真实用户名
if '@' in username:
iuser = await asyncio.to_thread(auth.get_user_by_email, username)
lock_name = iuser['username'] if iuser else (auth._normalize_email(username) or username)
else:
iuser = None
lock_name = username
if not _login_fail_check(lock_name):
if _login_fail_locked_log_once(lock_name):
await asyncio.to_thread(auth.add_log, 'fail', lock_name, ip,
'登录', '失败次数过多,临时锁定')
return error('尝试过于频繁,该账号已临时锁定,请 10 分钟后再试')
verify_name = iuser['username'] if iuser is not None else username
user = await asyncio.to_thread(auth.verify_login, verify_name, password)
if not user:
_login_fail_record(lock_name)
return error('用户名或密码错误')
keys = user.keys()
status = user['status'] if 'status' in keys else 1
if status == 2:
reason = user['ban_reason'] if 'ban_reason' in keys else ''
await asyncio.to_thread(auth.add_log, 'fail', lock_name, ip,
'登录', f'封禁账号尝试登录: {reason}')
return error('账号已被封禁' + (f':{reason}' if reason else '') + ',请联系管理员')
if status == 0:
return error('账号已被禁用,请联系管理员')
_login_fail_clear(lock_name)
if user['role'] == 'admin':
# 管理员一律转两步验证通道(密码正确才签发票据;未绑定邮箱 fail-closed)
admin_email = user['email'] if 'email' in keys else ''
if not admin_email:
await asyncio.to_thread(auth.add_log, 'fail', lock_name, ip,
'管理员登录', '未绑定邮箱,两步验证不可用')
return error('管理员账号未绑定邮箱,请联系管理员先完成绑定')
ticket = _admin2fa_issue(user)
await asyncio.to_thread(auth.add_log, 'success', lock_name, ip,
'管理员登录', '密码验证通过,待邮箱验证')
return success({'ticket': ticket, 'email_masked': _admin2fa_mask(admin_email),
'username': user['username'], 'admin_2fa': True},
'密码验证通过,请完成邮箱验证')
# 普通用户:建立会话(原子递增 login_seq 顶号)
uid = int(user['id'])
now = int(time.time())
await db.execute(
'UPDATE users SET last_active = ?, last_login = ?, '
'login_seq = COALESCE(login_seq, 0) + 1 WHERE id = ?', (now, now, uid))
seq_row = await repo.get_user_by_id(uid)
seq = int((seq_row or {}).get('login_seq') or 1)
banned, _info = await asyncio.to_thread(auth.record_login_location, user, ip)
if banned:
await db.execute(
'UPDATE users SET login_seq = COALESCE(login_seq, 0) + 1 WHERE id = ?', (uid,))
return error('检测到账号在多个省份异常登录,已自动封禁,请联系管理员')
fresh = await repo.get_user_by_id(uid) or {}
return _login_response({
'user': _user_public(fresh),
'duration_text': _format_credits(int(fresh.get('duration_seconds') or 0)),
'redirect': 'admin' if fresh.get('role') == 'admin' else 'user',
}, '登录成功', uid, seq)
except Exception:
return error('登录失败,请稍后重试', 500)
# ============ 管理员两步验证 Step2 / Step3 ============
@router.post('/auth/admin_login/email')
async def admin_login_email(request: Request):
"""管理员 2FA Step2:比对绑定邮箱 → 发送 admin2fa 验证码。"""
import hmac as _hmac
from web.auth_state import (
_ADMIN2FA_LAST_SEND,
_ADMIN2FA_SEND_GAP,
_ADMIN_2FA,
_ADMIN_2FA_MAX_ATTEMPTS,
_admin2fa_mask,
_admin2fa_take,
)
data = await _request_data(request)
ip = ratelimit.client_ip(request)
ticket, v = _admin2fa_take(str(data.get('ticket') or ''))
if not v:
return error('登录会话已过期,请重新开始登录', 401, error_code='admin_2fa_expired')
email_input = auth._normalize_email(str(data.get('email') or ''))
# 第二因子比对走常时比较
if (not email_input or not v.get('email')
or not _hmac.compare_digest(email_input, v['email'])):
v['attempts'] += 1
await asyncio.to_thread(auth.add_log, 'fail', v['username'], ip, '管理员登录', '绑定邮箱不匹配')
if v['attempts'] >= _ADMIN_2FA_MAX_ATTEMPTS:
_ADMIN_2FA.pop(ticket, None)
return error('错误次数过多,请重新开始登录')
return error(f'绑定邮箱不正确,还可尝试 {_ADMIN_2FA_MAX_ATTEMPTS - v["attempts"]} 次')
last_send = _ADMIN2FA_LAST_SEND.get(v['username'], 0)
if time.time() - last_send < _ADMIN2FA_SEND_GAP:
return error('验证码发送过于频繁,请 1 分钟后再试')
try:
await asyncio.to_thread(auth.send_verify_code, v['email'], 'admin2fa')
_ADMIN2FA_LAST_SEND[v['username']] = time.time()
await asyncio.to_thread(auth.add_log, 'success', v['username'], ip, '管理员登录',
f'绑定邮箱验证通过,验证码已发送({_admin2fa_mask(v["email"])})')
return success(message='验证码已发送至管理员邮箱,请查收')
except (ValueError, RuntimeError) as e:
return error(str(e))
except Exception:
return error('验证码发送失败,请稍后重试', 500)
@router.post('/auth/admin_login/verify')
async def admin_login_verify(request: Request):
"""管理员 2FA Step3:验证码原子消费 → 建立管理员会话。"""
from web.auth_state import (
_ADMIN_2FA,
_ADMIN_2FA_MAX_ATTEMPTS,
_admin2fa_take,
)
data = await _request_data(request)
ip = ratelimit.client_ip(request)
ticket, v = _admin2fa_take(str(data.get('ticket') or ''))
if not v:
return error('登录会话已过期,请重新开始登录', 401, error_code='admin_2fa_expired')
code = str(data.get('code') or '').strip()
if not await asyncio.to_thread(auth.check_verify_code, v['email'], code, 'admin2fa'):
v['attempts'] += 1
if v['attempts'] >= _ADMIN_2FA_MAX_ATTEMPTS:
_ADMIN_2FA.pop(ticket, None)
return error('错误次数过多,请重新开始登录')
await asyncio.to_thread(auth.add_log, 'fail', v['username'], ip, '管理员登录', '验证码错误')
return error('验证码错误或已过期')
_ADMIN_2FA.pop(ticket, None) # 一次性
user = await asyncio.to_thread(auth.get_user_by_id, v['user_id'])
if not user or user['role'] != 'admin':
return error('登录会话已过期,请重新开始登录', 401, error_code='admin_2fa_expired')
keys = user.keys()
status = user['status'] if 'status' in keys else 1
if status == 2:
await asyncio.to_thread(auth.add_log, 'fail', v['username'], ip, '管理员登录', '封禁账号尝试登录')
return error('账号已被封禁,请联系管理员')
if status == 0:
return error('账号已被禁用,请联系管理员')
uid = int(user['id'])
now = int(time.time())
await db.execute('UPDATE users SET last_active=?, last_login=?, '
'login_seq=COALESCE(login_seq,0)+1 WHERE id=?', (now, now, uid))
seq_row = await repo.get_user_by_id(uid)
seq = int((seq_row or {}).get('login_seq') or 1)
banned, _info = await asyncio.to_thread(auth.record_login_location, user, ip)
if banned:
await db.execute('UPDATE users SET login_seq = COALESCE(login_seq, 0) + 1 WHERE id = ?', (uid,))
return error('检测到账号在多个省份异常登录,已自动封禁,请联系管理员')
fresh = await repo.get_user_by_id(uid) or {}
await asyncio.to_thread(auth.add_log, 'success', v['username'], ip, '管理员登录', '两步验证通过')
return _login_response({
'user': _user_public(fresh),
'duration_text': _format_credits(int(fresh.get('duration_seconds') or 0)),
'redirect': 'admin',
}, '登录成功', uid, seq)