feat(auth): refresh token 续期(B2) — 不透明哈希+单次旋转+/auth/refresh+/auth/logout
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 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 同步
This commit is contained in:
AskaEth committed 2026-09-08 21:45:00 +08:00
1 parent 7ecb43bd5d
commit 026b27b310
17 files changed
+1237 -13

No files matched your search

+22 -6
View File
@@ -59,11 +59,14 @@ type loginRequest struct {
Password string `json:"password"`
}
// loginResponse is the login success payload.
// loginResponse is the login success payload. RefreshToken is the opaque
// long-lived credential (POST /auth/refresh) added by B2; it is omitted when no
// refresh store is wired so the pre-B2 contract is unchanged.
type loginResponse struct {
Token string `json:"token"`
ExpiresAt int64 `json:"expiresAt"`
Role string `json:"role"`
Token string `json:"token"`
ExpiresAt int64 `json:"expiresAt"`
Role string `json:"role"`
RefreshToken string `json:"refreshToken,omitempty"`
}
// chatRequest is the POST /api/v1/chat body. When Stream is true the gateway
@@ -114,11 +117,24 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
writeError(w, apperr.Wrap(apperr.ErrInternal, "login_failed", "could not sign token"))
return
}
writeJSON(w, http.StatusOK, loginResponse{
resp := loginResponse{
Token: token,
ExpiresAt: time.Now().Add(tokenTTL).Unix(),
Role: string(auth.RoleOwner),
})
}
// Issue the long-lived refresh credential alongside the access token. A store
// failure fails the login rather than silently handing back a login without
// the advertised refresh token; the plaintext is never logged.
if s.refresh != nil {
refreshToken, _, rerr := s.refresh.Issue(r.Context(), req.Username, auth.RoleOwner, s.refreshTTL())
if rerr != nil {
s.log.Error("login issue refresh token", "err", rerr)
writeError(w, apperr.Wrap(apperr.ErrInternal, "login_failed", "could not issue refresh token"))
return
}
resp.RefreshToken = refreshToken
}
writeJSON(w, http.StatusOK, resp)
}
// handleChat runs one synchronous agent turn (or triggers streaming).