184 lines
5.9 KiB
Go
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})
|
|
}
|