kereru-forms/backend/internal/api/auth_middleware.go

207 lines
4.6 KiB
Go
Raw Permalink Normal View History

2026-10-23 19:36:02 +00:00
package api
import (
"context"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"kereru.forms/internal/auth"
)
type authUserContextKey struct{}
type accessPermission int
const (
permissionAuthenticated accessPermission = iota
permissionAdmin
permissionView
)
type accessRule struct {
requiresAuth bool
permission accessPermission
}
func authMiddleware(service *auth.Service) gin.HandlerFunc {
return func(c *gin.Context) {
path := normalizeAPIPath(c.Request.URL.Path)
if path == "/auth/me" {
// /auth/me reports authentication state, so a missing or invalid
// token is handled by the endpoint rather than aborting here.
if rawToken, ok := bearerToken(c.GetHeader("Authorization")); ok {
if user, err := service.Authenticate(c.Request.Context(), rawToken); err == nil {
attachAuthUser(c, user)
}
}
c.Next()
return
}
rule := accessRuleFor(c.Request.Method, c.Request.URL.Path, service.RequiresSubmissionAuth())
if !rule.requiresAuth {
c.Next()
return
}
rawToken, ok := bearerToken(c.GetHeader("Authorization"))
if !ok {
abortUnauthenticated(c)
return
}
user, err := service.Authenticate(c.Request.Context(), rawToken)
if err != nil {
abortUnauthenticated(c)
return
}
if rule.permission == permissionAdmin && !user.CanAdmin {
abortForbidden(c)
return
}
if rule.permission == permissionView && !user.CanView {
abortForbidden(c)
return
}
attachAuthUser(c, user)
c.Next()
}
}
func attachAuthUser(c *gin.Context, user auth.User) {
c.Set("auth.user", user)
ctx := context.WithValue(c.Request.Context(), authUserContextKey{}, user)
c.Request = c.Request.WithContext(ctx)
}
// CurrentUser returns the authenticated user attached by authMiddleware.
func CurrentUser(c *gin.Context) (auth.User, bool) {
value, exists := c.Get("auth.user")
if !exists {
return auth.User{}, false
}
user, ok := value.(auth.User)
return user, ok
}
func accessRuleFor(method, requestPath string, requireSubmissionAuth bool) accessRule {
if method == http.MethodOptions {
return accessRule{}
}
path := normalizeAPIPath(requestPath)
if path == "/health" || path == "/auth" || strings.HasPrefix(path, "/auth/") {
return accessRule{}
}
segments := splitAPIPath(path)
if len(segments) == 0 {
return accessRule{}
}
switch segments[0] {
case "forms":
switch {
case len(segments) == 1:
if method == http.MethodPost {
return adminRule()
}
if method == http.MethodGet && requireSubmissionAuth {
return authenticatedRule()
}
case len(segments) == 2:
switch method {
case http.MethodPut, http.MethodDelete:
return adminRule()
case http.MethodGet:
if requireSubmissionAuth {
return authenticatedRule()
}
}
case len(segments) == 3:
switch segments[2] {
case "activate", "inactivate", "versions", "access-codes":
return adminRule()
case "submissions":
if method == http.MethodPost {
if requireSubmissionAuth {
return authenticatedRule()
}
return accessRule{}
}
if method == http.MethodGet {
return viewRule()
}
case "pdf":
if method == http.MethodGet && requireSubmissionAuth {
return authenticatedRule()
}
}
}
case "submissions":
if len(segments) == 2 && method == http.MethodGet {
return viewRule()
}
if len(segments) == 3 && segments[2] == "offline-pdf" && method == http.MethodGet {
return viewRule()
}
}
return accessRule{}
}
func adminRule() accessRule {
return accessRule{requiresAuth: true, permission: permissionAdmin}
}
func viewRule() accessRule {
return accessRule{requiresAuth: true, permission: permissionView}
}
func authenticatedRule() accessRule {
return accessRule{requiresAuth: true, permission: permissionAuthenticated}
}
func normalizeAPIPath(requestPath string) string {
path := requestPath
if path == "/api" {
return "/"
}
if strings.HasPrefix(path, "/api/") {
path = strings.TrimPrefix(path, "/api")
}
return path
}
func splitAPIPath(path string) []string {
trimmed := strings.Trim(path, "/")
if trimmed == "" {
return nil
}
return strings.Split(trimmed, "/")
}
func bearerToken(header string) (string, bool) {
parts := strings.Fields(header)
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
return "", false
}
return parts[1], parts[1] != ""
}
func abortUnauthenticated(c *gin.Context) {
c.Header("WWW-Authenticate", "Bearer")
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": "authentication required",
"code": "unauthenticated",
})
}
func abortForbidden(c *gin.Context) {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"error": "insufficient permissions",
"code": "forbidden",
})
}