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) } }