fix: 修复混合认证方案 - Cookie + Authorization Header

- 修改 login/register API 使用 JSONResponse 正确设置 Cookie
- 添加 path="/" 确保 Cookie 在所有路径可用
- 前端同时使用 localStorage token 进行 API 认证
- 修复登录后闪屏返回登录页的问题

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-22 15:03:01 +08:00
parent 518e5c8284
commit 9fa6616110
3 changed files with 200 additions and 43 deletions

View File

@@ -528,7 +528,7 @@ def get_current_user_id(authorization: Optional[str] = Header(None)) -> int:
# ===== 认证 API =====
@app.post("/api/auth/login")
async def login(data: LoginInput, response: Response):
async def login(data: LoginInput):
"""用户登录"""
user = db.get_user_by_name(data.username)
if not user:
@@ -543,6 +543,17 @@ async def login(data: LoginInput, response: Response):
# 创建 token根据 remember_me 设置不同的过期时间)
token = create_token(user.id, user.name, user.is_admin, data.remember_me)
# 创建响应并设置 Cookie
response = JSONResponse(content={
"token": token,
"user": {
"id": user.id,
"name": user.name,
"is_admin": user.is_admin,
"is_disabled": user.is_disabled,
}
})
# 设置 HTTPOnly Cookie
max_age = get_token_expire_seconds(data.remember_me) if data.remember_me else None
response.set_cookie(
@@ -552,21 +563,14 @@ async def login(data: LoginInput, response: Response):
secure=False, # HTTP 环境,生产环境应设为 True
samesite="lax",
max_age=max_age, # None 表示会话 Cookie
path="/", # 确保所有路径都能使用此 Cookie
)
return {
"token": token,
"user": {
"id": user.id,
"name": user.name,
"is_admin": user.is_admin,
"is_disabled": user.is_disabled,
}
}
return response
@app.post("/api/auth/register")
async def register(data: RegisterInput, response: Response):
async def register(data: RegisterInput):
"""用户注册(需要邀请码)"""
# 验证邀请码
invite = db.get_invite_by_code(data.invite_code)
@@ -599,18 +603,9 @@ async def register(data: RegisterInput, response: Response):
# 获取创建的用户
user = db.get_user(user_id)
# 生成 token 并设置 Cookie
# 生成 token 并创建响应
token = create_token(user.id, user.name, user.is_admin)
response.set_cookie(
key="auth_token",
value=token,
httponly=True,
secure=False,
samesite="lax",
max_age=get_token_expire_seconds(False), # 注册默认 1 天
)
return {
response = JSONResponse(content={
"token": token,
"user": {
"id": user.id,
@@ -618,7 +613,20 @@ async def register(data: RegisterInput, response: Response):
"is_admin": user.is_admin,
"is_disabled": user.is_disabled,
}
}
})
# 设置 Cookie
response.set_cookie(
key="auth_token",
value=token,
httponly=True,
secure=False,
samesite="lax",
max_age=get_token_expire_seconds(False), # 注册默认 1 天
path="/",
)
return response
@app.post("/api/auth/logout")
@@ -2228,17 +2236,24 @@ def get_login_page_html() -> str:
const data = await response.json();
if (response.ok) {
// 保存用户信息(非敏感)
// 保存 token 和用户信息
localStorage.setItem('token', data.token);
localStorage.setItem('user', JSON.stringify(data.user));
// 显示成功状态
btn.textContent = '✓ 登录成功';
btn.classList.add('success');
// 跳转
// 跳转(管理员跳转到管理页面)
setTimeout(() => {
const params = new URLSearchParams(window.location.search);
const redirect = params.get('redirect') || '/';
let redirect = params.get('redirect');
// 如果没有指定重定向,管理员默认跳转到 /admin
if (!redirect) {
redirect = data.user.is_admin ? '/admin' : '/';
}
window.location.href = redirect;
}, 500);
} else {
@@ -2566,7 +2581,8 @@ def get_register_page_html() -> str:
const data = await response.json();
if (response.ok) {
// 保存用户信息
// 保存 token 和用户信息
localStorage.setItem('token', data.token);
localStorage.setItem('user', JSON.stringify(data.user));
// 显示成功
@@ -2959,18 +2975,28 @@ def get_admin_page_html() -> str:
} catch (e) {
// 忽略错误
}
localStorage.removeItem('token');
localStorage.removeItem('user');
window.location.href = '/login';
}
function getToken() {
return localStorage.getItem('token');
}
async function apiRequest(url, options = {}) {
const token = getToken();
const headers = {
'Content-Type': 'application/json',
...options.headers,
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(url, {
...options,
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
...options.headers,
}
headers
});
if (response.status === 401) {
logout();
@@ -7133,6 +7159,26 @@ def get_settings_page_html() -> str:
</form>
</div>
<!-- 账户管理 -->
<div class="section">
<h2>账户</h2>
<p style="color: #64748B; margin-bottom: 20px;">当前登录账户管理</p>
<div style="display: flex; align-items: center; justify-content: space-between; padding: 20px; background: #F8FAFC; border-radius: 8px; margin-bottom: 20px;">
<div>
<div style="font-weight: 600; color: #1E293B; font-size: 1.1rem;" id="account-name">--</div>
<div style="color: #64748B; font-size: 0.9rem; margin-top: 4px;" id="account-role">普通用户</div>
</div>
<div id="account-badge" style="display: none; padding: 4px 12px; background: #3B82F6; color: white; border-radius: 12px; font-size: 0.75rem; font-weight: 700;">
管理员
</div>
</div>
<button type="button" class="btn btn-danger" onclick="logoutAccount()" style="width: 100%;">
退出登录
</button>
</div>
<!-- 创建用户模态框 -->
<div id="create-user-modal" class="modal">
<div class="modal-content" style="max-width: 500px;">
@@ -7575,10 +7621,50 @@ def get_settings_page_html() -> str:
});
});
// 加载账户信息
async function loadAccountInfo() {
try {
const response = await fetch('/api/auth/me', { credentials: 'same-origin' });
if (response.ok) {
const user = await response.json();
document.getElementById('account-name').textContent = user.name;
document.getElementById('account-role').textContent = user.is_admin ? '管理员账户' : '普通用户';
if (user.is_admin) {
document.getElementById('account-badge').style.display = 'block';
}
// 保存到 localStorage
localStorage.setItem('user', JSON.stringify(user));
}
} catch (error) {
console.error('加载账户信息失败:', error);
}
}
// 退出登录
async function logoutAccount() {
if (!confirm('确定要退出登录吗?')) {
return;
}
try {
await fetch('/api/auth/logout', {
method: 'POST',
credentials: 'same-origin'
});
} catch (error) {
console.error('登出失败:', error);
}
localStorage.removeItem('token');
localStorage.removeItem('user');
window.location.href = '/login';
}
// 页面加载
document.addEventListener('DOMContentLoaded', () => {
loadUsers();
loadProfile();
loadAccountInfo();
// BMI 实时计算
document.getElementById('profile-height').addEventListener('input', calculateBMI);