Commit 3ac8de5f authored by Muhammad Umair Khan's avatar Muhammad Umair Khan
Browse files

Add login lockout mechanism and PostGIS environment overrides

parent 570db9f8
Loading
Loading
Loading
Loading
+19 −0
Original line number Diff line number Diff line
@@ -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 }}
+15 −0
Original line number Diff line number Diff line
@@ -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:
@@ -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
+157 −14
Original line number Diff line number Diff line
@@ -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--"

@@ -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
@@ -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"
@@ -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) {
@@ -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())
@@ -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) {
@@ -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()
@@ -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())
@@ -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()
@@ -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 {
@@ -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)
@@ -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
				}
@@ -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
	}
@@ -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 {
+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)
	}
}