147 lines
4.3 KiB
Go
147 lines
4.3 KiB
Go
// Package auth defines authentication and authorization boundaries for GoOmni.
|
|
// It does not choose JWT, sessions, OAuth, or any other credential format.
|
|
package auth
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
|
|
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
|
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
|
)
|
|
|
|
const principalKey = "go-omni.auth.principal"
|
|
|
|
var (
|
|
ErrMissingToken = errors.New("authorization token is missing")
|
|
ErrInvalidToken = errors.New("authorization token is invalid")
|
|
)
|
|
|
|
// Principal is the authenticated identity made available to business handlers.
|
|
// Claims are optional provider-specific values and should not be required by core business code.
|
|
type Principal struct {
|
|
UserID string `json:"userId"`
|
|
TenantID string `json:"tenantId,omitempty"`
|
|
Roles []string `json:"roles,omitempty"`
|
|
Claims map[string]any `json:"claims,omitempty"`
|
|
}
|
|
|
|
func (p Principal) HasRole(role string) bool {
|
|
role = strings.TrimSpace(role)
|
|
if role == "" {
|
|
return false
|
|
}
|
|
for _, candidate := range p.Roles {
|
|
if candidate == role {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Authenticator converts a credential into a provider-neutral Principal.
|
|
type Authenticator interface {
|
|
Authenticate(context.Context, string) (Principal, error)
|
|
}
|
|
|
|
// StaticAuthenticator is intended only for local development and tests.
|
|
type StaticAuthenticator map[string]Principal
|
|
|
|
func (a StaticAuthenticator) Authenticate(_ context.Context, token string) (Principal, error) {
|
|
principal, ok := a[token]
|
|
if !ok {
|
|
return Principal{}, ErrInvalidToken
|
|
}
|
|
return clonePrincipal(principal), nil
|
|
}
|
|
|
|
// FromContext returns the authenticated identity attached by RequireAuth.
|
|
func FromContext(ctx *httpx.Context) (Principal, bool) {
|
|
value, ok := ctx.Get(principalKey)
|
|
if !ok {
|
|
return Principal{}, false
|
|
}
|
|
principal, ok := value.(Principal)
|
|
return principal, ok
|
|
}
|
|
|
|
// RequireAuth extracts a Bearer token and attaches its Principal to the request.
|
|
func RequireAuth(authenticator Authenticator) httpx.Middleware {
|
|
return func(next httpx.Handler) httpx.Handler {
|
|
return func(ctx *httpx.Context) error {
|
|
if authenticator == nil {
|
|
return apierrors.Internal(errors.New("authenticator is not configured"))
|
|
}
|
|
token, err := bearerToken(ctx.Header("Authorization"))
|
|
if err != nil {
|
|
return apierrors.Unauthorized("UNAUTHENTICATED", "authentication is required").WithCause(err)
|
|
}
|
|
principal, err := authenticator.Authenticate(ctx.Request().Context(), token)
|
|
if err != nil || strings.TrimSpace(principal.UserID) == "" {
|
|
if err == nil {
|
|
err = ErrInvalidToken
|
|
}
|
|
return apierrors.Unauthorized("UNAUTHENTICATED", "authentication is required").WithCause(err)
|
|
}
|
|
ctx.Set(principalKey, clonePrincipal(principal))
|
|
return next(ctx)
|
|
}
|
|
}
|
|
}
|
|
|
|
// OptionalAuth attaches a Principal when a valid Bearer token is present.
|
|
// Missing credentials are allowed; malformed or invalid credentials are rejected.
|
|
func OptionalAuth(authenticator Authenticator) httpx.Middleware {
|
|
return func(next httpx.Handler) httpx.Handler {
|
|
return func(ctx *httpx.Context) error {
|
|
if strings.TrimSpace(ctx.Header("Authorization")) == "" {
|
|
return next(ctx)
|
|
}
|
|
return RequireAuth(authenticator)(next)(ctx)
|
|
}
|
|
}
|
|
}
|
|
|
|
// RequireRole checks a Principal attached by RequireAuth.
|
|
func RequireRole(roles ...string) httpx.Middleware {
|
|
return func(next httpx.Handler) httpx.Handler {
|
|
return func(ctx *httpx.Context) error {
|
|
principal, ok := FromContext(ctx)
|
|
if !ok {
|
|
return apierrors.Unauthorized("UNAUTHENTICATED", "authentication is required")
|
|
}
|
|
for _, role := range roles {
|
|
if principal.HasRole(role) {
|
|
return next(ctx)
|
|
}
|
|
}
|
|
return apierrors.Forbidden("FORBIDDEN", "permission denied")
|
|
}
|
|
}
|
|
}
|
|
|
|
func bearerToken(value string) (string, error) {
|
|
parts := strings.Fields(value)
|
|
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") || parts[1] == "" {
|
|
return "", ErrMissingToken
|
|
}
|
|
return parts[1], nil
|
|
}
|
|
|
|
func clonePrincipal(principal Principal) Principal {
|
|
principal.Roles = append([]string(nil), principal.Roles...)
|
|
if principal.Claims != nil {
|
|
principal.Claims = mapsClone(principal.Claims)
|
|
}
|
|
return principal
|
|
}
|
|
|
|
func mapsClone(values map[string]any) map[string]any {
|
|
clone := make(map[string]any, len(values))
|
|
for key, value := range values {
|
|
clone[key] = value
|
|
}
|
|
return clone
|
|
}
|