Implements pansy's local (email + password) authentication and the session layer that OIDC (#5) will also reuse. - store: users.go (create/get-by-id/get-by-email/count) and sessions.go (create/get/touch/delete/delete-expired), scanning the existing 0001 schema. - service: the business-logic seam. auth.go (Register/Login/session lifecycle/Providers) + password.go (argon2id, 64 MiB/1/4, PHC-encoded, constant-time verify) + service.go (Service, clock injection, token hashing). First user is admin; closed registration still allows the bootstrap user; unknown-email and wrong-password are indistinguishable (same error, same argon2 work via a dummy hash). - api: POST /auth/register|login|logout, GET /auth/me|providers, plus a requireAuth middleware that resolves the HttpOnly session cookie (SameSite=Lax, Secure under https) to the actor. Handlers stay thin. - main: wires the service and a periodic expired-session sweep; sessions are also dropped lazily on access. Sliding 30-day expiry. - tests: service (register/login/expiry/renewal/cleanup, password) and api (cookie flow, middleware, validation, providers). Verified end-to-end via curl: register -> me -> restart -> session persists -> logout -> 401. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
181 lines
6.2 KiB
Go
181 lines
6.2 KiB
Go
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")
|
|
}
|
|
}
|