package api import ( "errors" "log/slog" "net/http" "net/url" "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,max=1024"` } // 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 { writeServiceError(c, err) return } h.startSessionAndRespond(c, 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 { writeServiceError(c, err) return } h.startSessionAndRespond(c, 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 and slides the cookie's // lifetime forward in step with the server-side session (otherwise an active // user would be logged out 30 days after login regardless of activity). 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 == "" { abortUnauthenticated(c) return } user, expiresAt, 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. abortUnauthenticated(c) return } c.Set(actorKey, user) // Refresh the client cookie to the session's current expiry so browser and // server slide together. h.setSessionCookie(c, token, expiresAt) c.Next() } } // abortUnauthenticated writes the standard 401 and stops the handler chain. func abortUnauthenticated(c *gin.Context) { writeAPIError(c, http.StatusUnauthorized, "UNAUTHENTICATED", "authentication required") c.Abort() } // csrfGuard rejects state-changing requests whose Origin header doesn't match // the instance's own origin — a defense against login/logout CSRF, which // SameSite=Lax cookies alone do not prevent. It is a no-op unless PANSY_BASE_URL // is set (so it never interferes with the local Vite dev proxy), and it allows // requests with no Origin (non-browser clients like curl, which aren't a CSRF // vector). Safe methods are always allowed. func (h *handlers) csrfGuard() gin.HandlerFunc { var wantHost string if u, err := url.Parse(h.cfg.BaseURL); err == nil { wantHost = u.Host } return func(c *gin.Context) { if wantHost == "" { c.Next() return } switch c.Request.Method { case http.MethodGet, http.MethodHead, http.MethodOptions: c.Next() return } if origin := c.GetHeader("Origin"); origin != "" { if u, err := url.Parse(origin); err != nil || !strings.EqualFold(u.Host, wantHost) { writeAPIError(c, http.StatusForbidden, "CROSS_ORIGIN", "cross-origin request rejected") c.Abort() return } } c.Next() } } // startSession issues a session for the user and writes the session 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 } // startSessionAndRespond issues a session, sets the cookie, and returns the user // as JSON — the shared tail of register and login. func (h *handlers) startSessionAndRespond(c *gin.Context, user *domain.User) { if err := h.startSession(c, user.ID); err != nil { // The account exists and is usable via login; only the auto-login cookie // failed. Log it so the operator can see the underlying DB problem. slog.Error("api: could not start session", "user_id", user.ID, "error", err) writeAPIError(c, http.StatusInternalServerError, "INTERNAL", "could not start session") return } c.JSON(http.StatusOK, user) } // 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) { // Callers only ever pass a future expiry (login/register set now+TTL; // requireAuth uses a session already checked to be unexpired), so this is // always well above the floor; the floor merely avoids emitting a delete // cookie (MaxAge<=0) from a pathological clock skew. 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 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") } }