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]>
283 lines
7.7 KiB
Go
283 lines
7.7 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) {
|
|
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{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
|
|
}
|