Loading charts/platform-core/meep-auth-svc/templates/deployment.yaml +19 −0 Original line number Diff line number Diff line Loading @@ -40,6 +40,25 @@ spec: containerPort: {{ .Values.prometheus.monitor.port }} protocol: TCP {{- end}} {{- if .Values.deployment.probes.enabled }} readinessProbe: # The REST API only starts listening once Init()/Run() succeed, # so a bare TCP check is a reliable readiness signal here tcpSocket: port: {{ .Values.deployment.port }} initialDelaySeconds: {{ .Values.deployment.probes.initialDelaySeconds }} periodSeconds: {{ .Values.deployment.probes.periodSeconds }} failureThreshold: {{ .Values.deployment.probes.failureThreshold }} livenessProbe: # Hits the unauthenticated Index route so liveness never depends # on Redis/Postgres/platform-ctrl health httpGet: path: /auth/v1/ port: {{ .Values.deployment.port }} initialDelaySeconds: {{ .Values.deployment.probes.initialDelaySeconds }} periodSeconds: {{ .Values.deployment.probes.periodSeconds }} failureThreshold: {{ .Values.deployment.probes.failureThreshold }} {{- end }} env: {{- range $key, $value := .Values.image.env }} - name: {{ $key }} Loading charts/platform-core/meep-auth-svc/values.yaml +15 −0 Original line number Diff line number Diff line Loading @@ -13,6 +13,13 @@ deployment: system: - kube-dns namespace: probes: # readinessProbe/livenessProbe are opt-out via this flag if they ever # cause unwanted restarts/traffic removal enabled: true initialDelaySeconds: 5 periodSeconds: 10 failureThreshold: 3 affinity: nodeAffinity: Loading Loading @@ -45,6 +52,14 @@ image: MEEP_SESSION_KEY: name: meep-session key: encryption-key # Optional Postgis credential override - falls back to the service's # built-in default if this secret/key does not exist (secretKeyRef is optional) MEEP_PG_USER: name: meep-postgis-secret key: username MEEP_PG_PASSWORD: name: meep-postgis-secret key: password MEEP_OAUTH_GITHUB_CLIENT_ID: name: github-secret key: client-id Loading go-apps/meep-auth-svc/server/auth-svc.go +157 −14 Original line number Diff line number Diff line Loading @@ -64,9 +64,13 @@ const OAUTH_PROVIDER_LOCAL = "local" const serviceName = "Auth Service" const moduleName = "meep-auth-svc" const moduleNamespace = "default" const postgisUser = "postgres" const postgisPwd = "pwd" // Default Postgis credentials, overridable via MEEP_PG_USER / MEEP_PG_PASSWORD const defaultPostgisUser = "postgres" const defaultPostgisPwd = "pwd" const pfmCtrlBasepath = "http://meep-platform-ctrl/platform-ctrl/v1" const pfmCtrlTimeout = 10 * time.Second const oauthTimeout = 10 * time.Second const providerModeSecure = "secure" const mepPrefix = "mep--" Loading Loading @@ -232,12 +236,16 @@ func Init() (err error) { log.Info("Connected to Session Manager") // Connect to User Store authSvc.userStore, err = users.NewConnector(moduleName, postgisUser, postgisPwd, "", "") pgUser, pgPwd := getPostgisCredentials() authSvc.userStore, err = users.NewConnector(moduleName, pgUser, pgPwd, "", "") if err != nil { log.Error("Failed connection to User Store: ", err.Error()) return err } _ = authSvc.userStore.CreateTables() if err = authSvc.userStore.CreateTables(); err != nil { log.Error("Failed to create User Store tables: ", err.Error()) return err } log.Info("Connected to User Store") // Retrieve & cache endpoint authorization permissions Loading Loading @@ -359,6 +367,19 @@ func Stop() { } } // getPostgisCredentials - Retrieve Postgis credentials from environment, falling back to defaults func getPostgisCredentials() (user string, pwd string) { user = strings.TrimSpace(os.Getenv("MEEP_PG_USER")) if user == "" { user = defaultPostgisUser } pwd = strings.TrimSpace(os.Getenv("MEEP_PG_PASSWORD")) if pwd == "" { pwd = defaultPostgisPwd } return user, pwd } func getPermissionsConfig() (config *PermissionsConfig, err error) { // Read & apply API permissions from file permissionsFile := "/permissions.yaml" Loading Loading @@ -402,14 +423,15 @@ func cachePermissions() { } func cacheDefaultPermission(cfg *PermissionsConfig) { authSvc.cache.Default = &cfg.Default if authSvc.cache.Default == nil { if cfg.Default.Mode == "" { log.Warn("Failed to retrieve default permission") log.Warn("Granting full API access for all roles by default") permission := new(Permission) permission.Mode = sm.ModeAllow authSvc.cache.Default = permission return } authSvc.cache.Default = &cfg.Default } func cacheServicePermissions(cfg *PermissionsConfig) { Loading Loading @@ -633,7 +655,9 @@ func sessionTimeoutCb(session *sm.Session) { // Destroy session sandbox if session.Sandbox != "" { _, err := authSvc.pfmCtrlClient.SandboxControlApi.DeleteSandbox(context.TODO(), session.Sandbox) ctx, cancel := pfmCtrlContext() _, err := authSvc.pfmCtrlClient.SandboxControlApi.DeleteSandbox(ctx, session.Sandbox) cancel() if err == nil { metricSessionActive.Dec() metricSessionDuration.Observe(time.Since(session.StartTime).Minutes()) Loading Loading @@ -700,6 +724,98 @@ func getErrUrl(err string) string { return authSvc.uri + "?err=" + strings.ReplaceAll(err, " ", "+") } // ---------- Local login brute-force protection ---------- const defaultMaxLoginAttempts = 5 const defaultLoginLockoutSeconds = 300 type loginAttemptTracker struct { failures int lockedUntil time.Time timer *time.Timer } var loginAttemptsMutex sync.Mutex var loginAttemptsByUser = make(map[string]*loginAttemptTracker) func getMaxLoginAttempts() int { if v, err := strconv.Atoi(strings.TrimSpace(os.Getenv("MEEP_LOGIN_MAX_ATTEMPTS"))); err == nil && v > 0 { return v } return defaultMaxLoginAttempts } func getLoginLockoutDuration() time.Duration { if v, err := strconv.Atoi(strings.TrimSpace(os.Getenv("MEEP_LOGIN_LOCKOUT_SECONDS"))); err == nil && v > 0 { return time.Duration(v) * time.Second } return defaultLoginLockoutSeconds * time.Second } // isLoginLocked - Returns whether username is currently locked out due to repeated failed attempts func isLoginLocked(username string) bool { loginAttemptsMutex.Lock() defer loginAttemptsMutex.Unlock() tracker, found := loginAttemptsByUser[username] if !found { return false } return time.Now().Before(tracker.lockedUntil) } // recordLoginFailure - Tracks a failed login attempt & locks out username once the threshold is reached func recordLoginFailure(username string) { loginAttemptsMutex.Lock() defer loginAttemptsMutex.Unlock() lockoutDuration := getLoginLockoutDuration() tracker, found := loginAttemptsByUser[username] if !found { tracker = new(loginAttemptTracker) loginAttemptsByUser[username] = tracker } tracker.failures++ if tracker.failures >= getMaxLoginAttempts() { tracker.lockedUntil = time.Now().Add(lockoutDuration) tracker.failures = 0 } // (Re)schedule cleanup so entries with no further activity don't accumulate forever if tracker.timer != nil { tracker.timer.Stop() } tracker.timer = time.AfterFunc(lockoutDuration, func() { loginAttemptsMutex.Lock() delete(loginAttemptsByUser, username) loginAttemptsMutex.Unlock() }) } // resetLoginAttempts - Clears failed attempt tracking for username after a successful login func resetLoginAttempts(username string) { loginAttemptsMutex.Lock() defer loginAttemptsMutex.Unlock() if tracker, found := loginAttemptsByUser[username]; found { if tracker.timer != nil { tracker.timer.Stop() } delete(loginAttemptsByUser, username) } } // pfmCtrlContext - Bounded context for calls to the Platform Controller, // so a slow/unresponsive backend can't hang an auth request indefinitely func pfmCtrlContext() (context.Context, context.CancelFunc) { return context.WithTimeout(context.Background(), pfmCtrlTimeout) } // oauthContext - Bounded context for calls to the OAuth provider (GitHub/GitLab), // so a slow/unresponsive provider can't hang a login request indefinitely func oauthContext() (context.Context, context.CancelFunc) { return context.WithTimeout(context.Background(), oauthTimeout) } // ---------- REST API ---------- func asAuthenticate(w http.ResponseWriter, r *http.Request) { Loading Loading @@ -883,8 +999,12 @@ func asAuthorize(w http.ResponseWriter, r *http.Request) { } metric.Provider = provider // Bounded context so a slow/unresponsive OAuth provider can't hang this request ctx, cancel := oauthContext() defer cancel() // Retrieve access token token, err := config.Exchange(context.Background(), code) token, err := config.Exchange(ctx, code) if err != nil { log.Error(err.Error()) metric.Description = err.Error() Loading @@ -896,7 +1016,10 @@ func asAuthorize(w http.ResponseWriter, r *http.Request) { return } oauthClient := config.Client(context.Background(), token) oauthClient := config.Client(ctx, token) if oauthClient != nil { oauthClient.Timeout = oauthTimeout } if oauthClient == nil { err = errors.New("Failed to create new oauth client") log.Error(err.Error()) Loading Loading @@ -925,7 +1048,7 @@ func asAuthorize(w http.ResponseWriter, r *http.Request) { metricSessionFail.WithLabelValues("OAuth").Inc() return } user, _, err := client.Users.Get(context.Background(), "") user, _, err := client.Users.Get(ctx, "") if err != nil { log.Error(err.Error()) metric.Description = err.Error() Loading Loading @@ -1096,9 +1219,20 @@ func asLoginUser(w http.ResponseWriter, r *http.Request) { metric.Provider = OAUTH_PROVIDER_LOCAL metric.User = username // Reject if account is locked out from too many recent failed attempts. // Respond identically to a bad-credentials failure so lockout state isn't leaked. if isLoginLocked(username) { log.Warn("Rejected login for locked out user: ", username) metric.Description = "Account temporarily locked" _ = authSvc.metricStore.SetSessionMetric(met.SesMetTypeError, metric) http.Error(w, "Unauthorized", http.StatusUnauthorized) return } // Validate user credentials authenticated, err := authSvc.userStore.AuthenticateUser(OAUTH_PROVIDER_LOCAL, username, password) if err != nil || !authenticated { recordLoginFailure(username) if err != nil { metric.Description = err.Error() } else { Loading @@ -1108,6 +1242,7 @@ func asLoginUser(w http.ResponseWriter, r *http.Request) { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } resetLoginAttempts(username) // Start user session sandboxName, isNew, _, err, errCode := startSession(OAUTH_PROVIDER_LOCAL, username, w, r, false) Loading Loading @@ -1173,13 +1308,17 @@ func startSession(provider string, username string, w http.ResponseWriter, r *ht if createSandbox { var sandboxConfig pcc.SandboxConfig if sandboxName == "" { sandbox, _, err := authSvc.pfmCtrlClient.SandboxControlApi.CreateSandbox(context.TODO(), sandboxConfig) ctx, cancel := pfmCtrlContext() sandbox, _, err := authSvc.pfmCtrlClient.SandboxControlApi.CreateSandbox(ctx, sandboxConfig) cancel() if err != nil { return "", false, "", err, http.StatusInternalServerError } sandboxName = sandbox.Name } else { _, err := authSvc.pfmCtrlClient.SandboxControlApi.CreateSandboxWithName(context.TODO(), sandboxName, sandboxConfig) ctx, cancel := pfmCtrlContext() _, err := authSvc.pfmCtrlClient.SandboxControlApi.CreateSandboxWithName(ctx, sandboxName, sandboxConfig) cancel() if err != nil && !strings.Contains(err.Error(), "409") && !strings.Contains(err.Error(), "Conflict") { return "", false, "", err, http.StatusInternalServerError } Loading Loading @@ -1213,7 +1352,9 @@ func startSession(provider string, username string, w http.ResponseWriter, r *ht log.Error("Failed to set session with err: ", err.Error()) // Remove newly created sandbox on failure if session.ID == "" && createSandbox { _, _ = authSvc.pfmCtrlClient.SandboxControlApi.DeleteSandbox(context.TODO(), sandboxName) ctx, cancel := pfmCtrlContext() _, _ = authSvc.pfmCtrlClient.SandboxControlApi.DeleteSandbox(ctx, sandboxName) cancel() } return "", false, "", err, code } Loading @@ -1239,7 +1380,9 @@ func asLogout(w http.ResponseWriter, r *http.Request) { // Delete sandbox if session.Sandbox != "" { log.Info("asLogout: Deleting sandbox: ", session.Sandbox) _, err = authSvc.pfmCtrlClient.SandboxControlApi.DeleteSandbox(context.TODO(), session.Sandbox) ctx, cancel := pfmCtrlContext() _, err = authSvc.pfmCtrlClient.SandboxControlApi.DeleteSandbox(ctx, session.Sandbox) cancel() if err != nil { log.Error("asLogout: Failed to delete sandbox: ", err.Error()) } else { Loading go-apps/meep-auth-svc/server/auth-svc_test.go 0 → 100644 +211 −0 Original line number Diff line number Diff line /* * Copyright (c) 2022 The AdvantEDGE Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package server import ( "net/http" "net/http/httptest" "testing" "time" sm "github.com/InterDigitalInc/AdvantEDGE/go-packages/meep-sessions" "github.com/gorilla/mux" ) // newTestPermissionsConfig builds a small config exercising a default, an // endpoint-specific override, and a service-level default fallback. func newTestPermissionsConfig() *PermissionsConfig { return &PermissionsConfig{ Default: Permission{Mode: sm.ModeAllow}, Services: []Service{ { Name: "test-svc", Path: "/test-svc/v1", Sbox: false, Default: Permission{ Mode: sm.ModeAllow, Roles: map[string]string{}, }, Endpoints: []Endpoint{ { Name: "Blocked", Path: "/blocked", Method: "GET", Sbox: false, Mode: sm.ModeBlock, Roles: map[string]string{}, }, }, }, }, } } // setupTestAuthSvc rebuilds the global authSvc permission cache & router from // an in-memory config, bypassing the /permissions.yaml file on disk. func setupTestAuthSvc(cfg *PermissionsConfig) { authSvc = new(AuthSvc) authSvc.router = mux.NewRouter().StrictSlash(true) cacheDefaultPermission(cfg) cacheServicePermissions(cfg) cacheFileserverPermissions(cfg) } func TestCacheDefaultPermission_ExplicitMode(t *testing.T) { cfg := &PermissionsConfig{Default: Permission{Mode: sm.ModeBlock}} authSvc = new(AuthSvc) cacheDefaultPermission(cfg) if authSvc.cache.Default == nil || authSvc.cache.Default.Mode != sm.ModeBlock { t.Fatalf("expected cached default mode %q, got %+v", sm.ModeBlock, authSvc.cache.Default) } } func TestCacheDefaultPermission_MissingModeFallsBackToAllow(t *testing.T) { cfg := &PermissionsConfig{Default: Permission{}} authSvc = new(AuthSvc) cacheDefaultPermission(cfg) if authSvc.cache.Default == nil || authSvc.cache.Default.Mode != sm.ModeAllow { t.Fatalf("expected fallback allow-all default, got %+v", authSvc.cache.Default) } } func newAuthRequest(originalURL, originalMethod, svc string) *http.Request { req := httptest.NewRequest(http.MethodGet, "/auth/v1/authenticate?svc="+svc, nil) if originalURL != "" { req.Header.Set("X-Original-URL", originalURL) } if originalMethod != "" { req.Header.Set("X-Original-Method", originalMethod) } return req } func TestAsAuthenticate_MissingOriginalHeaders(t *testing.T) { setupTestAuthSvc(newTestPermissionsConfig()) w := httptest.NewRecorder() asAuthenticate(w, newAuthRequest("", "", "test-svc")) if w.Code != http.StatusUnauthorized { t.Fatalf("expected 401 with missing original headers, got %d", w.Code) } } func TestAsAuthenticate_EndpointBlockOverride(t *testing.T) { setupTestAuthSvc(newTestPermissionsConfig()) w := httptest.NewRecorder() asAuthenticate(w, newAuthRequest("/test-svc/v1/blocked", "GET", "test-svc")) if w.Code != http.StatusUnauthorized { t.Fatalf("expected 401 for blocked endpoint, got %d", w.Code) } } func TestAsAuthenticate_ServiceDefaultAllow(t *testing.T) { setupTestAuthSvc(newTestPermissionsConfig()) w := httptest.NewRecorder() asAuthenticate(w, newAuthRequest("/test-svc/v1/other", "GET", "test-svc")) if w.Code != http.StatusOK { t.Fatalf("expected 200 via service default allow, got %d", w.Code) } } func TestAsAuthenticate_UnmatchedRouteUsesGlobalDefault(t *testing.T) { setupTestAuthSvc(newTestPermissionsConfig()) w := httptest.NewRecorder() asAuthenticate(w, newAuthRequest("/nomatch", "GET", "")) if w.Code != http.StatusOK { t.Fatalf("expected 200 via global default allow, got %d", w.Code) } } func TestLoginLockout(t *testing.T) { loginAttemptsMutex.Lock() loginAttemptsByUser = make(map[string]*loginAttemptTracker) loginAttemptsMutex.Unlock() t.Setenv("MEEP_LOGIN_MAX_ATTEMPTS", "3") t.Setenv("MEEP_LOGIN_LOCKOUT_SECONDS", "1") const user = "alice" if isLoginLocked(user) { t.Fatal("user should not be locked before any failures") } recordLoginFailure(user) recordLoginFailure(user) if isLoginLocked(user) { t.Fatal("user should not be locked before reaching the attempt threshold") } recordLoginFailure(user) if !isLoginLocked(user) { t.Fatal("user should be locked out after reaching the attempt threshold") } time.Sleep(1200 * time.Millisecond) if isLoginLocked(user) { t.Fatal("lockout should have expired") } } func TestLoginLockout_ResetOnSuccess(t *testing.T) { loginAttemptsMutex.Lock() loginAttemptsByUser = make(map[string]*loginAttemptTracker) loginAttemptsMutex.Unlock() t.Setenv("MEEP_LOGIN_MAX_ATTEMPTS", "3") t.Setenv("MEEP_LOGIN_LOCKOUT_SECONDS", "60") const user = "bob" recordLoginFailure(user) recordLoginFailure(user) resetLoginAttempts(user) recordLoginFailure(user) if isLoginLocked(user) { t.Fatal("failure count should have been reset by resetLoginAttempts") } } func TestGetPostgisCredentials_Defaults(t *testing.T) { t.Setenv("MEEP_PG_USER", "") t.Setenv("MEEP_PG_PASSWORD", "") user, pwd := getPostgisCredentials() if user != defaultPostgisUser || pwd != defaultPostgisPwd { t.Fatalf("expected defaults %q/%q, got %q/%q", defaultPostgisUser, defaultPostgisPwd, user, pwd) } } func TestGetPostgisCredentials_EnvOverride(t *testing.T) { t.Setenv("MEEP_PG_USER", "customuser") t.Setenv("MEEP_PG_PASSWORD", "custompwd") user, pwd := getPostgisCredentials() if user != "customuser" || pwd != "custompwd" { t.Fatalf("expected env override, got %q/%q", user, pwd) } } Loading
charts/platform-core/meep-auth-svc/templates/deployment.yaml +19 −0 Original line number Diff line number Diff line Loading @@ -40,6 +40,25 @@ spec: containerPort: {{ .Values.prometheus.monitor.port }} protocol: TCP {{- end}} {{- if .Values.deployment.probes.enabled }} readinessProbe: # The REST API only starts listening once Init()/Run() succeed, # so a bare TCP check is a reliable readiness signal here tcpSocket: port: {{ .Values.deployment.port }} initialDelaySeconds: {{ .Values.deployment.probes.initialDelaySeconds }} periodSeconds: {{ .Values.deployment.probes.periodSeconds }} failureThreshold: {{ .Values.deployment.probes.failureThreshold }} livenessProbe: # Hits the unauthenticated Index route so liveness never depends # on Redis/Postgres/platform-ctrl health httpGet: path: /auth/v1/ port: {{ .Values.deployment.port }} initialDelaySeconds: {{ .Values.deployment.probes.initialDelaySeconds }} periodSeconds: {{ .Values.deployment.probes.periodSeconds }} failureThreshold: {{ .Values.deployment.probes.failureThreshold }} {{- end }} env: {{- range $key, $value := .Values.image.env }} - name: {{ $key }} Loading
charts/platform-core/meep-auth-svc/values.yaml +15 −0 Original line number Diff line number Diff line Loading @@ -13,6 +13,13 @@ deployment: system: - kube-dns namespace: probes: # readinessProbe/livenessProbe are opt-out via this flag if they ever # cause unwanted restarts/traffic removal enabled: true initialDelaySeconds: 5 periodSeconds: 10 failureThreshold: 3 affinity: nodeAffinity: Loading Loading @@ -45,6 +52,14 @@ image: MEEP_SESSION_KEY: name: meep-session key: encryption-key # Optional Postgis credential override - falls back to the service's # built-in default if this secret/key does not exist (secretKeyRef is optional) MEEP_PG_USER: name: meep-postgis-secret key: username MEEP_PG_PASSWORD: name: meep-postgis-secret key: password MEEP_OAUTH_GITHUB_CLIENT_ID: name: github-secret key: client-id Loading
go-apps/meep-auth-svc/server/auth-svc.go +157 −14 Original line number Diff line number Diff line Loading @@ -64,9 +64,13 @@ const OAUTH_PROVIDER_LOCAL = "local" const serviceName = "Auth Service" const moduleName = "meep-auth-svc" const moduleNamespace = "default" const postgisUser = "postgres" const postgisPwd = "pwd" // Default Postgis credentials, overridable via MEEP_PG_USER / MEEP_PG_PASSWORD const defaultPostgisUser = "postgres" const defaultPostgisPwd = "pwd" const pfmCtrlBasepath = "http://meep-platform-ctrl/platform-ctrl/v1" const pfmCtrlTimeout = 10 * time.Second const oauthTimeout = 10 * time.Second const providerModeSecure = "secure" const mepPrefix = "mep--" Loading Loading @@ -232,12 +236,16 @@ func Init() (err error) { log.Info("Connected to Session Manager") // Connect to User Store authSvc.userStore, err = users.NewConnector(moduleName, postgisUser, postgisPwd, "", "") pgUser, pgPwd := getPostgisCredentials() authSvc.userStore, err = users.NewConnector(moduleName, pgUser, pgPwd, "", "") if err != nil { log.Error("Failed connection to User Store: ", err.Error()) return err } _ = authSvc.userStore.CreateTables() if err = authSvc.userStore.CreateTables(); err != nil { log.Error("Failed to create User Store tables: ", err.Error()) return err } log.Info("Connected to User Store") // Retrieve & cache endpoint authorization permissions Loading Loading @@ -359,6 +367,19 @@ func Stop() { } } // getPostgisCredentials - Retrieve Postgis credentials from environment, falling back to defaults func getPostgisCredentials() (user string, pwd string) { user = strings.TrimSpace(os.Getenv("MEEP_PG_USER")) if user == "" { user = defaultPostgisUser } pwd = strings.TrimSpace(os.Getenv("MEEP_PG_PASSWORD")) if pwd == "" { pwd = defaultPostgisPwd } return user, pwd } func getPermissionsConfig() (config *PermissionsConfig, err error) { // Read & apply API permissions from file permissionsFile := "/permissions.yaml" Loading Loading @@ -402,14 +423,15 @@ func cachePermissions() { } func cacheDefaultPermission(cfg *PermissionsConfig) { authSvc.cache.Default = &cfg.Default if authSvc.cache.Default == nil { if cfg.Default.Mode == "" { log.Warn("Failed to retrieve default permission") log.Warn("Granting full API access for all roles by default") permission := new(Permission) permission.Mode = sm.ModeAllow authSvc.cache.Default = permission return } authSvc.cache.Default = &cfg.Default } func cacheServicePermissions(cfg *PermissionsConfig) { Loading Loading @@ -633,7 +655,9 @@ func sessionTimeoutCb(session *sm.Session) { // Destroy session sandbox if session.Sandbox != "" { _, err := authSvc.pfmCtrlClient.SandboxControlApi.DeleteSandbox(context.TODO(), session.Sandbox) ctx, cancel := pfmCtrlContext() _, err := authSvc.pfmCtrlClient.SandboxControlApi.DeleteSandbox(ctx, session.Sandbox) cancel() if err == nil { metricSessionActive.Dec() metricSessionDuration.Observe(time.Since(session.StartTime).Minutes()) Loading Loading @@ -700,6 +724,98 @@ func getErrUrl(err string) string { return authSvc.uri + "?err=" + strings.ReplaceAll(err, " ", "+") } // ---------- Local login brute-force protection ---------- const defaultMaxLoginAttempts = 5 const defaultLoginLockoutSeconds = 300 type loginAttemptTracker struct { failures int lockedUntil time.Time timer *time.Timer } var loginAttemptsMutex sync.Mutex var loginAttemptsByUser = make(map[string]*loginAttemptTracker) func getMaxLoginAttempts() int { if v, err := strconv.Atoi(strings.TrimSpace(os.Getenv("MEEP_LOGIN_MAX_ATTEMPTS"))); err == nil && v > 0 { return v } return defaultMaxLoginAttempts } func getLoginLockoutDuration() time.Duration { if v, err := strconv.Atoi(strings.TrimSpace(os.Getenv("MEEP_LOGIN_LOCKOUT_SECONDS"))); err == nil && v > 0 { return time.Duration(v) * time.Second } return defaultLoginLockoutSeconds * time.Second } // isLoginLocked - Returns whether username is currently locked out due to repeated failed attempts func isLoginLocked(username string) bool { loginAttemptsMutex.Lock() defer loginAttemptsMutex.Unlock() tracker, found := loginAttemptsByUser[username] if !found { return false } return time.Now().Before(tracker.lockedUntil) } // recordLoginFailure - Tracks a failed login attempt & locks out username once the threshold is reached func recordLoginFailure(username string) { loginAttemptsMutex.Lock() defer loginAttemptsMutex.Unlock() lockoutDuration := getLoginLockoutDuration() tracker, found := loginAttemptsByUser[username] if !found { tracker = new(loginAttemptTracker) loginAttemptsByUser[username] = tracker } tracker.failures++ if tracker.failures >= getMaxLoginAttempts() { tracker.lockedUntil = time.Now().Add(lockoutDuration) tracker.failures = 0 } // (Re)schedule cleanup so entries with no further activity don't accumulate forever if tracker.timer != nil { tracker.timer.Stop() } tracker.timer = time.AfterFunc(lockoutDuration, func() { loginAttemptsMutex.Lock() delete(loginAttemptsByUser, username) loginAttemptsMutex.Unlock() }) } // resetLoginAttempts - Clears failed attempt tracking for username after a successful login func resetLoginAttempts(username string) { loginAttemptsMutex.Lock() defer loginAttemptsMutex.Unlock() if tracker, found := loginAttemptsByUser[username]; found { if tracker.timer != nil { tracker.timer.Stop() } delete(loginAttemptsByUser, username) } } // pfmCtrlContext - Bounded context for calls to the Platform Controller, // so a slow/unresponsive backend can't hang an auth request indefinitely func pfmCtrlContext() (context.Context, context.CancelFunc) { return context.WithTimeout(context.Background(), pfmCtrlTimeout) } // oauthContext - Bounded context for calls to the OAuth provider (GitHub/GitLab), // so a slow/unresponsive provider can't hang a login request indefinitely func oauthContext() (context.Context, context.CancelFunc) { return context.WithTimeout(context.Background(), oauthTimeout) } // ---------- REST API ---------- func asAuthenticate(w http.ResponseWriter, r *http.Request) { Loading Loading @@ -883,8 +999,12 @@ func asAuthorize(w http.ResponseWriter, r *http.Request) { } metric.Provider = provider // Bounded context so a slow/unresponsive OAuth provider can't hang this request ctx, cancel := oauthContext() defer cancel() // Retrieve access token token, err := config.Exchange(context.Background(), code) token, err := config.Exchange(ctx, code) if err != nil { log.Error(err.Error()) metric.Description = err.Error() Loading @@ -896,7 +1016,10 @@ func asAuthorize(w http.ResponseWriter, r *http.Request) { return } oauthClient := config.Client(context.Background(), token) oauthClient := config.Client(ctx, token) if oauthClient != nil { oauthClient.Timeout = oauthTimeout } if oauthClient == nil { err = errors.New("Failed to create new oauth client") log.Error(err.Error()) Loading Loading @@ -925,7 +1048,7 @@ func asAuthorize(w http.ResponseWriter, r *http.Request) { metricSessionFail.WithLabelValues("OAuth").Inc() return } user, _, err := client.Users.Get(context.Background(), "") user, _, err := client.Users.Get(ctx, "") if err != nil { log.Error(err.Error()) metric.Description = err.Error() Loading Loading @@ -1096,9 +1219,20 @@ func asLoginUser(w http.ResponseWriter, r *http.Request) { metric.Provider = OAUTH_PROVIDER_LOCAL metric.User = username // Reject if account is locked out from too many recent failed attempts. // Respond identically to a bad-credentials failure so lockout state isn't leaked. if isLoginLocked(username) { log.Warn("Rejected login for locked out user: ", username) metric.Description = "Account temporarily locked" _ = authSvc.metricStore.SetSessionMetric(met.SesMetTypeError, metric) http.Error(w, "Unauthorized", http.StatusUnauthorized) return } // Validate user credentials authenticated, err := authSvc.userStore.AuthenticateUser(OAUTH_PROVIDER_LOCAL, username, password) if err != nil || !authenticated { recordLoginFailure(username) if err != nil { metric.Description = err.Error() } else { Loading @@ -1108,6 +1242,7 @@ func asLoginUser(w http.ResponseWriter, r *http.Request) { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } resetLoginAttempts(username) // Start user session sandboxName, isNew, _, err, errCode := startSession(OAUTH_PROVIDER_LOCAL, username, w, r, false) Loading Loading @@ -1173,13 +1308,17 @@ func startSession(provider string, username string, w http.ResponseWriter, r *ht if createSandbox { var sandboxConfig pcc.SandboxConfig if sandboxName == "" { sandbox, _, err := authSvc.pfmCtrlClient.SandboxControlApi.CreateSandbox(context.TODO(), sandboxConfig) ctx, cancel := pfmCtrlContext() sandbox, _, err := authSvc.pfmCtrlClient.SandboxControlApi.CreateSandbox(ctx, sandboxConfig) cancel() if err != nil { return "", false, "", err, http.StatusInternalServerError } sandboxName = sandbox.Name } else { _, err := authSvc.pfmCtrlClient.SandboxControlApi.CreateSandboxWithName(context.TODO(), sandboxName, sandboxConfig) ctx, cancel := pfmCtrlContext() _, err := authSvc.pfmCtrlClient.SandboxControlApi.CreateSandboxWithName(ctx, sandboxName, sandboxConfig) cancel() if err != nil && !strings.Contains(err.Error(), "409") && !strings.Contains(err.Error(), "Conflict") { return "", false, "", err, http.StatusInternalServerError } Loading Loading @@ -1213,7 +1352,9 @@ func startSession(provider string, username string, w http.ResponseWriter, r *ht log.Error("Failed to set session with err: ", err.Error()) // Remove newly created sandbox on failure if session.ID == "" && createSandbox { _, _ = authSvc.pfmCtrlClient.SandboxControlApi.DeleteSandbox(context.TODO(), sandboxName) ctx, cancel := pfmCtrlContext() _, _ = authSvc.pfmCtrlClient.SandboxControlApi.DeleteSandbox(ctx, sandboxName) cancel() } return "", false, "", err, code } Loading @@ -1239,7 +1380,9 @@ func asLogout(w http.ResponseWriter, r *http.Request) { // Delete sandbox if session.Sandbox != "" { log.Info("asLogout: Deleting sandbox: ", session.Sandbox) _, err = authSvc.pfmCtrlClient.SandboxControlApi.DeleteSandbox(context.TODO(), session.Sandbox) ctx, cancel := pfmCtrlContext() _, err = authSvc.pfmCtrlClient.SandboxControlApi.DeleteSandbox(ctx, session.Sandbox) cancel() if err != nil { log.Error("asLogout: Failed to delete sandbox: ", err.Error()) } else { Loading
go-apps/meep-auth-svc/server/auth-svc_test.go 0 → 100644 +211 −0 Original line number Diff line number Diff line /* * Copyright (c) 2022 The AdvantEDGE Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package server import ( "net/http" "net/http/httptest" "testing" "time" sm "github.com/InterDigitalInc/AdvantEDGE/go-packages/meep-sessions" "github.com/gorilla/mux" ) // newTestPermissionsConfig builds a small config exercising a default, an // endpoint-specific override, and a service-level default fallback. func newTestPermissionsConfig() *PermissionsConfig { return &PermissionsConfig{ Default: Permission{Mode: sm.ModeAllow}, Services: []Service{ { Name: "test-svc", Path: "/test-svc/v1", Sbox: false, Default: Permission{ Mode: sm.ModeAllow, Roles: map[string]string{}, }, Endpoints: []Endpoint{ { Name: "Blocked", Path: "/blocked", Method: "GET", Sbox: false, Mode: sm.ModeBlock, Roles: map[string]string{}, }, }, }, }, } } // setupTestAuthSvc rebuilds the global authSvc permission cache & router from // an in-memory config, bypassing the /permissions.yaml file on disk. func setupTestAuthSvc(cfg *PermissionsConfig) { authSvc = new(AuthSvc) authSvc.router = mux.NewRouter().StrictSlash(true) cacheDefaultPermission(cfg) cacheServicePermissions(cfg) cacheFileserverPermissions(cfg) } func TestCacheDefaultPermission_ExplicitMode(t *testing.T) { cfg := &PermissionsConfig{Default: Permission{Mode: sm.ModeBlock}} authSvc = new(AuthSvc) cacheDefaultPermission(cfg) if authSvc.cache.Default == nil || authSvc.cache.Default.Mode != sm.ModeBlock { t.Fatalf("expected cached default mode %q, got %+v", sm.ModeBlock, authSvc.cache.Default) } } func TestCacheDefaultPermission_MissingModeFallsBackToAllow(t *testing.T) { cfg := &PermissionsConfig{Default: Permission{}} authSvc = new(AuthSvc) cacheDefaultPermission(cfg) if authSvc.cache.Default == nil || authSvc.cache.Default.Mode != sm.ModeAllow { t.Fatalf("expected fallback allow-all default, got %+v", authSvc.cache.Default) } } func newAuthRequest(originalURL, originalMethod, svc string) *http.Request { req := httptest.NewRequest(http.MethodGet, "/auth/v1/authenticate?svc="+svc, nil) if originalURL != "" { req.Header.Set("X-Original-URL", originalURL) } if originalMethod != "" { req.Header.Set("X-Original-Method", originalMethod) } return req } func TestAsAuthenticate_MissingOriginalHeaders(t *testing.T) { setupTestAuthSvc(newTestPermissionsConfig()) w := httptest.NewRecorder() asAuthenticate(w, newAuthRequest("", "", "test-svc")) if w.Code != http.StatusUnauthorized { t.Fatalf("expected 401 with missing original headers, got %d", w.Code) } } func TestAsAuthenticate_EndpointBlockOverride(t *testing.T) { setupTestAuthSvc(newTestPermissionsConfig()) w := httptest.NewRecorder() asAuthenticate(w, newAuthRequest("/test-svc/v1/blocked", "GET", "test-svc")) if w.Code != http.StatusUnauthorized { t.Fatalf("expected 401 for blocked endpoint, got %d", w.Code) } } func TestAsAuthenticate_ServiceDefaultAllow(t *testing.T) { setupTestAuthSvc(newTestPermissionsConfig()) w := httptest.NewRecorder() asAuthenticate(w, newAuthRequest("/test-svc/v1/other", "GET", "test-svc")) if w.Code != http.StatusOK { t.Fatalf("expected 200 via service default allow, got %d", w.Code) } } func TestAsAuthenticate_UnmatchedRouteUsesGlobalDefault(t *testing.T) { setupTestAuthSvc(newTestPermissionsConfig()) w := httptest.NewRecorder() asAuthenticate(w, newAuthRequest("/nomatch", "GET", "")) if w.Code != http.StatusOK { t.Fatalf("expected 200 via global default allow, got %d", w.Code) } } func TestLoginLockout(t *testing.T) { loginAttemptsMutex.Lock() loginAttemptsByUser = make(map[string]*loginAttemptTracker) loginAttemptsMutex.Unlock() t.Setenv("MEEP_LOGIN_MAX_ATTEMPTS", "3") t.Setenv("MEEP_LOGIN_LOCKOUT_SECONDS", "1") const user = "alice" if isLoginLocked(user) { t.Fatal("user should not be locked before any failures") } recordLoginFailure(user) recordLoginFailure(user) if isLoginLocked(user) { t.Fatal("user should not be locked before reaching the attempt threshold") } recordLoginFailure(user) if !isLoginLocked(user) { t.Fatal("user should be locked out after reaching the attempt threshold") } time.Sleep(1200 * time.Millisecond) if isLoginLocked(user) { t.Fatal("lockout should have expired") } } func TestLoginLockout_ResetOnSuccess(t *testing.T) { loginAttemptsMutex.Lock() loginAttemptsByUser = make(map[string]*loginAttemptTracker) loginAttemptsMutex.Unlock() t.Setenv("MEEP_LOGIN_MAX_ATTEMPTS", "3") t.Setenv("MEEP_LOGIN_LOCKOUT_SECONDS", "60") const user = "bob" recordLoginFailure(user) recordLoginFailure(user) resetLoginAttempts(user) recordLoginFailure(user) if isLoginLocked(user) { t.Fatal("failure count should have been reset by resetLoginAttempts") } } func TestGetPostgisCredentials_Defaults(t *testing.T) { t.Setenv("MEEP_PG_USER", "") t.Setenv("MEEP_PG_PASSWORD", "") user, pwd := getPostgisCredentials() if user != defaultPostgisUser || pwd != defaultPostgisPwd { t.Fatalf("expected defaults %q/%q, got %q/%q", defaultPostgisUser, defaultPostgisPwd, user, pwd) } } func TestGetPostgisCredentials_EnvOverride(t *testing.T) { t.Setenv("MEEP_PG_USER", "customuser") t.Setenv("MEEP_PG_PASSWORD", "custompwd") user, pwd := getPostgisCredentials() if user != "customuser" || pwd != "custompwd" { t.Fatalf("expected env override, got %q/%q", user, pwd) } }