Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b28ad88b22 | ||
|
|
b871d62de6 |
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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`.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user