package app import ( "context" "crypto/sha256" "database/sql" "errors" "net/http" "strings" "time" ) const ( adminAccessTokenTTL = 30 * time.Minute adminRefreshTokenTTL = 7 * 24 * time.Hour adminRefreshCookieKey = "xingyu_admin_refresh" ) func adminUserAgentHash(r *http.Request) []byte { sum := sha256.Sum256([]byte(strings.TrimSpace(r.UserAgent()))) return sum[:] } func (a *App) setAdminRefreshCookie(w http.ResponseWriter, token string, expiresAt time.Time) { maxAge := int(time.Until(expiresAt).Seconds()) if maxAge < 0 { maxAge = 0 } sameSite := http.SameSiteLaxMode secure := a.config.Environment == "production" if secure { // Production supports a dedicated HTTPS API domain while keeping the // refresh token inaccessible to JavaScript. sameSite = http.SameSiteNoneMode } http.SetCookie(w, &http.Cookie{ Name: adminRefreshCookieKey, Value: token, Path: "/admin/v1/auth", Expires: expiresAt, MaxAge: maxAge, HttpOnly: true, Secure: secure, SameSite: sameSite, }) } func (a *App) clearAdminRefreshCookie(w http.ResponseWriter) { http.SetCookie(w, &http.Cookie{ Name: adminRefreshCookieKey, Value: "", Path: "/admin/v1/auth", Expires: time.Unix(1, 0), MaxAge: -1, HttpOnly: true, Secure: a.config.Environment == "production", SameSite: func() http.SameSite { if a.config.Environment == "production" { return http.SameSiteNoneMode } return http.SameSiteLaxMode }(), }) } func (a *App) adminRefreshTokenFromRequest(r *http.Request) string { if cookie, err := r.Cookie(adminRefreshCookieKey); err == nil { if token := strings.TrimSpace(cookie.Value); token != "" { return token } } if r.Body == nil || r.ContentLength == 0 { return "" } var req struct { RefreshToken string `json:"refreshToken"` } if decode(r, &req) != nil { return "" } return strings.TrimSpace(req.RefreshToken) } func (a *App) newAdminSession(ctx context.Context, w http.ResponseWriter, r *http.Request, adminID int64, realName string) (map[string]any, error) { refreshToken := randomToken() refreshHash := sha256.Sum256([]byte(refreshToken)) expiresAt := time.Now().Add(adminRefreshTokenTTL) if _, err := a.db.ExecContext(ctx, `INSERT INTO admin_sessions(admin_user_id,refresh_token_hash,user_agent_hash,ip,expires_at) VALUES(?,?,?,?,?)`, adminID, refreshHash[:], adminUserAgentHash(r), clientIP(r), expiresAt); err != nil { return nil, err } accessToken, err := a.token(adminID, "admin", realName, adminAccessTokenTTL) if err != nil { _, _ = a.db.ExecContext(ctx, `UPDATE admin_sessions SET revoked_at=NOW(3) WHERE refresh_token_hash=?`, refreshHash[:]) return nil, err } a.setAdminRefreshCookie(w, refreshToken, expiresAt) result := map[string]any{"accessToken": accessToken, "expiresIn": int(adminAccessTokenTTL.Seconds())} if a.config.Environment != "production" { // HTTP development across localhost/127.0.0.1 cannot use a SameSite=None // cookie. The fallback is sessionStorage-only on the admin client. result["refreshToken"] = refreshToken } return result, nil } func (a *App) rotateAdminSession(ctx context.Context, w http.ResponseWriter, r *http.Request, refreshToken string) (map[string]any, error) { oldHash := sha256.Sum256([]byte(refreshToken)) tx, err := a.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted}) if err != nil { return nil, err } defer func() { _ = tx.Rollback() }() var sessionID, adminID int64 var realName string var status int var expiresAt time.Time err = tx.QueryRowContext(ctx, `SELECT s.id,a.id,a.real_name,a.status,s.expires_at FROM admin_sessions s JOIN admin_users a ON a.id=s.admin_user_id WHERE s.refresh_token_hash=? AND s.revoked_at IS NULL AND s.expires_at>NOW(3) FOR UPDATE`, oldHash[:]).Scan(&sessionID, &adminID, &realName, &status, &expiresAt) if err != nil || status != 1 { return nil, errors.New("刷新令牌无效或已过期") } newRefreshToken := randomToken() newHash := sha256.Sum256([]byte(newRefreshToken)) result, err := tx.ExecContext(ctx, `UPDATE admin_sessions SET refresh_token_hash=?,user_agent_hash=?,ip=?,last_active_at=NOW(3) WHERE id=? AND refresh_token_hash=? AND revoked_at IS NULL`, newHash[:], adminUserAgentHash(r), clientIP(r), sessionID, oldHash[:]) if err != nil { return nil, err } affected, _ := result.RowsAffected() if affected != 1 { return nil, errors.New("刷新令牌已被使用") } if err = tx.Commit(); err != nil { return nil, err } accessToken, err := a.token(adminID, "admin", realName, adminAccessTokenTTL) if err != nil { return nil, err } a.setAdminRefreshCookie(w, newRefreshToken, expiresAt) payload := map[string]any{"accessToken": accessToken, "expiresIn": int(adminAccessTokenTTL.Seconds())} if a.config.Environment != "production" { payload["refreshToken"] = newRefreshToken } return payload, nil } func (a *App) revokeAdminSession(ctx context.Context, refreshToken string) { if strings.TrimSpace(refreshToken) == "" { return } hash := sha256.Sum256([]byte(refreshToken)) _, _ = a.db.ExecContext(ctx, `UPDATE admin_sessions SET revoked_at=NOW(3) WHERE refresh_token_hash=? AND revoked_at IS NULL`, hash[:]) } func (a *App) adminRefresh(w http.ResponseWriter, r *http.Request) { if !a.rateLimit(w, r, "admin_refresh", clientIP(r), 120, 10*time.Minute) { return } refreshToken := a.adminRefreshTokenFromRequest(r) if refreshToken == "" { a.clearAdminRefreshCookie(w) fail(w, http.StatusUnauthorized, 10001, "刷新令牌不存在") return } payload, err := a.rotateAdminSession(r.Context(), w, r, refreshToken) if err != nil { a.clearAdminRefreshCookie(w) fail(w, http.StatusUnauthorized, 10001, err.Error()) return } reply(w, payload) } func (a *App) adminLogout(w http.ResponseWriter, r *http.Request) { a.revokeAdminSession(r.Context(), a.adminRefreshTokenFromRequest(r)) a.clearAdminRefreshCookie(w) reply(w, map[string]bool{"success": true}) }