package api import ( "crypto/subtle" "net" "net/http" "sync" "time" "cpa-usage-keeper/internal/auth" "github.com/gin-gonic/gin" ) const sessionCookieName = "cpa_usage_keeper_session" const maxFailedLoginAttempts = 5 type AuthConfig struct { Enabled bool LoginPassword string SessionTTL time.Duration BasePath string } type authHandler struct { config AuthConfig sessions *auth.SessionManager mu sync.Mutex failedAttempts map[string]int } type loginRequest struct { Password string `json:"password"` } type sessionResponse struct { Authenticated bool `json:"authenticated"` } func NewAuthHandler(config AuthConfig, sessions *auth.SessionManager) *authHandler { return &authHandler{config: config, sessions: sessions, failedAttempts: make(map[string]int)} } func (h *authHandler) registerRoutes(router gin.IRoutes) { router.GET("/session", h.getSession) router.POST("/login", h.login) router.POST("/logout", h.logout) } func (h *authHandler) middleware() gin.HandlerFunc { return func(c *gin.Context) { if h == nil || !h.config.Enabled { c.Next() return } if h.sessions == nil { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "authentication required"}) return } token, err := c.Cookie(sessionCookieName) if err != nil || !h.sessions.Validate(token) { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "authentication required"}) return } c.Next() } } func (h *authHandler) getSession(c *gin.Context) { if h == nil || !h.config.Enabled { c.JSON(http.StatusOK, sessionResponse{Authenticated: true}) return } if h.sessions == nil { c.JSON(http.StatusOK, sessionResponse{Authenticated: false}) return } token, err := c.Cookie(sessionCookieName) if err != nil { c.JSON(http.StatusOK, sessionResponse{Authenticated: false}) return } c.JSON(http.StatusOK, sessionResponse{Authenticated: h.sessions.Validate(token)}) } func (h *authHandler) login(c *gin.Context) { if h == nil || !h.config.Enabled { c.Status(http.StatusNoContent) return } if h.sessions == nil { writeInternalError(c, "session manager is not configured", nil) return } var request loginRequest if err := c.ShouldBindJSON(&request); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } clientKey := loginClientKey(c) passwordMatches := subtle.ConstantTimeCompare([]byte(request.Password), []byte(h.config.LoginPassword)) == 1 if h.tooManyFailedAttempts(clientKey) && !passwordMatches { c.JSON(http.StatusTooManyRequests, gin.H{"error": "too many failed login attempts"}) return } if !passwordMatches { h.recordFailedAttempt(clientKey) c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid password"}) return } h.clearFailedAttempts(clientKey) token, expiresAt, err := h.sessions.Create() if err != nil { writeInternalError(c, "create auth session failed", err) return } secure := c.Request.TLS != nil || c.GetHeader("X-Forwarded-Proto") == "https" cookiePath := h.config.BasePath if cookiePath == "" { cookiePath = "/" } http.SetCookie(c.Writer, &http.Cookie{ Name: sessionCookieName, Value: token, Path: cookiePath, HttpOnly: true, Secure: secure, SameSite: http.SameSiteLaxMode, Expires: expiresAt, MaxAge: int(time.Until(expiresAt).Seconds()), }) c.Status(http.StatusNoContent) } func (h *authHandler) logout(c *gin.Context) { if h == nil || !h.config.Enabled { c.Status(http.StatusNoContent) return } if h.sessions != nil { if token, err := c.Cookie(sessionCookieName); err == nil { h.sessions.Delete(token) } } clearSessionCookie(c, h.config.BasePath) c.Status(http.StatusNoContent) } func (h *authHandler) tooManyFailedAttempts(key string) bool { h.mu.Lock() defer h.mu.Unlock() return h.failedAttempts[key] >= maxFailedLoginAttempts } func (h *authHandler) recordFailedAttempt(key string) { h.mu.Lock() defer h.mu.Unlock() h.failedAttempts[key]++ } func (h *authHandler) clearFailedAttempts(key string) { h.mu.Lock() defer h.mu.Unlock() delete(h.failedAttempts, key) } func loginClientKey(c *gin.Context) string { host, _, err := net.SplitHostPort(c.Request.RemoteAddr) if err == nil && host != "" { return host } return c.ClientIP() } func clearSessionCookie(c *gin.Context, basePath string) { cookiePath := basePath if cookiePath == "" { cookiePath = "/" } http.SetCookie(c.Writer, &http.Cookie{ Name: sessionCookieName, Value: "", Path: cookiePath, HttpOnly: true, SameSite: http.SameSiteLaxMode, Expires: time.Unix(0, 0), MaxAge: -1, }) }