CI / changes (push) Successful in 4s
CI / openapi (push) Successful in 25s
CI / web (push) Successful in 58s
CI / commitlint (push) Skipped
CI / go (push) Successful in 1m3s
CI / bird2 (push) Successful in 16s
CI / release (push) Successful in 4m15s
Updated the authentication logic to prioritize tenant resolution from JWT claims, specifically using `tenants.bgp` or `bgp_tenant_id` as the primary source. If these claims are absent, the system will fallback to the configured `EVOBGP_PORTAL_TENANT_ID`. This change improves the flexibility of tenant management in the authentication process. Additionally, updated related documentation to reflect these changes and added tests to ensure proper functionality.
302 lines
8.1 KiB
Go
302 lines
8.1 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"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, 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) {
|
|
a, ok := ctx.Value(authCtxKey).(Auth)
|
|
return a, ok
|
|
}
|
|
|
|
type apiKeyRecord struct {
|
|
token string
|
|
tenantID string
|
|
role string
|
|
keyID string // set for DB-managed keys (last_used_at)
|
|
}
|
|
|
|
func parseAPIKeysSpec(spec string) []apiKeyRecord {
|
|
spec = strings.TrimSpace(spec)
|
|
if spec == "" {
|
|
return nil
|
|
}
|
|
var out []apiKeyRecord
|
|
for _, part := range strings.Split(spec, ",") {
|
|
part = strings.TrimSpace(part)
|
|
if part == "" {
|
|
continue
|
|
}
|
|
fields := strings.Split(part, "|")
|
|
if len(fields) != 3 {
|
|
continue
|
|
}
|
|
out = append(out, apiKeyRecord{
|
|
token: strings.TrimSpace(fields[0]),
|
|
tenantID: strings.TrimSpace(fields[1]),
|
|
role: strings.TrimSpace(fields[2]),
|
|
})
|
|
}
|
|
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")
|
|
const p = "Bearer "
|
|
if !strings.HasPrefix(h, p) {
|
|
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing or invalid bearer token")
|
|
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")
|
|
return
|
|
}
|
|
if a.APIKeyID != "" {
|
|
go func(id string) { _ = s.store.TouchAPIKeyLastUsed(id) }(a.APIKeyID)
|
|
}
|
|
r = r.WithContext(context.WithValue(r.Context(), authCtxKey, a))
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
func authFromKeyRecord(raw string, rec apiKeyRecord) Auth {
|
|
return Auth{
|
|
Kind: AuthKindAPIKey,
|
|
TenantID: rec.tenantID,
|
|
Role: rec.role,
|
|
Token: raw,
|
|
APIKeyID: rec.keyID,
|
|
}
|
|
}
|
|
|
|
// 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) {
|
|
if raw == "dev" {
|
|
if a, ok := s.devAuth(); ok {
|
|
return a, true
|
|
}
|
|
if rec, ok := s.keyResolver.Lookup(raw); ok {
|
|
return authFromKeyRecord(raw, rec), true
|
|
}
|
|
return Auth{}, false
|
|
}
|
|
rec, ok := s.keyResolver.Lookup(raw)
|
|
if !ok {
|
|
if s.firewallResolver != nil {
|
|
if fw, ok := s.firewallResolver.Lookup(raw); ok {
|
|
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{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) {
|
|
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
|
|
}
|
|
tenantID := tenantIDFromClaims(claims)
|
|
if tenantID == "" {
|
|
tenantID = strings.TrimSpace(s.portalTenantID)
|
|
}
|
|
if tenantID == "" {
|
|
return Auth{}, http.StatusServiceUnavailable, "portal tenant not configured (set bgp tenant in auth-portal App Switcher or EVOBGP_PORTAL_TENANT_ID)", false
|
|
}
|
|
email, _ := claims["email"].(string)
|
|
perms := coerceStringSlice(claims["permissions"])
|
|
isAdmin, _ := claims["is_admin"].(bool)
|
|
return Auth{
|
|
Kind: AuthKindJWT,
|
|
TenantID: tenantID,
|
|
UserID: strings.TrimSpace(sub),
|
|
Email: strings.TrimSpace(email),
|
|
Permissions: perms,
|
|
IsAdmin: isAdmin,
|
|
Token: raw,
|
|
}, 0, "", true
|
|
}
|
|
|
|
// tenantIDFromClaims prefers tenants.bgp, then bgp_tenant_id.
|
|
func tenantIDFromClaims(claims jwt.MapClaims) string {
|
|
if m, ok := claims["tenants"].(map[string]any); ok {
|
|
if v, ok := m["bgp"].(string); ok {
|
|
if tid := strings.TrimSpace(v); tid != "" {
|
|
return tid
|
|
}
|
|
}
|
|
}
|
|
if v, ok := claims["bgp_tenant_id"].(string); ok {
|
|
return strings.TrimSpace(v)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
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{Kind: AuthKindAPIKey, TenantID: tid, Role: "operator", Token: "dev"}, true
|
|
}
|
|
|
|
func roleLevel(role string) int {
|
|
switch strings.ToLower(role) {
|
|
case "viewer":
|
|
return 1
|
|
case "editor":
|
|
return 2
|
|
case "operator":
|
|
return 3
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
// 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")
|
|
return false
|
|
}
|
|
if roleLevel(a.Role) < roleLevel(need) {
|
|
writeProblem(w, http.StatusForbidden, "Forbidden", "insufficient role")
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (s *Server) requireFirewall(w http.ResponseWriter, a Auth) bool {
|
|
if strings.ToLower(a.Role) != "firewall" {
|
|
writeProblem(w, http.StatusForbidden, "Forbidden", "firewall client role required")
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (s *Server) requireNode(w http.ResponseWriter, a Auth) bool {
|
|
if strings.ToLower(a.Role) != "node" {
|
|
writeProblem(w, http.StatusForbidden, "Forbidden", "node role required")
|
|
return false
|
|
}
|
|
return true
|
|
}
|