feat: bootstrap reusable GoOmni framework
This commit is contained in:
145
auth/auth.go
Normal file
145
auth/auth.go
Normal file
@@ -0,0 +1,145 @@
|
||||
// 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
|
||||
Roles []string
|
||||
Claims map[string]any
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user