Files
Cyrene_v2/internal/services/gateway/auth_refresh.go
T
AskaEth 026b27b310
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(auth): refresh token 续期(B2) — 不透明哈希+单次旋转+/auth/refresh+/auth/logout
- auth/refresh.go: RefreshStore(Issue/Rotate/Revoke/RevokeAll/RevokeByUser), SHA-256哈希256-bit随机串, 单次旋转事务(并发重放fail-closed)
- 迁移0008_refresh_tokens(id/user_id/role/token_hash UNIQUE/expires_at/revoked_at + 索引)
- gateway: POST /v1/auth/refresh(旋转/续期) + /v1/auth/logout(单/全设备); login响应增refreshToken
- config: AUTH_REFRESH_TTL(720h)/AUTH_REFRESH_ROTATE_DISABLED; decode.go 支持time.Duration
- rotate-jwt成功后RevokeAll(响应refreshTokensRevoked), revoke-keys不动refresh
- 14测试+真实二进制e2e, build/vet/test绿+交叉编译
- Docs/api-client v0.25 + api-reference §2.3/2.4 同步
2026-09-08 21:45:00 +08:00

154 lines
5.6 KiB
Go

package gateway
import (
"encoding/json"
"errors"
"io"
"net/http"
"strings"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
)
// defaultRefreshTTL is the refresh-token lifetime used when cfg.Auth.RefreshTTL
// is unset (an undecoded config in tests), matching the AUTH_REFRESH_TTL default.
const defaultRefreshTTL = 720 * time.Hour
// refreshRequest is the POST /api/v1/auth/refresh body.
type refreshRequest struct {
RefreshToken string `json:"refreshToken"`
}
// refreshResponse is the refresh success payload: a fresh access token plus the
// rotated refresh token (omitted when rotation is disabled by config).
type refreshResponse struct {
Token string `json:"token"`
ExpiresAt int64 `json:"expiresAt"`
Role string `json:"role"`
RefreshToken string `json:"refreshToken,omitempty"`
}
// logoutRequest is the POST /api/v1/auth/logout body.
type logoutRequest struct {
RefreshToken string `json:"refreshToken"`
}
// refreshTTL returns the configured refresh-token lifetime, falling back to
// defaultRefreshTTL when the config was not decoded (zero value).
func (s *Server) refreshTTL() time.Duration {
if s.cfg != nil && s.cfg.Auth.RefreshTTL > 0 {
return s.cfg.Auth.RefreshTTL
}
return defaultRefreshTTL
}
// refreshRotate reports whether single-use rotation is enabled. Rotation is the
// default; only an explicit AUTH_REFRESH_ROTATE_DISABLED=true opts out.
func (s *Server) refreshRotate() bool {
return s.cfg == nil || !s.cfg.Auth.RefreshRotateDisabled
}
// handleRefresh exchanges a valid opaque refresh token for a new access token,
// rotating the refresh token by default. It is public (the refresh token is the
// credential) and returns 401 invalid_grant for an unknown, revoked, or expired
// token. A successful rotation is audited; the token itself is never logged.
func (s *Server) handleRefresh(w http.ResponseWriter, r *http.Request) {
var req refreshRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
return
}
if s.refresh == nil {
writeError(w, apperr.Wrap(apperr.ErrInternal, "refresh_unavailable", "refresh token service is not configured"))
return
}
grant, newRefresh, err := s.refresh.Rotate(r.Context(), req.RefreshToken, auth.RotateOptions{
TTL: s.refreshTTL(),
Rotate: s.refreshRotate(),
})
if err != nil {
if errors.Is(err, auth.ErrInvalidGrant) {
writeError(w, apperr.Wrap(apperr.ErrUnauthorized, "invalid_grant", "invalid refresh token"))
return
}
s.log.Error("refresh rotate", "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "refresh_failed", "could not refresh token"))
return
}
token, err := s.auth.Sign(grant.UserID, grant.Role, tokenTTL)
if err != nil {
s.log.Error("refresh sign", "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "refresh_failed", "could not sign token"))
return
}
s.audit(r.Context(), grant.UserID+":"+string(grant.Role), "auth.refresh", "ok")
writeJSON(w, http.StatusOK, refreshResponse{
Token: token,
ExpiresAt: time.Now().Add(tokenTTL).Unix(),
Role: string(grant.Role),
RefreshToken: newRefresh,
})
}
// handleLogout revokes the refresh token in the body. It is public and
// idempotent: an unknown or already revoked token yields {"revoked": false} with
// 200 rather than an error, so a client can always clear its local state.
//
// The body may carry either a refreshToken (revokes that one grant) or nothing,
// in which case the Authorization Bearer access token identifies the account and
// every refresh grant for it is revoked (logout on all devices). A request with
// neither is a 400.
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
var req logoutRequest
// An empty body is valid: it means "no refreshToken", and the account is
// identified by the bearer access token below.
if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
return
}
if s.refresh == nil {
writeError(w, apperr.Wrap(apperr.ErrInternal, "refresh_unavailable", "refresh token service is not configured"))
return
}
if strings.TrimSpace(req.RefreshToken) != "" {
revoked, err := s.refresh.Revoke(r.Context(), req.RefreshToken)
if err != nil {
s.log.Error("logout revoke refresh token", "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "logout_failed", "could not revoke refresh token"))
return
}
writeJSON(w, http.StatusOK, map[string]any{"revoked": revoked})
return
}
access := bearerFromRequest(r)
if access == "" {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "refreshToken or Authorization bearer token is required"))
return
}
claims, err := s.auth.Verify(access)
if err != nil {
writeError(w, apperr.Wrap(apperr.ErrUnauthorized, "invalid_grant", "invalid access token"))
return
}
n, err := s.refresh.RevokeByUser(r.Context(), claims.Sub)
if err != nil {
s.log.Error("logout revoke user refresh tokens", "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "logout_failed", "could not revoke refresh tokens"))
return
}
writeJSON(w, http.StatusOK, map[string]any{"revoked": n > 0, "revokedCount": n})
}
// bearerFromRequest extracts the Bearer credential from the Authorization
// header, returning "" when the header is absent or malformed.
func bearerFromRequest(r *http.Request) string {
const prefix = "Bearer "
h := r.Header.Get("Authorization")
if len(h) <= len(prefix) || !strings.EqualFold(h[:len(prefix)], prefix) {
return ""
}
return strings.TrimSpace(h[len(prefix):])
}