mirror of
https://github.com/warmbly/warmbly.git
synced 2026-08-19 08:01:16 +00:00
248 lines
6.8 KiB
Go
248 lines
6.8 KiB
Go
package middleware
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
"github.com/warmbly/warmbly/internal/app/apikey"
|
|
"github.com/warmbly/warmbly/internal/errx"
|
|
"github.com/warmbly/warmbly/internal/models"
|
|
)
|
|
|
|
const (
|
|
APIKeyIDKey = "api_key_id"
|
|
APIKeyPermissionsKey = "api_key_permissions"
|
|
APIKeyUserIDKey = "api_key_user_id"
|
|
AuthTypeKey = "auth_type"
|
|
AuthTypeJWT = "jwt"
|
|
AuthTypeAPIKey = "api_key"
|
|
)
|
|
|
|
// APIKeyMiddleware accepts only API key auth ("Bearer wmbly_..."). Reserved
|
|
// for endpoints that should never accept browser sessions — none today, but
|
|
// useful if we add API-only routes (e.g. partner integrations).
|
|
func (h *Handler) APIKeyMiddleware() gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
authHeader := c.GetHeader("Authorization")
|
|
|
|
if strings.HasPrefix(authHeader, "Bearer "+apikey.KeyPrefix) {
|
|
key := strings.TrimPrefix(authHeader, "Bearer ")
|
|
h.validateAPIKey(c, key)
|
|
return
|
|
}
|
|
|
|
errx.Handle(c, errx.ErrAuth)
|
|
c.Abort()
|
|
}
|
|
}
|
|
|
|
// CombinedAuthMiddleware accepts either a JWT or an API key. The two paths
|
|
// set the same context keys (UserIDKey, OrganizationIDKey) so downstream
|
|
// handlers don't need to branch on auth_type unless they care about it.
|
|
func (h *Handler) CombinedAuthMiddleware() gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
authHeader := c.GetHeader("Authorization")
|
|
|
|
switch {
|
|
case strings.HasPrefix(authHeader, "Bearer "+apikey.KeyPrefix):
|
|
key := strings.TrimPrefix(authHeader, "Bearer ")
|
|
h.validateAPIKey(c, key)
|
|
case strings.HasPrefix(authHeader, "Bearer "):
|
|
token := strings.TrimPrefix(authHeader, "Bearer ")
|
|
h.validateJWT(c, token)
|
|
default:
|
|
errx.Handle(c, errx.ErrAuth)
|
|
c.Abort()
|
|
}
|
|
}
|
|
}
|
|
|
|
func (h *Handler) validateAPIKey(c *gin.Context, rawKey string) {
|
|
if h.APIKeyService == nil {
|
|
errx.Handle(c, errx.ErrAuth)
|
|
c.Abort()
|
|
return
|
|
}
|
|
|
|
key, xerr := h.APIKeyService.ValidateKey(c.Request.Context(), rawKey)
|
|
if xerr != nil {
|
|
errx.Handle(c, xerr)
|
|
c.Abort()
|
|
return
|
|
}
|
|
|
|
if !h.APIKeyService.ValidateKeyIP(key, c.ClientIP()) {
|
|
errx.Handle(c, errx.New(errx.Forbidden, "IP not allowed for this API key"))
|
|
c.Abort()
|
|
return
|
|
}
|
|
|
|
// Per-key minute-window rate limit. Surfaces rate-limit headers on
|
|
// every API-key request so well-behaved clients can self-throttle
|
|
// before hitting 429. Fails open on cache errors.
|
|
remaining, retryAfter, allowed := h.APIKeyService.CheckAndIncrementRateLimit(c.Request.Context(), key)
|
|
limit := key.RateLimitPerMinute
|
|
if limit <= 0 {
|
|
limit = 60
|
|
}
|
|
c.Header("X-RateLimit-Limit", fmt.Sprintf("%d", limit))
|
|
c.Header("X-RateLimit-Remaining", fmt.Sprintf("%d", remaining))
|
|
c.Header("X-RateLimit-Policy", fmt.Sprintf("%d;w=60", limit))
|
|
if !allowed {
|
|
c.Header("Retry-After", fmt.Sprintf("%d", retryAfter))
|
|
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{
|
|
"error": "rate_limit_exceeded",
|
|
"message": fmt.Sprintf("API key exceeded %d requests per minute", limit),
|
|
"retry_after": retryAfter,
|
|
})
|
|
return
|
|
}
|
|
|
|
c.Set(AuthTypeKey, AuthTypeAPIKey)
|
|
c.Set(APIKeyIDKey, key.ID.String())
|
|
c.Set(APIKeyPermissionsKey, key.Permissions)
|
|
c.Set(UserIDKey, key.UserID.String())
|
|
c.Set(OrganizationIDKey, key.OrganizationID)
|
|
|
|
// UpdateLastUsed is itself fire-and-forget; also remembers the caller
|
|
// IP so the dashboard can show "last called from".
|
|
h.APIKeyService.UpdateLastUsed(c.Request.Context(), key.ID, c.ClientIP())
|
|
|
|
c.Next()
|
|
}
|
|
|
|
func (h *Handler) validateJWT(c *gin.Context, token string) {
|
|
session, err := h.TokenService.ValidateAccessToken(c.Request.Context(), token)
|
|
if err != nil {
|
|
errx.Handle(c, err)
|
|
c.Abort()
|
|
return
|
|
}
|
|
|
|
c.Set(AuthTypeKey, AuthTypeJWT)
|
|
c.Set(UserIDKey, session.UserID.String())
|
|
c.Set(SessionKey, session)
|
|
c.Set(AccessTokenKey, token)
|
|
if session.CurrentOrganizationID != nil {
|
|
c.Set(OrganizationIDKey, *session.CurrentOrganizationID)
|
|
}
|
|
c.Next()
|
|
}
|
|
|
|
// RequireAPIPermission gates a route on a single API permission bit. JWT
|
|
// callers are waved through — for them, the relevant gate is the
|
|
// OrganizationPermission check (RequirePermission / RequireAccess).
|
|
func RequireAPIPermission(perm uint64) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
if c.GetString(AuthTypeKey) != AuthTypeAPIKey {
|
|
c.Next()
|
|
return
|
|
}
|
|
|
|
perms, exists := c.Get(APIKeyPermissionsKey)
|
|
if !exists {
|
|
errx.Handle(c, errx.ErrForbidden)
|
|
c.Abort()
|
|
return
|
|
}
|
|
permissions, ok := perms.(uint64)
|
|
if !ok || !models.HasAPIPermission(permissions, perm) {
|
|
errx.Handle(c, errx.New(errx.Forbidden, "insufficient API key permissions"))
|
|
c.Abort()
|
|
return
|
|
}
|
|
c.Next()
|
|
}
|
|
}
|
|
|
|
// RequireAccess is the dual-auth permission gate. On a JWT request it
|
|
// enforces the caller's organization role (orgPerm); on an API key request
|
|
// it enforces the key's permission bit (apiPerm). Use this on routes that
|
|
// accept both auth types but need an explicit permission.
|
|
func (h *Handler) RequireAccess(orgPerm models.OrganizationPermission, apiPerm uint64) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
switch c.GetString(AuthTypeKey) {
|
|
case AuthTypeAPIKey:
|
|
perms, exists := c.Get(APIKeyPermissionsKey)
|
|
if !exists {
|
|
errx.Handle(c, errx.ErrForbidden)
|
|
c.Abort()
|
|
return
|
|
}
|
|
permissions, ok := perms.(uint64)
|
|
if !ok || !models.HasAPIPermission(permissions, apiPerm) {
|
|
errx.Handle(c, errx.New(errx.Forbidden, "insufficient API key permissions"))
|
|
c.Abort()
|
|
return
|
|
}
|
|
c.Next()
|
|
default:
|
|
// JWT path: defer to the org-permission gate.
|
|
if h.OrganizationService == nil {
|
|
c.Next()
|
|
return
|
|
}
|
|
userID, err := GetUserUUID(c)
|
|
if err != nil {
|
|
errx.JSON(c, errx.ErrUnauthorized)
|
|
c.Abort()
|
|
return
|
|
}
|
|
orgID := GetOrganizationID(c)
|
|
if orgID == nil {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "no organization selected"))
|
|
c.Abort()
|
|
return
|
|
}
|
|
has, xerr := h.OrganizationService.HasPermission(c.Request.Context(), *orgID, userID, orgPerm)
|
|
if xerr != nil {
|
|
errx.JSON(c, xerr)
|
|
c.Abort()
|
|
return
|
|
}
|
|
if !has {
|
|
errx.JSON(c, errx.ErrForbidden)
|
|
c.Abort()
|
|
return
|
|
}
|
|
c.Next()
|
|
}
|
|
}
|
|
}
|
|
|
|
// GetAuthType returns "jwt" or "api_key" (empty if unauthenticated).
|
|
func GetAuthType(c *gin.Context) string {
|
|
return c.GetString(AuthTypeKey)
|
|
}
|
|
|
|
// GetAPIKeyID returns the authenticating API key's ID, or nil when the
|
|
// request came in via JWT (or wasn't authenticated).
|
|
func GetAPIKeyID(c *gin.Context) *uuid.UUID {
|
|
idStr := c.GetString(APIKeyIDKey)
|
|
if idStr == "" {
|
|
return nil
|
|
}
|
|
id, err := uuid.Parse(idStr)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return &id
|
|
}
|
|
|
|
// GetAPIKeyPermissions returns the bitmask granted to the authenticating
|
|
// API key, or 0 if the request was JWT-authenticated.
|
|
func GetAPIKeyPermissions(c *gin.Context) uint64 {
|
|
perms, exists := c.Get(APIKeyPermissionsKey)
|
|
if !exists {
|
|
return 0
|
|
}
|
|
permissions, ok := perms.(uint64)
|
|
if !ok {
|
|
return 0
|
|
}
|
|
return permissions
|
|
}
|