207 lines
4.6 KiB
Go
207 lines
4.6 KiB
Go
|
|
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",
|
||
|
|
})
|
||
|
|
}
|