feat(auth): integrate portal JWT for enhanced authentication and authorization
CI / changes (push) Successful in 6s
CI / commitlint (push) Skipped
CI / openapi (push) Successful in 27s
CI / web (push) Successful in 51s
CI / go (push) Successful in 2m19s
CI / bird2 (push) Successful in 13s
CI / release (push) Successful in 4m24s
CI / changes (push) Successful in 6s
CI / commitlint (push) Skipped
CI / openapi (push) Successful in 27s
CI / web (push) Successful in 51s
CI / go (push) Successful in 2m19s
CI / bird2 (push) Successful in 13s
CI / release (push) Successful in 4m24s
Added support for portal JWT authentication, enabling single sign-on (SSO) capabilities. Updated the application to handle JWT claims for user permissions and roles, enhancing security and access control. Refactored relevant components and API routes to accommodate the new authentication flow, ensuring a seamless user experience. Updated documentation to reflect the new authentication requirements and configurations. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
+128
-7
@@ -6,18 +6,32 @@ import (
|
||||
"strings"
|
||||
|
||||
"evobgp/internal/authkey"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
type ctxKey int
|
||||
|
||||
const authCtxKey ctxKey = 1
|
||||
|
||||
// Auth kinds distinguish API key sessions from portal JWT sessions.
|
||||
const (
|
||||
AuthKindAPIKey = "apikey"
|
||||
AuthKindJWT = "jwt"
|
||||
)
|
||||
|
||||
// Auth holds resolved API identity for a request.
|
||||
type Auth struct {
|
||||
TenantID string
|
||||
Role string // viewer, editor, operator, node
|
||||
Role string // viewer, editor, operator, node, firewall (apikeys only)
|
||||
Token string
|
||||
APIKeyID string // non-empty for DB-managed keys
|
||||
// Portal / dual-auth fields (empty for API keys unless noted).
|
||||
Kind string // "apikey" | "jwt"
|
||||
UserID string // JWT sub
|
||||
Email string // JWT email claim
|
||||
Permissions []string // JWT permissions claim (bgp:*)
|
||||
IsAdmin bool // JWT is_admin claim
|
||||
}
|
||||
|
||||
func authFromContext(ctx context.Context) (Auth, bool) {
|
||||
@@ -56,6 +70,23 @@ func parseAPIKeysSpec(spec string) []apiKeyRecord {
|
||||
return out
|
||||
}
|
||||
|
||||
// looksLikeJWT reports whether raw is a compact JWS (three dot-separated segments, non-empty).
|
||||
func looksLikeJWT(raw string) bool {
|
||||
if raw == "" {
|
||||
return false
|
||||
}
|
||||
parts := strings.Split(raw, ".")
|
||||
if len(parts) != 3 {
|
||||
return false
|
||||
}
|
||||
for _, p := range parts {
|
||||
if strings.TrimSpace(p) == "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Server) authMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
h := r.Header.Get("Authorization")
|
||||
@@ -65,6 +96,16 @@ func (s *Server) authMiddleware(next http.Handler) http.Handler {
|
||||
return
|
||||
}
|
||||
raw := strings.TrimSpace(strings.TrimPrefix(h, p))
|
||||
if looksLikeJWT(raw) && strings.TrimSpace(s.jwtSecret) != "" {
|
||||
a, status, msg, ok := s.resolveJWT(raw)
|
||||
if !ok {
|
||||
writeProblem(w, status, http.StatusText(status), msg)
|
||||
return
|
||||
}
|
||||
r = r.WithContext(context.WithValue(r.Context(), authCtxKey, a))
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
a, ok := s.resolveAuth(raw)
|
||||
if !ok {
|
||||
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "unknown api key")
|
||||
@@ -79,10 +120,16 @@ func (s *Server) authMiddleware(next http.Handler) http.Handler {
|
||||
}
|
||||
|
||||
func authFromKeyRecord(raw string, rec apiKeyRecord) Auth {
|
||||
return Auth{TenantID: rec.tenantID, Role: rec.role, Token: raw, APIKeyID: rec.keyID}
|
||||
return Auth{
|
||||
Kind: AuthKindAPIKey,
|
||||
TenantID: rec.tenantID,
|
||||
Role: rec.role,
|
||||
Token: raw,
|
||||
APIKeyID: rec.keyID,
|
||||
}
|
||||
}
|
||||
|
||||
// resolveAuth maps a bearer token to tenant identity.
|
||||
// resolveAuth maps a bearer token to tenant identity (API key path).
|
||||
// For the literal token "dev", the demo shortcut (devAuth) takes precedence when demo-seed
|
||||
// is available; env/DB mapping is used only when demo tenant is absent.
|
||||
func (s *Server) resolveAuth(raw string) (Auth, bool) {
|
||||
@@ -99,23 +146,96 @@ func (s *Server) resolveAuth(raw string) (Auth, bool) {
|
||||
if !ok {
|
||||
if s.firewallResolver != nil {
|
||||
if fw, ok := s.firewallResolver.Lookup(raw); ok {
|
||||
return Auth{TenantID: fw.tenantID, Role: "firewall", Token: raw, APIKeyID: fw.clientID}, true
|
||||
return Auth{Kind: AuthKindAPIKey, TenantID: fw.tenantID, Role: "firewall", Token: raw, APIKeyID: fw.clientID}, true
|
||||
}
|
||||
}
|
||||
if client, err := s.store.LookupFirewallClientByTokenHash(authkey.HashToken(raw)); err == nil {
|
||||
return Auth{TenantID: client.TenantID, Role: "firewall", Token: raw, APIKeyID: client.ID}, true
|
||||
return Auth{Kind: AuthKindAPIKey, TenantID: client.TenantID, Role: "firewall", Token: raw, APIKeyID: client.ID}, true
|
||||
}
|
||||
return Auth{}, false
|
||||
}
|
||||
return authFromKeyRecord(raw, rec), true
|
||||
}
|
||||
|
||||
// resolveJWT parses and validates a portal HS256 token, returning an Auth on success.
|
||||
// Returns (auth, status, detail, ok). status/detail are used when ok=false.
|
||||
func (s *Server) resolveJWT(raw string) (Auth, int, string, bool) {
|
||||
if strings.TrimSpace(s.portalTenantID) == "" {
|
||||
return Auth{}, http.StatusServiceUnavailable, "portal tenant not configured (EVOBGP_PORTAL_TENANT_ID)", false
|
||||
}
|
||||
tok, err := jwt.Parse(raw, func(t *jwt.Token) (any, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, jwt.ErrSignatureInvalid
|
||||
}
|
||||
return []byte(s.jwtSecret), nil
|
||||
}, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}))
|
||||
if err != nil || tok == nil || !tok.Valid {
|
||||
return Auth{}, http.StatusUnauthorized, "invalid jwt", false
|
||||
}
|
||||
claims, ok := tok.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
return Auth{}, http.StatusUnauthorized, "invalid jwt claims", false
|
||||
}
|
||||
if iss := strings.TrimSpace(s.authIssuer); iss != "" {
|
||||
got, _ := claims["iss"].(string)
|
||||
if strings.TrimSpace(got) != iss {
|
||||
return Auth{}, http.StatusUnauthorized, "jwt issuer mismatch", false
|
||||
}
|
||||
}
|
||||
apps := coerceStringSlice(claims["apps"])
|
||||
if !containsFold(apps, "bgp") {
|
||||
return Auth{}, http.StatusForbidden, "jwt does not grant access to bgp app", false
|
||||
}
|
||||
sub, _ := claims["sub"].(string)
|
||||
if strings.TrimSpace(sub) == "" {
|
||||
return Auth{}, http.StatusUnauthorized, "jwt missing sub", false
|
||||
}
|
||||
email, _ := claims["email"].(string)
|
||||
perms := coerceStringSlice(claims["permissions"])
|
||||
isAdmin, _ := claims["is_admin"].(bool)
|
||||
return Auth{
|
||||
Kind: AuthKindJWT,
|
||||
TenantID: s.portalTenantID,
|
||||
UserID: strings.TrimSpace(sub),
|
||||
Email: strings.TrimSpace(email),
|
||||
Permissions: perms,
|
||||
IsAdmin: isAdmin,
|
||||
Token: raw,
|
||||
}, 0, "", true
|
||||
}
|
||||
|
||||
func coerceStringSlice(v any) []string {
|
||||
switch t := v.(type) {
|
||||
case []string:
|
||||
return t
|
||||
case []any:
|
||||
out := make([]string, 0, len(t))
|
||||
for _, x := range t {
|
||||
if s, ok := x.(string); ok {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func containsFold(items []string, needle string) bool {
|
||||
for _, x := range items {
|
||||
if strings.EqualFold(strings.TrimSpace(x), needle) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *Server) devAuth() (Auth, bool) {
|
||||
tid, _, _, _, _ := s.store.DemoIDs()
|
||||
if tid == "" {
|
||||
return Auth{}, false
|
||||
}
|
||||
return Auth{TenantID: tid, Role: "operator", Token: "dev"}, true
|
||||
return Auth{Kind: AuthKindAPIKey, TenantID: tid, Role: "operator", Token: "dev"}, true
|
||||
}
|
||||
|
||||
func roleLevel(role string) int {
|
||||
@@ -131,7 +251,8 @@ func roleLevel(role string) int {
|
||||
}
|
||||
}
|
||||
|
||||
// requireAtLeast rejects node role and enforces viewer/editor/operator ladder.
|
||||
// requireAtLeast rejects node/firewall roles and enforces viewer/editor/operator ladder for API keys.
|
||||
// New code should call requirePerm which supports JWT permissions in addition to API-key roles.
|
||||
func (s *Server) requireAtLeast(w http.ResponseWriter, a Auth, need string) bool {
|
||||
if strings.ToLower(a.Role) == "node" {
|
||||
writeProblem(w, http.StatusForbidden, "Forbidden", "node role cannot access this resource")
|
||||
|
||||
Reference in New Issue
Block a user