Files
kefu/im/backend/internal/app/admin_sessions.go
T
2026-09-03 08:38:17 +08:00

184 lines
5.9 KiB
Go

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})
}