更新
This commit is contained in:
+183
@@ -0,0 +1,183 @@
|
||||
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})
|
||||
}
|
||||
Reference in New Issue
Block a user