Compare commits

...
2 Commits
Author SHA1 Message Date
Denozordec b28ad88b22 feat(auth): enhance tenant resolution in JWT handling
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.
2026-07-19 01:03:33 +07:00
Denozordec b871d62de6 feat(auth): add environment variables for portal SSO integration
Introduced new environment variables for the authentication portal in the production and microvps configurations. Updated the documentation to clarify the necessity of passing these variables to the evobgp-all service. This change enhances the authentication flow by enabling single sign-on (SSO) capabilities through JWT, ensuring a more secure and streamlined user experience.
2026-07-19 00:55:35 +07:00
10 changed files with 151 additions and 7 deletions
+1
View File
@@ -255,6 +255,7 @@ export function can(required: string): boolean {
const claims = getClaims()
if (!claims) return false
if (!claims.apps.includes(CURRENT_APP_ID)) return false
if (claims.is_admin) return true
return hasPermission(claims.permissions, required)
}
+7
View File
@@ -15,3 +15,10 @@ WEBUI_DOMAIN=bgp.example.com
WEBUI_IP_WHITELIST=203.0.113.10/32
LETSENCRYPT_EMAIL=admin@example.com
CF_DNS_API_TOKEN=
# Auth-portal SSO → контейнер evobgp-all (не VITE_* — они только для build web)
AUTH_REQUIRED=true
AUTH_JWT_SECRET=
AUTH_ISSUER=https://auth.shnt.top
AUTH_PORTAL_URL=https://auth.shnt.top
EVOBGP_PORTAL_TENANT_ID=
@@ -27,3 +27,16 @@ AUTO_UPDATE_INTERVAL_SEC=300
AUTO_UPDATE_SERVICES=evobgp-all,evobgp-web
# Защищенные сервисы, которые updater никогда не перезапускает
AUTO_UPDATE_PROTECTED_SERVICES=bird2
# ---------------------------------------------------------------------------
# Auth-portal SSO (прокидывается в контейнер evobgp-all)
# VITE_* в runtime .env НЕ влияют на уже собранный web-образ —
# UI читает GET /v1/auth/config с API (AUTH_REQUIRED / AUTH_PORTAL_URL).
# ---------------------------------------------------------------------------
AUTH_REQUIRED=true
AUTH_JWT_SECRET=
AUTH_ISSUER=https://auth.shnt.top
AUTH_PORTAL_URL=https://auth.shnt.top
# UUID tenant из БД (обязателен для JWT). При EVOBGP_SEED_DEMO=1 смотрите лог
# старта evobgp-all / SELECT id FROM tenant LIMIT 1;
EVOBGP_PORTAL_TENANT_ID=
@@ -136,6 +136,11 @@ services:
EVOBGP_BIRD_STAGING_DIR: /tmp/evobgp-bird-staging
EVOBGP_SERVICE: evobgp-all
EVOBGP_RUNTIME_LOGS_DIR: /opt/evobgp/runtime-logs
AUTH_REQUIRED: ${AUTH_REQUIRED:-false}
AUTH_JWT_SECRET: ${AUTH_JWT_SECRET:-}
AUTH_ISSUER: ${AUTH_ISSUER:-https://auth.shnt.top}
AUTH_PORTAL_URL: ${AUTH_PORTAL_URL:-}
EVOBGP_PORTAL_TENANT_ID: ${EVOBGP_PORTAL_TENANT_ID:-}
EVOBGP_DEV_INSECURE: "1"
volumes:
- bird_etc:/etc/bird
+6
View File
@@ -138,6 +138,12 @@ services:
EVOBGP_BIRD_STAGING_DIR: /tmp/evobgp-bird-staging
EVOBGP_SERVICE: evobgp-all
EVOBGP_RUNTIME_LOGS_DIR: /opt/evobgp/runtime-logs
# Portal SSO (JWT) — см. docs/access.md / auth-portal integrate-evobgp.md
AUTH_REQUIRED: ${AUTH_REQUIRED:-false}
AUTH_JWT_SECRET: ${AUTH_JWT_SECRET:-}
AUTH_ISSUER: ${AUTH_ISSUER:-https://auth.shnt.top}
AUTH_PORTAL_URL: ${AUTH_PORTAL_URL:-}
EVOBGP_PORTAL_TENANT_ID: ${EVOBGP_PORTAL_TENANT_ID:-}
# DEV ONLY — не для production (см. docs/access.md).
EVOBGP_DEV_INSECURE: "1"
volumes:
+17 -1
View File
@@ -12,7 +12,23 @@
| `AUTH_JWT_SECRET` / `EVOBGP_AUTH_JWT_SECRET` | Тот же секрет, что `JWT_SECRET` портала (HS256) |
| `AUTH_ISSUER` | Issuer JWT (как на портале) |
| `AUTH_PORTAL_URL` | URL портала (также `GET /v1/auth/config`) |
| `EVOBGP_PORTAL_TENANT_ID` | Tenant для всех portal JWT (обязателен при JWT) |
| `EVOBGP_PORTAL_TENANT_ID` | Fallback tenant для portal JWT, если в токене нет `bgp_tenant_id` / `tenants.bgp` |
Источник tenant (по приоритету):
1. JWT claim `tenants.bgp` или `bgp_tenant_id` (задаётся в auth-portal → **Админ → Приложения** → поле «EvoBGP tenant ID»)
2. Env `EVOBGP_PORTAL_TENANT_ID`
Compose: переменные `AUTH_*` / `EVOBGP_PORTAL_TENANT_ID` должны быть в `environment:` сервиса **`evobgp-all`** (см. `deploy/compose/stack.microvps-full.yaml`). Просто положить их в `.env` без проброса в контейнер недостаточно.
`VITE_AUTH_*` в runtime `.env` **не** меняют уже собранный `evobgp-web` образ. UI берёт режим из `GET /v1/auth/config` (`required``AUTH_REQUIRED`, `portal_url``AUTH_PORTAL_URL`).
Проверка после рестарта:
```bash
curl -sS https://bgp.shnt.top/v1/auth/config
# {"required":true,"portal_url":"https://auth.shnt.top"}
```
Права — строки `bgp:<section>:<action>` из каталога портала (dashboard, modules, lookup, network, …). Apply/rollback требуют `bgp:operations:admin`.
+1 -1
View File
@@ -13,7 +13,7 @@ info:
**Аутентификация (dual):**
- **API key** — `Authorization: Bearer <token>` из `EVOBGP_API_KEYS` / таблицы `api_key` (роли `viewer`/`editor`/`operator`/`node`/`firewall`).
- **Portal JWT** — HS256 от auth-portal; claim `apps` должен содержать `bgp`; права `bgp:<section>:<action>`; tenant из `EVOBGP_PORTAL_TENANT_ID`.
- **Portal JWT** — HS256 от auth-portal; claim `apps` должен содержать `bgp`; права `bgp:<section>:<action>`; tenant из `tenants.bgp` / `bgp_tenant_id` или fallback `EVOBGP_PORTAL_TENANT_ID`.
Публично: `GET /v1/auth/config` → `{ required, portal_url }`.
**Роли API key** (матрица): `viewer`, `editor`, `operator`, `node`. Нода использует отдельные пути и ключ с ролью `node`.
+23 -4
View File
@@ -160,9 +160,6 @@ func (s *Server) resolveAuth(raw string) (Auth, bool) {
// 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
@@ -190,12 +187,19 @@ func (s *Server) resolveJWT(raw string) (Auth, int, string, bool) {
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: s.portalTenantID,
TenantID: tenantID,
UserID: strings.TrimSpace(sub),
Email: strings.TrimSpace(email),
Permissions: perms,
@@ -204,6 +208,21 @@ func (s *Server) resolveJWT(raw string) (Auth, int, string, bool) {
}, 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:
+77
View File
@@ -181,6 +181,83 @@ func TestAuthJWTMissingPermissionRejected(t *testing.T) {
}
}
func TestAuthJWTTenantFromClaimWithoutEnv(t *testing.T) {
srv, err := New(Options{
SeedDemo: true,
BundleSeedHex: testBundleSeed,
JWTSecret: testJWTSecret,
AuthIssuer: testIssuer,
AuthPortalURL: "https://portal.test.local",
AuthRequired: true,
// No PortalTenantID — must come from JWT claim.
})
if err != nil {
t.Fatal(err)
}
defer srv.Close()
tenant, _, _, _, _ := srv.Store().DemoIDs()
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
token := signTestJWT(t, jwt.MapClaims{
"iss": testIssuer,
"sub": "user-1",
"apps": []string{"bgp"},
"permissions": []string{"bgp:modules:read"},
"bgp_tenant_id": tenant,
"exp": time.Now().Add(time.Hour).Unix(),
})
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/modules", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := ts.Client().Do(req)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status=%d body=%s", resp.StatusCode, b)
}
}
func TestAuthJWTRejectedWhenTenantMissing(t *testing.T) {
srv, err := New(Options{
SeedDemo: true,
BundleSeedHex: testBundleSeed,
JWTSecret: testJWTSecret,
AuthIssuer: testIssuer,
AuthPortalURL: "https://portal.test.local",
AuthRequired: true,
})
if err != nil {
t.Fatal(err)
}
defer srv.Close()
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
token := signTestJWT(t, jwt.MapClaims{
"iss": testIssuer,
"sub": "user-1",
"apps": []string{"bgp"},
"exp": time.Now().Add(time.Hour).Unix(),
})
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/modules", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := ts.Client().Do(req)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusServiceUnavailable {
t.Fatalf("status=%d want 503", resp.StatusCode)
}
}
func TestAuthConfigPublic(t *testing.T) {
srv, _ := newJWTTestServer(t)
defer srv.Close()
+1 -1
View File
@@ -61,7 +61,7 @@ type Options struct {
JWTSecret string // AUTH_JWT_SECRET / EVOBGP_AUTH_JWT_SECRET (HS256 shared secret)
AuthIssuer string // AUTH_ISSUER (expected iss claim; default https://auth.shnt.top)
AuthPortalURL string // AUTH_PORTAL_URL (returned by /v1/auth/config for the UI)
PortalTenantID string // EVOBGP_PORTAL_TENANT_ID (single tenant scope for JWT users)
PortalTenantID string // fallback when JWT has no bgp_tenant_id / tenants.bgp
AuthRequired bool // AUTH_REQUIRED / EVOBGP_AUTH_REQUIRED (surfaced via /v1/auth/config)
}