package api import ( "errors" "log/slog" "net/http" "strings" "time" "github.com/gin-gonic/gin" "gitea.stevedudenhoeffer.com/steve/pansy/internal/domain" "gitea.stevedudenhoeffer.com/steve/pansy/internal/service" ) // sessionCookie is the name of the HttpOnly cookie carrying the raw session token. const sessionCookie = "pansy_session" // actorKey is the gin-context key under which requireAuth stashes the // authenticated *domain.User. const actorKey = "actor" // registerRequest / loginRequest are the JSON bodies for the local-auth // endpoints. gin's binding validates them before the service is called. type registerRequest struct { Email string `json:"email" binding:"required,email"` DisplayName string `json:"displayName" binding:"required"` Password string `json:"password" binding:"required,min=8,max=1024"` } type loginRequest struct { Email string `json:"email" binding:"required,email"` Password string `json:"password" binding:"required"` } // register creates a local account and logs it in (sets the session cookie). func (h *handlers) register(c *gin.Context) { var req registerRequest if err := c.ShouldBindJSON(&req); err != nil { writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "email, display name, and an 8+ character password are required") return } user, err := h.svc.Register(c.Request.Context(), service.RegisterInput{ Email: req.Email, DisplayName: req.DisplayName, Password: req.Password, }) if err != nil { h.writeServiceError(c, err) return } if err := h.startSession(c, user.ID); err != nil { writeAPIError(c, http.StatusInternalServerError, "INTERNAL", "could not start session") return } c.JSON(http.StatusOK, user) } // login verifies credentials and sets the session cookie. func (h *handlers) login(c *gin.Context) { var req loginRequest if err := c.ShouldBindJSON(&req); err != nil { writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "email and password are required") return } user, err := h.svc.Login(c.Request.Context(), req.Email, req.Password) if err != nil { h.writeServiceError(c, err) return } if err := h.startSession(c, user.ID); err != nil { writeAPIError(c, http.StatusInternalServerError, "INTERNAL", "could not start session") return } c.JSON(http.StatusOK, user) } // logout deletes the current session (if any) and clears the cookie. It is // idempotent and never requires a valid session. func (h *handlers) logout(c *gin.Context) { if token, err := c.Cookie(sessionCookie); err == nil && token != "" { if err := h.svc.Logout(c.Request.Context(), token); err != nil { slog.Error("api: logout", "error", err) } } h.clearSessionCookie(c) c.JSON(http.StatusOK, gin.H{"ok": true}) } // me returns the authenticated user. It sits behind requireAuth. func (h *handlers) me(c *gin.Context) { c.JSON(http.StatusOK, mustActor(c)) } // providers reports which login methods to render on the login page. func (h *handlers) providers(c *gin.Context) { c.JSON(http.StatusOK, h.svc.Providers()) } // requireAuth is middleware that rejects requests without a valid session and, // on success, stashes the resolved user in the context. Feature routers added by // later issues (gardens, objects, …) attach this to their protected groups. func (h *handlers) requireAuth() gin.HandlerFunc { return func(c *gin.Context) { token, err := c.Cookie(sessionCookie) if err != nil || token == "" { writeAPIError(c, http.StatusUnauthorized, "UNAUTHENTICATED", "authentication required") c.Abort() return } user, err := h.svc.ResolveSession(c.Request.Context(), token) if err != nil { // Any resolution failure (missing/expired/tampered) is 401, never 404: // the client should log in, not think a resource is gone. writeAPIError(c, http.StatusUnauthorized, "UNAUTHENTICATED", "authentication required") c.Abort() return } c.Set(actorKey, user) c.Next() } } // startSession issues a session and writes the cookie. func (h *handlers) startSession(c *gin.Context, userID int64) error { token, expiresAt, err := h.svc.CreateSession(c.Request.Context(), userID) if err != nil { return err } h.setSessionCookie(c, token, expiresAt) return nil } // cookieSecure reports whether the session cookie should carry the Secure // attribute: only when the instance is served over HTTPS (PANSY_BASE_URL). // Marking it Secure under plain-http local dev would make the browser drop it. func (h *handlers) cookieSecure() bool { return strings.HasPrefix(h.cfg.BaseURL, "https://") } func (h *handlers) setSessionCookie(c *gin.Context, token string, expiresAt time.Time) { maxAge := max(int(time.Until(expiresAt).Seconds()), 1) c.SetSameSite(http.SameSiteLaxMode) c.SetCookie(sessionCookie, token, maxAge, "/", "", h.cookieSecure(), true) } func (h *handlers) clearSessionCookie(c *gin.Context) { c.SetSameSite(http.SameSiteLaxMode) c.SetCookie(sessionCookie, "", -1, "/", "", h.cookieSecure(), true) } // mustActor returns the user stashed by requireAuth. It panics if called on a // route that isn't behind requireAuth — a programming error. func mustActor(c *gin.Context) *domain.User { return c.MustGet(actorKey).(*domain.User) } // writeServiceError maps a service-layer sentinel error to pansy's JSON error // envelope. Login failures never leak which of email/password was wrong. func (h *handlers) writeServiceError(c *gin.Context, err error) { switch { case errors.Is(err, domain.ErrInvalidCredentials): writeAPIError(c, http.StatusUnauthorized, "INVALID_CREDENTIALS", "invalid email or password") case errors.Is(err, domain.ErrEmailTaken): writeAPIError(c, http.StatusConflict, "EMAIL_TAKEN", "an account with that email already exists") case errors.Is(err, domain.ErrRegistrationClosed): writeAPIError(c, http.StatusForbidden, "REGISTRATION_CLOSED", "registration is closed") case errors.Is(err, domain.ErrLocalAuthDisabled): writeAPIError(c, http.StatusForbidden, "LOCAL_AUTH_DISABLED", "local authentication is disabled") case errors.Is(err, domain.ErrInvalidInput): writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "invalid input") default: slog.Error("api: unhandled service error", "error", err) writeAPIError(c, http.StatusInternalServerError, "INTERNAL", "internal error") } }