From d742d4972c78bf1885ab21a5eb3732e20ac74ef4 Mon Sep 17 00:00:00 2001 From: fish Date: Sun, 3 May 2026 17:44:08 +0800 Subject: [PATCH] =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=91=98=E9=BB=98=E8=AE=A4?= =?UTF-8?q?=E5=AF=86=E7=A0=81=20admin/admin=EF=BC=8C=E9=A6=96=E6=AC=A1?= =?UTF-8?q?=E7=99=BB=E5=BD=95=E5=BC=BA=E5=88=B6=E6=94=B9=E5=AF=86=E7=A0=81?= =?UTF-8?q?=EF=BC=9B=E5=A2=9E=E5=8A=A0=E6=9C=8D=E5=8A=A1=E5=99=A8=E9=83=A8?= =?UTF-8?q?=E7=BD=B2=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 --- docker-compose.server.yml | 64 ++++++++++ docker-compose.yml | 4 +- web/backend/internal/auth/bootstrap.go | 20 ++-- web/backend/internal/config/config.go | 4 - web/backend/internal/handlers/auth.go | 85 ++++++++++++-- web/backend/internal/handlers/users.go | 5 + web/backend/internal/router/router.go | 1 + web/backend/internal/store/auth.go | 49 ++++++-- web/backend/main.go | 2 +- web/frontend/src/api/auth.ts | 5 + web/frontend/src/router/index.ts | 9 ++ web/frontend/src/stores/auth.ts | 32 ++++- web/frontend/src/views/ChangePasswordView.vue | 109 ++++++++++++++++++ web/frontend/src/views/LoginView.vue | 10 +- 14 files changed, 350 insertions(+), 49 deletions(-) create mode 100644 docker-compose.server.yml create mode 100644 web/frontend/src/views/ChangePasswordView.vue diff --git a/docker-compose.server.yml b/docker-compose.server.yml new file mode 100644 index 0000000..bcdbd77 --- /dev/null +++ b/docker-compose.server.yml @@ -0,0 +1,64 @@ +services: + postgres: + image: postgres:18.3-alpine3.23 + container_name: trade-postgres + environment: + POSTGRES_USER: trade + POSTGRES_PASSWORD: trade + POSTGRES_DB: futures + volumes: + - pgdata:/var/lib/postgresql + healthcheck: + test: ["CMD-SHELL", "pg_isready -U trade -d futures"] + interval: 5s + timeout: 5s + retries: 5 + restart: unless-stopped + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + + tushare: + image: registry.fishestlife.com/trade-tushare:latest + container_name: trade-tushare + env_file: ./tushare/.env + environment: + - DATABASE_URL=postgresql://trade:trade@postgres:5432/futures + depends_on: + postgres: + condition: service_healthy + ports: + - "4001:8000" + command: ["uvicorn", "src.api:app", "--host", "0.0.0.0", "--port", "8000"] + restart: unless-stopped + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + + web: + image: registry.fishestlife.com/trade-web:latest + container_name: trade-web + env_file: ./web/backend/.env + environment: + - LISTEN_ADDR=:8080 + - DATABASE_URL=postgres://trade:trade@postgres:5432/futures?sslmode=disable + - AUTH_DB_PATH=/app/auth/auth.db + depends_on: + - postgres + ports: + - "4000:8080" + volumes: + - ./data:/app/auth + restart: unless-stopped + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + +volumes: + pgdata: diff --git a/docker-compose.yml b/docker-compose.yml index f7fd990..6dc925e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -24,7 +24,7 @@ services: postgres: condition: service_healthy ports: - - "8000:8000" + - "4001:8000" command: ["uvicorn", "src.api:app", "--host", "0.0.0.0", "--port", "8000"] web: @@ -40,7 +40,7 @@ services: depends_on: - postgres ports: - - "8080:8080" + - "4000:8080" volumes: - ./data:/app/auth restart: unless-stopped diff --git a/web/backend/internal/auth/bootstrap.go b/web/backend/internal/auth/bootstrap.go index ec2bcfe..22d42c2 100644 --- a/web/backend/internal/auth/bootstrap.go +++ b/web/backend/internal/auth/bootstrap.go @@ -6,9 +6,9 @@ import ( "trade/web/internal/store" ) -// Bootstrap 在 auth.db 没有任何 admin 时,从 ADMIN_USER/ADMIN_PASS 写入一条管理员; -// 已存在 admin 时静默跳过,避免轮换 env 时静默改密。 -func Bootstrap(s *store.AuthStore, adminUser, adminPass string) error { +// Bootstrap 在 auth.db 没有任何 admin 时,写入默认管理员 admin/admin; +// 并强制首次登录后改密码。已存在 admin 时静默跳过。 +func Bootstrap(s *store.AuthStore) error { n, err := s.CountAdmins() if err != nil { return err @@ -16,17 +16,17 @@ func Bootstrap(s *store.AuthStore, adminUser, adminPass string) error { if n > 0 { return nil } - if adminUser == "" || adminPass == "" { - log.Printf("[bootstrap] auth.db 无 admin,但 ADMIN_USER/ADMIN_PASS 未设置,跳过引导") - return nil - } - hash, err := HashPassword(adminPass) + hash, err := HashPassword("admin") if err != nil { return err } - if _, err := s.CreateUser(adminUser, hash, store.RoleAdmin); err != nil { + u, err := s.CreateUser("admin", hash, store.RoleAdmin) + if err != nil { return err } - log.Printf("[bootstrap] admin %q created", adminUser) + if err := s.SetForcePasswordChange(u.ID, true); err != nil { + return err + } + log.Printf("[bootstrap] admin created (default password), force password change enabled") return nil } diff --git a/web/backend/internal/config/config.go b/web/backend/internal/config/config.go index 6e92ea9..bd76627 100644 --- a/web/backend/internal/config/config.go +++ b/web/backend/internal/config/config.go @@ -11,8 +11,6 @@ type Config struct { DatabaseURL string AuthDBPath string JWTSecret []byte - AdminUser string - AdminPass string TushareAPIURL string } @@ -21,8 +19,6 @@ func Load() (*Config, error) { ListenAddr: getenv("LISTEN_ADDR", ":8080"), DatabaseURL: os.Getenv("DATABASE_URL"), AuthDBPath: getenv("AUTH_DB_PATH", "/app/auth/auth.db"), - AdminUser: strings.TrimSpace(os.Getenv("ADMIN_USER")), - AdminPass: os.Getenv("ADMIN_PASS"), TushareAPIURL: getenv("TUSHARE_API_URL", "http://tushare:8000"), } if cfg.DatabaseURL == "" { diff --git a/web/backend/internal/handlers/auth.go b/web/backend/internal/handlers/auth.go index f74ea17..aef4b62 100644 --- a/web/backend/internal/handlers/auth.go +++ b/web/backend/internal/handlers/auth.go @@ -16,14 +16,21 @@ type loginReq struct { } type loginResp struct { - Token string `json:"token"` - User publicUserView `json:"user"` + Token string `json:"token"` + User publicUserView `json:"user"` + RequirePasswordChange bool `json:"require_password_change"` } type publicUserView struct { - ID int64 `json:"id"` - Username string `json:"username"` - Role string `json:"role"` + ID int64 `json:"id"` + Username string `json:"username"` + Role string `json:"role"` + ForcePasswordChange bool `json:"force_password_change"` +} + +type changePasswordReq struct { + OldPassword string `json:"old_password"` + NewPassword string `json:"new_password"` } func (d *Deps) Login(w http.ResponseWriter, r *http.Request) { @@ -54,10 +61,63 @@ func (d *Deps) Login(w http.ResponseWriter, r *http.Request) { } writeJSON(w, http.StatusOK, loginResp{ Token: token, - User: publicUserView{ID: u.ID, Username: u.Username, Role: u.Role}, + User: publicUserView{ + ID: u.ID, + Username: u.Username, + Role: u.Role, + ForcePasswordChange: u.ForcePasswordChange, + }, + RequirePasswordChange: u.ForcePasswordChange, }) } +func (d *Deps) ChangePassword(w http.ResponseWriter, r *http.Request) { + me, ok := middleware.FromContext(r.Context()) + if !ok { + writeErr(w, http.StatusUnauthorized, "no user") + return + } + var req changePasswordReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErr(w, http.StatusBadRequest, "invalid json") + return + } + if req.OldPassword == "" || req.NewPassword == "" { + writeErr(w, http.StatusBadRequest, "旧密码和新密码都不能为空") + return + } + if len(req.NewPassword) < 6 { + writeErr(w, http.StatusBadRequest, "新密码至少 6 位") + return + } + + u, err := d.Auth.GetByID(me.ID) + if err != nil { + writeErr(w, http.StatusUnauthorized, "user not found") + return + } + if !auth.CheckPassword(u.PasswordHash, req.OldPassword) { + writeErr(w, http.StatusUnauthorized, "旧密码错误") + return + } + + hash, err := auth.HashPassword(req.NewPassword) + if err != nil { + writeErr(w, http.StatusInternalServerError, "hash failed") + return + } + if err := d.Auth.UpdatePassword(me.ID, hash); err != nil { + writeErr(w, http.StatusInternalServerError, err.Error()) + return + } + // 改密码后清除强制改密标记 + if err := d.Auth.SetForcePasswordChange(me.ID, false); err != nil { + writeErr(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + func (d *Deps) Logout(w http.ResponseWriter, r *http.Request) { // JWT 是无状态的,服务端 logout 仅形式化;前端丢弃 token 即可。 writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) @@ -80,11 +140,12 @@ func (d *Deps) Me(w http.ResponseWriter, r *http.Request) { // sanitize 把内部 User 转成对外视图,剥掉 password_hash。 func sanitize(u *store.User) map[string]any { return map[string]any{ - "id": u.ID, - "username": u.Username, - "role": u.Role, - "disabled": u.Disabled, - "created_at": u.CreatedAt, - "updated_at": u.UpdatedAt, + "id": u.ID, + "username": u.Username, + "role": u.Role, + "disabled": u.Disabled, + "force_password_change": u.ForcePasswordChange, + "created_at": u.CreatedAt, + "updated_at": u.UpdatedAt, } } diff --git a/web/backend/internal/handlers/users.go b/web/backend/internal/handlers/users.go index 0bbc76e..547b0a1 100644 --- a/web/backend/internal/handlers/users.go +++ b/web/backend/internal/handlers/users.go @@ -100,6 +100,11 @@ func (d *Deps) AdminPatchUser(w http.ResponseWriter, r *http.Request) { writeErr(w, statusForErr(err), err.Error()) return } + // 管理员重置密码后,强制用户下次登录改密 + if err := d.Auth.SetForcePasswordChange(id, true); err != nil { + writeErr(w, statusForErr(err), err.Error()) + return + } } if req.Disabled != nil { // 禁止禁用自己,避免管理员锁死自己 diff --git a/web/backend/internal/router/router.go b/web/backend/internal/router/router.go index 8a01854..acd35ba 100644 --- a/web/backend/internal/router/router.go +++ b/web/backend/internal/router/router.go @@ -26,6 +26,7 @@ func New(d *handlers.Deps, mgr *auth.Manager, authStore *store.AuthStore, dist f r.Post("/logout", d.Logout) r.Get("/me", d.Me) + r.Post("/change-password", d.ChangePassword) r.Get("/scores", d.ListScores) r.Get("/scores/{id}", d.GetScore) r.Get("/contracts", d.ListContracts) diff --git a/web/backend/internal/store/auth.go b/web/backend/internal/store/auth.go index 1d129db..3acb8ad 100644 --- a/web/backend/internal/store/auth.go +++ b/web/backend/internal/store/auth.go @@ -13,13 +13,14 @@ import ( type AuthStore struct{ db *sql.DB } type User struct { - ID int64 `json:"id"` - Username string `json:"username"` - PasswordHash string `json:"-"` - Role string `json:"role"` - Disabled bool `json:"disabled"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` + ID int64 `json:"id"` + Username string `json:"username"` + PasswordHash string `json:"-"` + Role string `json:"role"` + Disabled bool `json:"disabled"` + ForcePasswordChange bool `json:"force_password_change"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` } const ( @@ -64,7 +65,12 @@ func (s *AuthStore) init() error { ); CREATE INDEX IF NOT EXISTS idx_users_username ON users(username); `) - return err + if err != nil { + return err + } + // 兼容旧表:添加 force_password_change 列(已存在则忽略错误) + _, _ = s.db.Exec(`ALTER TABLE users ADD COLUMN force_password_change INTEGER NOT NULL DEFAULT 0`) + return nil } func (s *AuthStore) CountAdmins() (int, error) { @@ -89,19 +95,19 @@ func (s *AuthStore) CreateUser(username, passwordHash, role string) (*User, erro } func (s *AuthStore) GetByUsername(username string) (*User, error) { - row := s.db.QueryRow(`SELECT id, username, password_hash, role, disabled, created_at, updated_at + row := s.db.QueryRow(`SELECT id, username, password_hash, role, disabled, force_password_change, created_at, updated_at FROM users WHERE username = ?`, username) return scanUser(row) } func (s *AuthStore) GetByID(id int64) (*User, error) { - row := s.db.QueryRow(`SELECT id, username, password_hash, role, disabled, created_at, updated_at + row := s.db.QueryRow(`SELECT id, username, password_hash, role, disabled, force_password_change, created_at, updated_at FROM users WHERE id = ?`, id) return scanUser(row) } func (s *AuthStore) ListUsers() ([]User, error) { - rows, err := s.db.Query(`SELECT id, username, password_hash, role, disabled, created_at, updated_at + rows, err := s.db.Query(`SELECT id, username, password_hash, role, disabled, force_password_change, created_at, updated_at FROM users ORDER BY id ASC`) if err != nil { return nil, err @@ -131,6 +137,23 @@ func (s *AuthStore) UpdatePassword(id int64, hash string) error { return nil } +func (s *AuthStore) SetForcePasswordChange(id int64, v bool) error { + now := time.Now().Format("2006-01-02 15:04:05") + val := 0 + if v { + val = 1 + } + res, err := s.db.Exec(`UPDATE users SET force_password_change = ?, updated_at = ? WHERE id = ?`, val, now, id) + if err != nil { + return err + } + n, _ := res.RowsAffected() + if n == 0 { + return ErrNotFound + } + return nil +} + func (s *AuthStore) SetDisabled(id int64, disabled bool) error { now := time.Now().Format("2006-01-02 15:04:05") v := 0 @@ -167,13 +190,15 @@ type rowScanner interface { func scanUser(r rowScanner) (*User, error) { var u User var disabled int - if err := r.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &disabled, &u.CreatedAt, &u.UpdatedAt); err != nil { + var forceChange int + if err := r.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &disabled, &forceChange, &u.CreatedAt, &u.UpdatedAt); err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound } return nil, err } u.Disabled = disabled != 0 + u.ForcePasswordChange = forceChange != 0 return &u, nil } diff --git a/web/backend/main.go b/web/backend/main.go index fa72912..5ae77db 100644 --- a/web/backend/main.go +++ b/web/backend/main.go @@ -36,7 +36,7 @@ func main() { } defer authDB.Close() - if err := auth.Bootstrap(authDB, cfg.AdminUser, cfg.AdminPass); err != nil { + if err := auth.Bootstrap(authDB); err != nil { log.Fatalf("bootstrap: %v", err) } diff --git a/web/frontend/src/api/auth.ts b/web/frontend/src/api/auth.ts index 15f79d0..15c5b76 100644 --- a/web/frontend/src/api/auth.ts +++ b/web/frontend/src/api/auth.ts @@ -4,6 +4,7 @@ import type { AuthUser } from '@/stores/auth' export interface LoginResp { token: string user: AuthUser + require_password_change: boolean } export function login(username: string, password: string) { @@ -17,3 +18,7 @@ export function logout() { export function me() { return client.get('/me').then((r) => r.data) } + +export function changePassword(oldPassword: string, newPassword: string) { + return client.post('/change-password', { old_password: oldPassword, new_password: newPassword }).then((r) => r.data) +} diff --git a/web/frontend/src/router/index.ts b/web/frontend/src/router/index.ts index 8dea98d..f078a62 100644 --- a/web/frontend/src/router/index.ts +++ b/web/frontend/src/router/index.ts @@ -8,6 +8,12 @@ const routes: RouteRecordRaw[] = [ component: () => import('@/views/LoginView.vue'), meta: { layout: 'blank', public: true }, }, + { + path: '/change-password', + name: 'change-password', + component: () => import('@/views/ChangePasswordView.vue'), + meta: { layout: 'blank' }, + }, { path: '/', redirect: '/scores' }, { path: '/scores', @@ -44,6 +50,9 @@ router.beforeEach((to) => { if (!auth.token) { return { path: '/login', query: { redirect: to.fullPath } } } + if (auth.requirePasswordChange && to.path !== '/change-password') { + return { path: '/change-password' } + } if (to.meta.adminOnly && !auth.isAdmin) { return { path: '/scores' } } diff --git a/web/frontend/src/stores/auth.ts b/web/frontend/src/stores/auth.ts index a718c83..0b2b34b 100644 --- a/web/frontend/src/stores/auth.ts +++ b/web/frontend/src/stores/auth.ts @@ -4,11 +4,13 @@ export interface AuthUser { id: number username: string role: 'admin' | 'user' + force_password_change?: boolean } interface State { token: string user: AuthUser | null + requirePasswordChange: boolean } const STORAGE_KEY = 'trade.auth' @@ -16,10 +18,15 @@ const STORAGE_KEY = 'trade.auth' function load(): State { try { const raw = localStorage.getItem(STORAGE_KEY) - if (!raw) return { token: '', user: null } - return JSON.parse(raw) as State + if (!raw) return { token: '', user: null, requirePasswordChange: false } + const parsed = JSON.parse(raw) as Partial + return { + token: parsed.token || '', + user: parsed.user || null, + requirePasswordChange: parsed.requirePasswordChange ?? false, + } } catch { - return { token: '', user: null } + return { token: '', user: null, requirePasswordChange: false } } } @@ -29,14 +36,29 @@ export const useAuthStore = defineStore('auth', { isAdmin: (s) => s.user?.role === 'admin', }, actions: { - setSession(token: string, user: AuthUser) { + setSession(token: string, user: AuthUser, requirePasswordChange?: boolean) { this.token = token this.user = user - localStorage.setItem(STORAGE_KEY, JSON.stringify({ token, user })) + this.requirePasswordChange = requirePasswordChange ?? false + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ token, user, requirePasswordChange: this.requirePasswordChange }), + ) + }, + clearRequirePasswordChange() { + this.requirePasswordChange = false + if (this.user) { + this.user.force_password_change = false + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ token: this.token, user: this.user, requirePasswordChange: false }), + ) + } }, logout() { this.token = '' this.user = null + this.requirePasswordChange = false localStorage.removeItem(STORAGE_KEY) }, }, diff --git a/web/frontend/src/views/ChangePasswordView.vue b/web/frontend/src/views/ChangePasswordView.vue new file mode 100644 index 0000000..d29b199 --- /dev/null +++ b/web/frontend/src/views/ChangePasswordView.vue @@ -0,0 +1,109 @@ + + + + + diff --git a/web/frontend/src/views/LoginView.vue b/web/frontend/src/views/LoginView.vue index 7645cbc..367fc2a 100644 --- a/web/frontend/src/views/LoginView.vue +++ b/web/frontend/src/views/LoginView.vue @@ -20,9 +20,13 @@ async function submit() { loading.value = true try { const resp = await login(form.username.trim(), form.password) - auth.setSession(resp.token, resp.user) - const redirect = (route.query.redirect as string) || '/scores' - router.replace(redirect) + auth.setSession(resp.token, resp.user, resp.require_password_change) + if (resp.require_password_change) { + router.replace('/change-password') + } else { + const redirect = (route.query.redirect as string) || '/scores' + router.replace(redirect) + } } catch { // axios 拦截器已弹错 } finally {