100 lines
3.0 KiB
Go
100 lines
3.0 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"strings"
|
|
)
|
|
|
|
// claimsCtxKey is the context key for a validated Claims set by Authorize.
|
|
type claimsCtxKey struct{}
|
|
|
|
// ClaimsFromContext returns the validated Claims stored by Authorize.
|
|
func ClaimsFromContext(ctx context.Context) (*Claims, bool) {
|
|
claims, ok := ctx.Value(claimsCtxKey{}).(*Claims)
|
|
return claims, ok
|
|
}
|
|
|
|
// Authorize returns an HTTP middleware that requires a valid Bearer JWT whose
|
|
// role satisfies at least one of the required roles. With no required roles it
|
|
// admits any authenticated call. A missing/invalid/expired token yields 401
|
|
// (ErrUnauthorized); an authenticated token with insufficient privilege yields
|
|
// 403 (ErrForbidden). The validated claims are stored in the request context
|
|
// for downstream handlers.
|
|
func (s *JWTService) Authorize(required ...Role) func(http.Handler) http.Handler {
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
token, ok := bearerToken(r)
|
|
if !ok {
|
|
s.log.Debug("missing bearer token")
|
|
writeAuthError(w, http.StatusUnauthorized, ErrUnauthorized)
|
|
return
|
|
}
|
|
claims, err := s.Verify(token)
|
|
if err != nil {
|
|
s.log.Debug("token verify failed", "err", err)
|
|
writeAuthError(w, http.StatusUnauthorized, ErrUnauthorized)
|
|
return
|
|
}
|
|
if !satisfies(claims.Role, required) {
|
|
s.log.Debug("insufficient role", "role", claims.Role)
|
|
writeAuthError(w, http.StatusForbidden, ErrForbidden)
|
|
return
|
|
}
|
|
ctx := context.WithValue(r.Context(), claimsCtxKey{}, claims)
|
|
next.ServeHTTP(w, r.WithContext(ctx))
|
|
})
|
|
}
|
|
}
|
|
|
|
// satisfies reports whether role has enough privilege for at least one of the
|
|
// required roles. An empty required list means any authenticated role passes.
|
|
func satisfies(role Role, required []Role) bool {
|
|
if len(required) == 0 {
|
|
return true
|
|
}
|
|
for _, r := range required {
|
|
if HasRole(role, r) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// bearerToken extracts the token from an Authorization: Bearer header.
|
|
func bearerToken(r *http.Request) (string, bool) {
|
|
const prefix = "Bearer "
|
|
h := r.Header.Get("Authorization")
|
|
if !strings.HasPrefix(h, prefix) {
|
|
return "", false
|
|
}
|
|
token := strings.TrimSpace(h[len(prefix):])
|
|
return token, token != ""
|
|
}
|
|
|
|
// writeAuthError writes an {error:{code,message}} JSON response matching the
|
|
// project's client contract. No token material is ever logged.
|
|
func writeAuthError(w http.ResponseWriter, status int, err error) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"error": map[string]any{
|
|
"code": authErrorCode(status),
|
|
"message": err.Error(),
|
|
},
|
|
})
|
|
}
|
|
|
|
// authErrorCode maps an HTTP status to a stable error code string.
|
|
func authErrorCode(status int) string {
|
|
switch status {
|
|
case http.StatusUnauthorized:
|
|
return "unauthorized"
|
|
case http.StatusForbidden:
|
|
return "forbidden"
|
|
default:
|
|
return http.StatusText(status)
|
|
}
|
|
}
|