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", }) }