authkit initial
This commit is contained in:
parent
5173b0a43d
commit
134393fbca
43 changed files with 5188 additions and 1 deletions
71
middleware/authz.go
Normal file
71
middleware/authz.go
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.juancwu.dev/juancwu/authkit"
|
||||
)
|
||||
|
||||
// authzGuard wraps the common pattern of "look up the Principal, run a
|
||||
// predicate, succeed or 403". onForbidden defaults to JSON 403.
|
||||
func authzGuard(onForbidden func(http.ResponseWriter, *http.Request, error), pred func(*authkit.Principal) bool) func(http.Handler) http.Handler {
|
||||
if onForbidden == nil {
|
||||
onForbidden = defaultJSONError(http.StatusForbidden)
|
||||
}
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
p, ok := PrincipalFrom(r.Context())
|
||||
if !ok {
|
||||
// No auth middleware ran upstream; treat as forbidden
|
||||
// rather than crashing — composition is the caller's
|
||||
// responsibility but a 403 is the safer default.
|
||||
onForbidden(w, r, authkit.ErrPermissionDenied)
|
||||
return
|
||||
}
|
||||
if !pred(p) {
|
||||
onForbidden(w, r, authkit.ErrPermissionDenied)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// RequireRole permits requests whose Principal holds the named role.
|
||||
func RequireRole(name string, onForbidden ...func(http.ResponseWriter, *http.Request, error)) func(http.Handler) http.Handler {
|
||||
return authzGuard(firstOrNil(onForbidden), func(p *authkit.Principal) bool {
|
||||
return p.HasRole(name)
|
||||
})
|
||||
}
|
||||
|
||||
// RequireAnyRole permits requests whose Principal holds at least one of the
|
||||
// named roles.
|
||||
func RequireAnyRole(names []string, onForbidden ...func(http.ResponseWriter, *http.Request, error)) func(http.Handler) http.Handler {
|
||||
return authzGuard(firstOrNil(onForbidden), func(p *authkit.Principal) bool {
|
||||
return p.HasAnyRole(names...)
|
||||
})
|
||||
}
|
||||
|
||||
// RequirePermission permits requests whose Principal holds the named
|
||||
// permission (resolved via roles at auth time).
|
||||
func RequirePermission(name string, onForbidden ...func(http.ResponseWriter, *http.Request, error)) func(http.Handler) http.Handler {
|
||||
return authzGuard(firstOrNil(onForbidden), func(p *authkit.Principal) bool {
|
||||
return p.HasPermission(name)
|
||||
})
|
||||
}
|
||||
|
||||
// RequireAbility permits requests whose Principal carries the named ability.
|
||||
// Abilities are populated only for API-key authentication; this middleware
|
||||
// will reject session/JWT-authenticated requests by design.
|
||||
func RequireAbility(name string, onForbidden ...func(http.ResponseWriter, *http.Request, error)) func(http.Handler) http.Handler {
|
||||
return authzGuard(firstOrNil(onForbidden), func(p *authkit.Principal) bool {
|
||||
return p.HasAbility(name)
|
||||
})
|
||||
}
|
||||
|
||||
func firstOrNil(s []func(http.ResponseWriter, *http.Request, error)) func(http.ResponseWriter, *http.Request, error) {
|
||||
if len(s) == 0 {
|
||||
return nil
|
||||
}
|
||||
return s[0]
|
||||
}
|
||||
39
middleware/context.go
Normal file
39
middleware/context.go
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
// Package middleware provides framework-neutral HTTP middleware for authkit.
|
||||
// Every middleware function returns the standard func(http.Handler)
|
||||
// http.Handler type, so it composes with lightmux's Use/Group/Handle as well
|
||||
// as any net/http stack that uses the same signature.
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"git.juancwu.dev/juancwu/authkit"
|
||||
)
|
||||
|
||||
// principalKey is an unexported context key. Using a distinct empty struct
|
||||
// type guarantees no collision with caller-defined keys.
|
||||
type principalKey struct{}
|
||||
|
||||
// withPrincipal stashes p on the request context for downstream handlers.
|
||||
func withPrincipal(ctx context.Context, p *authkit.Principal) context.Context {
|
||||
return context.WithValue(ctx, principalKey{}, p)
|
||||
}
|
||||
|
||||
// PrincipalFrom retrieves the authenticated Principal placed by RequireSession,
|
||||
// RequireJWT, or RequireAPIKey. The boolean is false if no auth middleware
|
||||
// ran for this request.
|
||||
func PrincipalFrom(ctx context.Context) (*authkit.Principal, bool) {
|
||||
p, ok := ctx.Value(principalKey{}).(*authkit.Principal)
|
||||
return p, ok
|
||||
}
|
||||
|
||||
// MustPrincipal panics if no Principal is on the context. Use only on
|
||||
// handlers known to be behind a Require* middleware.
|
||||
func MustPrincipal(r *http.Request) *authkit.Principal {
|
||||
p, ok := PrincipalFrom(r.Context())
|
||||
if !ok {
|
||||
panic("authkit/middleware: no principal on request context")
|
||||
}
|
||||
return p
|
||||
}
|
||||
138
middleware/middleware.go
Normal file
138
middleware/middleware.go
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
package middleware
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"git.juancwu.dev/juancwu/authkit"
|
||||
)
|
||||
|
||||
// Options configures auth middleware. Auth is required; the rest fall back
|
||||
// to defaults: BearerExtractor, a JSON 401 on auth failure, and a JSON 403
|
||||
// on authz failure.
|
||||
type Options struct {
|
||||
Auth *authkit.Auth
|
||||
Extractor authkit.Extractor
|
||||
OnUnauth func(w http.ResponseWriter, r *http.Request, err error)
|
||||
OnForbidden func(w http.ResponseWriter, r *http.Request, err error)
|
||||
}
|
||||
|
||||
func (o Options) extractor() authkit.Extractor {
|
||||
if o.Extractor != nil {
|
||||
return o.Extractor
|
||||
}
|
||||
return authkit.BearerExtractor()
|
||||
}
|
||||
|
||||
func (o Options) onUnauth() func(w http.ResponseWriter, r *http.Request, err error) {
|
||||
if o.OnUnauth != nil {
|
||||
return o.OnUnauth
|
||||
}
|
||||
return defaultJSONError(http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
func (o Options) onForbidden() func(w http.ResponseWriter, r *http.Request, err error) {
|
||||
if o.OnForbidden != nil {
|
||||
return o.OnForbidden
|
||||
}
|
||||
return defaultJSONError(http.StatusForbidden)
|
||||
}
|
||||
|
||||
func defaultJSONError(status int) func(w http.ResponseWriter, r *http.Request, err error) {
|
||||
return func(w http.ResponseWriter, _ *http.Request, err error) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": http.StatusText(status),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// RequireSession authenticates the request via an opaque session string. The
|
||||
// extractor is consulted first; if no extractor is set the default Bearer
|
||||
// extractor is used. For cookie-based session lookup, set
|
||||
// Options.Extractor = authkit.CookieExtractor(cfg.SessionCookieName).
|
||||
func RequireSession(opts Options) func(http.Handler) http.Handler {
|
||||
return requireWith(opts, func(r *http.Request, raw string) (*authkit.Principal, error) {
|
||||
return opts.Auth.AuthenticateSession(r.Context(), raw)
|
||||
})
|
||||
}
|
||||
|
||||
// RequireJWT authenticates the request via an HS256 JWT.
|
||||
func RequireJWT(opts Options) func(http.Handler) http.Handler {
|
||||
return requireWith(opts, func(r *http.Request, raw string) (*authkit.Principal, error) {
|
||||
return opts.Auth.AuthenticateJWT(r.Context(), raw)
|
||||
})
|
||||
}
|
||||
|
||||
// RequireAPIKey authenticates the request via an opaque API secret.
|
||||
func RequireAPIKey(opts Options) func(http.Handler) http.Handler {
|
||||
return requireWith(opts, func(r *http.Request, raw string) (*authkit.Principal, error) {
|
||||
return opts.Auth.AuthenticateAPIKey(r.Context(), raw)
|
||||
})
|
||||
}
|
||||
|
||||
// RequireAny tries each method in order until one succeeds. Useful for routes
|
||||
// that accept either a session cookie or an API key.
|
||||
func RequireAny(opts Options, methods ...authkit.AuthMethod) func(http.Handler) http.Handler {
|
||||
if len(methods) == 0 {
|
||||
methods = []authkit.AuthMethod{
|
||||
authkit.AuthMethodSession,
|
||||
authkit.AuthMethodJWT,
|
||||
authkit.AuthMethodAPIKey,
|
||||
}
|
||||
}
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
raw, ok := opts.extractor()(r)
|
||||
if !ok || raw == "" {
|
||||
opts.onUnauth()(w, r, authkit.ErrSessionInvalid)
|
||||
return
|
||||
}
|
||||
var (
|
||||
p *authkit.Principal
|
||||
lastErr error
|
||||
)
|
||||
for _, m := range methods {
|
||||
switch m {
|
||||
case authkit.AuthMethodSession:
|
||||
p, lastErr = opts.Auth.AuthenticateSession(r.Context(), raw)
|
||||
case authkit.AuthMethodJWT:
|
||||
p, lastErr = opts.Auth.AuthenticateJWT(r.Context(), raw)
|
||||
case authkit.AuthMethodAPIKey:
|
||||
p, lastErr = opts.Auth.AuthenticateAPIKey(r.Context(), raw)
|
||||
}
|
||||
if lastErr == nil && p != nil {
|
||||
next.ServeHTTP(w, r.WithContext(withPrincipal(r.Context(), p)))
|
||||
return
|
||||
}
|
||||
}
|
||||
opts.onUnauth()(w, r, lastErr)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// requireWith is the shared scaffolding for the single-method Require*
|
||||
// middlewares.
|
||||
func requireWith(opts Options, authn func(r *http.Request, raw string) (*authkit.Principal, error)) func(http.Handler) http.Handler {
|
||||
if opts.Auth == nil {
|
||||
panic("authkit/middleware: Options.Auth is required")
|
||||
}
|
||||
extractor := opts.extractor()
|
||||
onUnauth := opts.onUnauth()
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
raw, ok := extractor(r)
|
||||
if !ok || raw == "" {
|
||||
onUnauth(w, r, authkit.ErrSessionInvalid)
|
||||
return
|
||||
}
|
||||
p, err := authn(r, raw)
|
||||
if err != nil {
|
||||
onUnauth(w, r, err)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(withPrincipal(r.Context(), p)))
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue