Files
Cyrene_v2/internal/services/gateway/auth_refresh.go
T
AskaEth 074ffc4ebf
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
docs(gateway): gateway 剩余文件注释改中文(逐行 1:1,代码零字节改动)
主人 2026-10-03 拍板「注释一律中文」;gateway 至此整包注释中文化完成(本会话共 3 批:
已推库 16 个文件 + 本批剩余文件)。
校验:机械校验(非注释改动行)= 空;剥注释后代码文本逐行比对全等(不一致文件数 0);
全树 go test ./... 58 包 ok / 0 FAIL;交叉编译绿。
2026-10-03 22:28:25 +08:00

154 lines
5.5 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 是 cfg.Auth.RefreshTTL 未设置时(测试中未解码的
// 配置)使用的 refresh token 生命周期,与 AUTH_REFRESH_TTL 默认值一致。
const defaultRefreshTTL = 720 * time.Hour
// refreshRequest 是 POST /api/v1/auth/refresh 的请求体。
type refreshRequest struct {
RefreshToken string `json:"refreshToken"`
}
// refreshResponse 是刷新成功的载荷:新的访问令牌加上
// 轮换后的 refresh token(配置禁用轮换时省略)。
type refreshResponse struct {
Token string `json:"token"`
ExpiresAt int64 `json:"expiresAt"`
Role string `json:"role"`
RefreshToken string `json:"refreshToken,omitempty"`
}
// logoutRequest 是 POST /api/v1/auth/logout 的请求体。
type logoutRequest struct {
RefreshToken string `json:"refreshToken"`
}
// refreshTTL 返回已配置的 refresh token 生命周期,配置未解码
// (零值)时回退到 defaultRefreshTTL。
func (s *Server) refreshTTL() time.Duration {
if s.cfg != nil && s.cfg.Auth.RefreshTTL > 0 {
return s.cfg.Auth.RefreshTTL
}
return defaultRefreshTTL
}
// refreshRotate 报告是否启用一次性轮换。轮换是
// 默认;只有显式 AUTH_REFRESH_ROTATE_DISABLED=true 才选择退出。
func (s *Server) refreshRotate() bool {
return s.cfg == nil || !s.cfg.Auth.RefreshRotateDisabled
}
// handleRefresh 用有效的非透明 refresh token 换取新的访问令牌,
// 默认轮换 refresh token。它是公开的(refresh token 就是
// 凭据),对未知、已吊销或已过期的 token 返回 401 invalid_grant。
// 成功的轮换会被审计;token 本身绝不记日志。
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 吊销请求体中的 refresh token。它公开且
// 幂等:未知或已吊销的 token 返回 {"revoked": false} 与
// 200 而不是错误,因此客户端总能清理其本地状态。
//
// 请求体可以携带 refreshToken(只吊销那一份授权),也可以不携带,
// 此时 Authorization Bearer 访问令牌标识账号,该账号的
// 每一份 refresh 授权都被吊销(全设备登出)。两者都
// 没有的请求是 400。
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
var req logoutRequest
// 空请求体是合法的:它意味着「无 refreshToken」,账号由
// 下面的 bearer 访问令牌标识。
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 从 Authorization 头提取 Bearer 凭据,
// 头缺失或畸形时返回 ""。
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):])
}