]> acesimba.cloud Git - words.git/commitdiff
feat: 新增登录鉴权(单用户 Simba), 未登录无法使用; 后端 HMAC token + 中间件保护 /api/*, 前端登录页与路由守卫
authorroot <root@localhost.localdomain>
Wed, 19 Aug 2026 02:11:59 +0000 (10:11 +0800)
committerroot <root@localhost.localdomain>
Wed, 19 Aug 2026 02:11:59 +0000 (10:11 +0800)
backend/main.py
frontend/src/App.js
frontend/src/api.js
frontend/src/auth.js [new file with mode: 0644]
frontend/src/components/Home.css
frontend/src/components/Home.js
frontend/src/components/Login.css [new file with mode: 0644]
frontend/src/components/Login.js [new file with mode: 0644]
frontend/src/hooks/useOnlineStatus.js
frontend/src/index.js

index a63f59202850ae713665d37edc5c1f3759d1874f..bb0c47b8838fed99a5f2807703e0f8ebf5069a5a 100644 (file)
@@ -1,8 +1,12 @@
-from fastapi import FastAPI, HTTPException, Response
+from fastapi import FastAPI, HTTPException, Response, Request
+from fastapi.responses import JSONResponse
 from fastapi.middleware.cors import CORSMiddleware
 import sqlite3
 import os
 import datetime
+import hmac
+import hashlib
+import base64
 import urllib.request
 import urllib.parse
 import urllib.error
@@ -20,12 +24,68 @@ app.add_middleware(
     allow_headers=["*"],
 )
 
+# --- 登录鉴权中间件:除 /api/auth/login 外,所有 /api/* 必须带有效 token ---
+# token 来源:Authorization: Bearer <token>(axios 请求)或 ?token=<token>(<img>/<Audio> 媒体)
+@app.middleware("http")
+async def auth_guard(request: Request, call_next):
+    path = request.url.path
+    if path.startswith("/api/") and path != "/api/auth/login":
+        auth_header = request.headers.get("Authorization", "")
+        token = auth_header[7:] if auth_header.startswith("Bearer ") else request.query_params.get("token")
+        if not verify_token(token):
+            return JSONResponse(status_code=401, content={"detail": "未登录或登录已过期"})
+    return await call_next(request)
+
 # --- 路径自动修复逻辑 ---
 BASE_DIR = os.path.dirname(os.path.abspath(__file__))
 DATABASE_PATH = os.path.join(BASE_DIR, "a", "bcd.db")
 if not os.path.exists(DATABASE_PATH):
     DATABASE_PATH = os.path.join(BASE_DIR, "db", "bcd.db")
 
+# --- 简单登录鉴权(单用户;账密可用环境变量覆盖,默认 Simba / Simba+0322)---
+# 前端登录后持 token;所有 /api/*(除登录接口)必须带有效 token,否则 401。
+# 改密码:设置环境变量 WORDS_PASSWORD 或告诉我直接在后端改默认值。
+AUTH_USERNAME = os.environ.get("WORDS_USERNAME", "Simba")
+AUTH_PASSWORD = os.environ.get("WORDS_PASSWORD", "Simba+0322")
+AUTH_SECRET = os.environ.get("WORDS_AUTH_SECRET", "words-fixed-secret-change-in-prod-7f3a9c")
+TOKEN_TTL_SECONDS = 30 * 24 * 3600  # 30 天
+
+
+def _b64url(b: bytes) -> str:
+    return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
+
+
+def _b64url_decode(s: str) -> bytes:
+    s = s + "=" * (-len(s) % 4)
+    return base64.urlsafe_b64decode(s.encode())
+
+
+def create_token(username: str) -> str:
+    """签发 HMAC 签名 token:payload(username:expiry).sig,secret 固定故重启不失效。"""
+    expiry = int(datetime.datetime.now().timestamp()) + TOKEN_TTL_SECONDS
+    payload = f"{username}:{expiry}".encode()
+    sig = hmac.new(AUTH_SECRET.encode(), payload, hashlib.sha256).digest()
+    return f"{_b64url(payload)}.{_b64url(sig)}"
+
+
+def verify_token(token: str):
+    """校验 token 签名与过期,返回 username 或 None。"""
+    if not token:
+        return None
+    try:
+        payload_b64, sig_b64 = token.split(".")
+        payload = _b64url_decode(payload_b64)
+        sig = _b64url_decode(sig_b64)
+        expected = hmac.new(AUTH_SECRET.encode(), payload, hashlib.sha256).digest()
+        if not hmac.compare_digest(sig, expected):
+            return None
+        username, expiry = payload.decode().split(":")
+        if int(expiry) < datetime.datetime.now().timestamp():
+            return None
+        return username
+    except Exception:
+        return None
+
 # --- 数据模型 ---
 class HomeData(BaseModel):
     pictures_count: int
@@ -74,6 +134,11 @@ class SyncEvent(BaseModel):
     def coerce_group_id(cls, v):
         return str(v)
 
+
+class LoginRequest(BaseModel):
+    username: str
+    password: str
+
 class SyncPushRequest(BaseModel):
     events: List[SyncEvent]
 
@@ -444,6 +509,14 @@ async def sync_pull():
     except Exception as e:
         raise HTTPException(status_code=500, detail=str(e))
 
+@app.post("/api/auth/login")
+async def auth_login(data: LoginRequest):
+    # 单用户:账密匹配才签发 token。错误统一 401,不透露具体原因。
+    if data.username == AUTH_USERNAME and data.password == AUTH_PASSWORD:
+        return {"status": "success", "token": create_token(data.username)}
+    raise HTTPException(status_code=401, detail="用户名或密码错误")
+
+
 @app.get("/")
 async def root():
     return {"message": "English Lexicon API is running"}
index 9a9dc3b94f8079788d78b0506e344c8cbafdbc45..eac1cdc5da3fd48564c6c602f9416753896df10c 100644 (file)
@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { useState } from 'react';
 import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
 import Home from './components/Home';
 import StudyMode from './components/StudyMode';
@@ -6,8 +6,15 @@ import TestMode from './components/TestMode';
 import GroupView from './components/GroupView';
 import StudyCalendar from './components/StudyCalendar';
 import SyncNotifications from './components/SyncNotifications';
+import Login from './components/Login';
+import { isLoggedIn } from './auth';
 
 function App() {
+    const [loggedIn, setLoggedIn] = useState(isLoggedIn());
+    // 未登录:只显示登录页,应用其余部分完全不加载
+    if (!loggedIn) {
+        return <Login onLogin={() => setLoggedIn(true)} />;
+    }
     return (
         <Router>
             <SyncNotifications />
index 204688638ed890ac647097580b144f841566fc98..911d1dd67fa11fce7c082f3d33617d835af073de 100644 (file)
@@ -1,4 +1,5 @@
 import axios from 'axios';
+import { getToken, clearToken } from './auth';
 
 // 后端 API 基址。PWA 离线方案中所有组件统一走此出口,便于 Service Worker 拦截与未来切换。
 // 默认走跨域 IP:端口(:55 现有 build 行为不变);HTTPS 同源 build 通过 REACT_APP_API_BASE="" 覆盖为相对路径。
@@ -12,6 +13,23 @@ export const http = axios.create({
     timeout: 8000,
 });
 
+// 登录鉴权拦截器:自动带 token;遇 401(未登录/过期)清 token 并跳回登录页
+http.interceptors.request.use((config) => {
+    const t = getToken();
+    if (t) config.headers.Authorization = `Bearer ${t}`;
+    return config;
+});
+http.interceptors.response.use(
+    (res) => res,
+    (err) => {
+        if (err.response && err.response.status === 401) {
+            clearToken();
+            window.location.href = '/';
+        }
+        return Promise.reject(err);
+    }
+);
+
 // 生成中国时间(Asia/Shanghai, UTC+8)时间戳 "YYYY-MM-DD HH:MM:SS"。
 // 注意: 必须用固定 +8h 偏移,不能用 toISOString()(那是 UTC),否则
 // 凌晨学习会被记到前一天(时区 bug)。与服务器 Asia/Shanghai 保持一致。
@@ -36,7 +54,8 @@ export const api = {
         headers: { 'Content-Type': 'multipart/form-data' },
     }),
     // 图片与 TTS 走 <img src>/new Audio(),不走 axios;SW 仍能拦截缓存
-    pictureUrl: (picId) => `${API_BASE}/api/picture/${picId}`,
+    // 需带 ?token= 以通过后端鉴权中间件(不登录无法取媒体)
+    pictureUrl: (picId) => `${API_BASE}/api/picture/${picId}?token=${getToken()}`,
     // 走查询参数 w=,避免词内字面 "/"(如 "in the/a form of")与 URL 路径分隔符冲突导致 404
-    ttsUrl: (word) => `${API_BASE}/api/tts?w=${encodeURIComponent(word)}`,
+    ttsUrl: (word) => `${API_BASE}/api/tts?w=${encodeURIComponent(word)}&token=${getToken()}`,
 };
diff --git a/frontend/src/auth.js b/frontend/src/auth.js
new file mode 100644 (file)
index 0000000..4a4bcf0
--- /dev/null
@@ -0,0 +1,45 @@
+// 简单登录态管理(单用户)。token 存 localStorage(保持登录,30 天过期)。
+// 后端在 main.py 校验账密并签发 HMAC token;所有 /api/* 需携带该 token。
+import { API_BASE } from './api';
+
+const TOKEN_KEY = 'words_auth_token';
+
+export function getToken() {
+    return localStorage.getItem(TOKEN_KEY) || '';
+}
+
+export function setToken(t) {
+    localStorage.setItem(TOKEN_KEY, t);
+}
+
+export function clearToken() {
+    localStorage.removeItem(TOKEN_KEY);
+}
+
+export function isLoggedIn() {
+    return !!getToken();
+}
+
+export async function login(username, password) {
+    const r = await fetch(`${API_BASE}/api/auth/login`, {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ username, password }),
+    });
+    if (!r.ok) {
+        let msg = '登录失败';
+        try { msg = (await r.json()).detail || msg; } catch (e) {}
+        throw new Error(msg);
+    }
+    const data = await r.json();
+    if (data && data.token) {
+        setToken(data.token);
+        return true;
+    }
+    throw new Error('未返回 token');
+}
+
+export function logout() {
+    clearToken();
+}
+
index 7878fd175d9d1fd9bf9e9c204d4685d3400ef662..1a3db682d9a2c73d35ad2f2b2137576faf274abb 100644 (file)
       padding: 8px 16px;
       font-size: 13px;
     }
+  }
+
+  /* 头部右侧:统计信息 + 退出按钮 */
+  .header-right {
+    display: flex;
+    align-items: center;
+    gap: 14px;
+    flex-wrap: wrap;
+    justify-content: flex-end;
+  }
+  .logout-btn {
+    border: 1px solid #c9d4e3;
+    background: #fff;
+    color: #4a6fa5;
+    border-radius: 8px;
+    padding: 6px 14px;
+    font-size: 13px;
+    cursor: pointer;
+  }
+  .logout-btn:hover {
+    background: #eef3fb;
   }
\ No newline at end of file
index 20ba64d90d22c2c069862c32fcbccb7aa9d577dc..01f71f8990f43263cf2f9e455282c207886ef239 100644 (file)
@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom';
 import { useOnlineStatus } from '../hooks/useOnlineStatus';
 import { getAllWordsFromIDB, getAllProgress, isInitialized } from '../db';
 import { triggerAutoSync, subscribeSyncStatus } from '../sync';
+import { logout } from '../auth';
 import './Home.css';
 
 function Home() {
@@ -134,9 +135,12 @@ function Home() {
         <div className="main-app">
             <header className="header">
                 <h1>单词学习进度记录</h1>
-                <p className="stats-info">
-                    图片总数: {stats.pictures_count} | 单词总数: {stats.words_count}
-                </p>
+                <div className="header-right">
+                    <p className="stats-info">
+                        图片总数: {stats.pictures_count} | 单词总数: {stats.words_count}
+                    </p>
+                    <button className="logout-btn" onClick={() => { logout(); window.location.reload(); }}>退出</button>
+                </div>
             </header>
 
             {needInit && (
diff --git a/frontend/src/components/Login.css b/frontend/src/components/Login.css
new file mode 100644 (file)
index 0000000..d215673
--- /dev/null
@@ -0,0 +1,78 @@
+.login-wrap {
+    min-height: 100vh;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    background: #f3f5f9;
+    padding: 16px;
+}
+
+.login-box {
+    width: 320px;
+    max-width: 100%;
+    background: #fff;
+    border-radius: 12px;
+    box-shadow: 0 6px 24px rgba(0, 0, 0, 0.08);
+    padding: 28px 24px;
+    display: flex;
+    flex-direction: column;
+}
+
+.login-title {
+    margin: 0;
+    font-size: 22px;
+    text-align: center;
+    color: #1f2d3d;
+}
+
+.login-sub {
+    margin: 4px 0 20px;
+    text-align: center;
+    color: #8a94a6;
+    font-size: 13px;
+}
+
+.login-label {
+    font-size: 13px;
+    color: #5a6677;
+    margin-bottom: 6px;
+}
+
+.login-input {
+    height: 40px;
+    border: 1px solid #d8dee9;
+    border-radius: 8px;
+    padding: 0 12px;
+    font-size: 14px;
+    margin-bottom: 14px;
+    outline: none;
+}
+
+.login-input:focus {
+    border-color: #4a90e2;
+}
+
+.login-error {
+    background: #fdecea;
+    color: #c0392b;
+    font-size: 13px;
+    padding: 8px 10px;
+    border-radius: 6px;
+    margin-bottom: 12px;
+}
+
+.login-btn {
+    height: 42px;
+    border: none;
+    border-radius: 8px;
+    background: #4a90e2;
+    color: #fff;
+    font-size: 15px;
+    cursor: pointer;
+    margin-top: 4px;
+}
+
+.login-btn:disabled {
+    opacity: 0.6;
+    cursor: default;
+}
diff --git a/frontend/src/components/Login.js b/frontend/src/components/Login.js
new file mode 100644 (file)
index 0000000..4a83215
--- /dev/null
@@ -0,0 +1,54 @@
+import React, { useState } from 'react';
+import { login } from '../auth';
+import './Login.css';
+
+export default function Login({ onLogin }) {
+    const [username, setUsername] = useState('');
+    const [password, setPassword] = useState('');
+    const [error, setError] = useState('');
+    const [loading, setLoading] = useState(false);
+
+    const handleSubmit = async (e) => {
+        e.preventDefault();
+        setError('');
+        setLoading(true);
+        try {
+            await login(username.trim(), password);
+            setLoading(false);
+            onLogin();
+        } catch (err) {
+            setLoading(false);
+            setError(err.message || '登录失败');
+        }
+    };
+
+    return (
+        <div className="login-wrap">
+            <form className="login-box" onSubmit={handleSubmit}>
+                <h1 className="login-title">单词学习</h1>
+                <p className="login-sub">请登录后使用</p>
+                <label className="login-label">用户名</label>
+                <input
+                    className="login-input"
+                    type="text"
+                    value={username}
+                    autoFocus
+                    onChange={(e) => setUsername(e.target.value)}
+                    placeholder="用户名"
+                />
+                <label className="login-label">密码</label>
+                <input
+                    className="login-input"
+                    type="password"
+                    value={password}
+                    onChange={(e) => setPassword(e.target.value)}
+                    placeholder="密码"
+                />
+                {error && <div className="login-error">{error}</div>}
+                <button className="login-btn" type="submit" disabled={loading}>
+                    {loading ? '登录中…' : '登录'}
+                </button>
+            </form>
+        </div>
+    );
+}
index 3c3b5ff1dddd4795a7c04bad898961c44996002e..9c0f7132f6fad817bc56e180aada540d1df31390 100644 (file)
@@ -1,5 +1,6 @@
 import { useState, useEffect } from 'react';
 import { API_BASE } from '../api';
+import { getToken } from '../auth';
 
 // 返回当前是否联网
 // navigator.onLine + online/offline 事件在 iOS Safari 不可靠 (飞行模式切换不触发)
@@ -18,7 +19,10 @@ export function useOnlineStatus() {
             try {
                 const ctrl = new AbortController();
                 const t = setTimeout(() => ctrl.abort(), 2500);
-                const r = await fetch(`${API_BASE}/api/home`, { signal: ctrl.signal });
+                const headers = {};
+                const tok = getToken();
+                if (tok) headers.Authorization = `Bearer ${tok}`;
+                const r = await fetch(`${API_BASE}/api/home`, { signal: ctrl.signal, headers });
                 clearTimeout(t);
                 setIsOnline(r.ok);
             } catch (e) {
index a918fdb1be48ec0a87e118c59d3834bd5b79c2b5..2b8b867011101e8e0e8d7430702c49f28db9010b 100644 (file)
@@ -2,6 +2,7 @@ import React from 'react';
 import ReactDOM from 'react-dom/client';
 import './index.css';
 import App from './App';
+import { isLoggedIn } from './auth';
 import reportWebVitals from './reportWebVitals';
 
 const root = ReactDOM.createRoot(document.getElementById('root'));
@@ -22,7 +23,9 @@ if ('serviceWorker' in navigator) {
     navigator.serviceWorker.register('/sw.js').catch(err => console.error('SW registration failed:', err));
     // SW 接管页面后, 再触发一次同步+预取, 确保预取请求被 SW 拦截并写入缓存
     navigator.serviceWorker.ready.then(() => {
-      import('./sync').then(m => m.triggerAutoSync()).catch(() => {});
+      if (isLoggedIn()) {
+        import('./sync').then(m => m.triggerAutoSync()).catch(() => {});
+      }
     }).catch(() => {});
   });
 }