refactor(auth): split IAM module and add access/refresh session flow

This commit is contained in:
2026-03-01 21:26:37 +08:00
parent 6a2d2c9724
commit 57c27e9102
13 changed files with 1377 additions and 345 deletions

View File

@@ -0,0 +1,192 @@
package iam
import (
"errors"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
)
type Handler struct {
service *Service
cfg Config
}
func NewHandler(service *Service, cfg Config) *Handler {
return &Handler{service: service, cfg: cfg}
}
func (h *Handler) RegisterRoutes(router *gin.Engine) {
auth := router.Group("/api/v1/auth")
auth.POST("/register", h.register)
auth.POST("/login", h.login)
auth.POST("/refresh", h.refresh)
protected := auth.Group("")
protected.Use(h.service.RequireAccess())
protected.POST("/logout", h.logout)
protected.POST("/logout-all", h.logoutAll)
protected.GET("/sessions", h.listSessions)
protected.DELETE("/sessions/:sid", h.revokeSession)
}
func (h *Handler) register(c *gin.Context) {
var input struct {
Email string `json:"email"`
Password string `json:"password"`
AutoLogin *bool `json:"auto_login"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
autoLogin := true
if input.AutoLogin != nil {
autoLogin = *input.AutoLogin
}
result, refreshToken, err := h.service.Register(c.Request.Context(), input.Email, input.Password, autoLogin, requestMetaFromContext(c))
if err != nil {
h.writeAuthError(c, err, "registration failed")
return
}
if !autoLogin {
c.JSON(http.StatusCreated, gin.H{"email": strings.TrimSpace(strings.ToLower(input.Email))})
return
}
h.setRefreshCookie(c, refreshToken)
c.JSON(http.StatusCreated, result)
}
func (h *Handler) login(c *gin.Context) {
var input struct {
Email string `json:"email"`
Password string `json:"password"`
DeviceInfo string `json:"device_info"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
meta := requestMetaFromContext(c)
meta.DeviceInfo = input.DeviceInfo
result, refreshToken, err := h.service.Login(c.Request.Context(), input.Email, input.Password, meta)
if err != nil {
h.writeAuthError(c, err, "login failed")
return
}
h.setRefreshCookie(c, refreshToken)
c.JSON(http.StatusOK, result)
}
func (h *Handler) refresh(c *gin.Context) {
refreshToken, err := c.Cookie(h.cfg.RefreshCookieName)
if err != nil || strings.TrimSpace(refreshToken) == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing refresh token"})
return
}
result, newRefreshToken, err := h.service.Refresh(c.Request.Context(), refreshToken)
if err != nil {
h.writeAuthError(c, err, "refresh failed")
return
}
h.setRefreshCookie(c, newRefreshToken)
c.JSON(http.StatusOK, result)
}
func (h *Handler) logout(c *gin.Context) {
token := extractBearerToken(c.GetHeader("Authorization"))
if token == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing authorization"})
return
}
if err := h.service.Logout(c.Request.Context(), token); err != nil {
h.writeAuthError(c, err, "logout failed")
return
}
h.clearRefreshCookie(c)
c.Status(http.StatusNoContent)
}
func (h *Handler) logoutAll(c *gin.Context) {
token := extractBearerToken(c.GetHeader("Authorization"))
if token == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing authorization"})
return
}
if err := h.service.LogoutAll(c.Request.Context(), token); err != nil {
h.writeAuthError(c, err, "logout-all failed")
return
}
h.clearRefreshCookie(c)
c.Status(http.StatusNoContent)
}
func (h *Handler) listSessions(c *gin.Context) {
uid := c.GetInt64(ContextUserIDKey)
sessions, err := h.service.ListSessions(c.Request.Context(), uid)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list sessions"})
return
}
c.JSON(http.StatusOK, sessions)
}
func (h *Handler) revokeSession(c *gin.Context) {
uid := c.GetInt64(ContextUserIDKey)
sid := strings.TrimSpace(c.Param("sid"))
if sid == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid sid"})
return
}
if err := h.service.RevokeSession(c.Request.Context(), uid, sid); err != nil {
h.writeAuthError(c, err, "revoke session failed")
return
}
c.Status(http.StatusNoContent)
}
func requestMetaFromContext(c *gin.Context) requestMeta {
return requestMeta{
IP: c.ClientIP(),
UserAgent: c.Request.UserAgent(),
}
}
func (h *Handler) writeAuthError(c *gin.Context, err error, fallback string) {
switch {
case errors.Is(err, errInvalidCredentials):
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
case errors.Is(err, errAlreadyExists):
c.JSON(http.StatusConflict, gin.H{"error": "user already exists"})
case errors.Is(err, errInvalidToken), errors.Is(err, errTokenExpired), errors.Is(err, errUnauthorized), errors.Is(err, errSessionRevoked):
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
case errors.Is(err, errForbidden):
c.JSON(http.StatusForbidden, gin.H{"error": "forbidden"})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": fallback})
}
}
func (h *Handler) setRefreshCookie(c *gin.Context, token string) {
h.applySameSite(c)
maxAge := int(h.cfg.RefreshTTL / time.Second)
c.SetCookie(h.cfg.RefreshCookieName, token, maxAge, h.cfg.CookiePath, h.cfg.CookieDomain, h.cfg.CookieSecure, true)
}
func (h *Handler) clearRefreshCookie(c *gin.Context) {
h.applySameSite(c)
c.SetCookie(h.cfg.RefreshCookieName, "", -1, h.cfg.CookiePath, h.cfg.CookieDomain, h.cfg.CookieSecure, true)
}
func (h *Handler) applySameSite(c *gin.Context) {
switch strings.ToLower(strings.TrimSpace(h.cfg.CookieSameSite)) {
case "strict":
c.SetSameSite(http.SameSiteStrictMode)
case "none":
c.SetSameSite(http.SameSiteNoneMode)
default:
c.SetSameSite(http.SameSiteLaxMode)
}
}