From caa7e8f77dce756a5fae4cfb81eb26d85dadd41d Mon Sep 17 00:00:00 2001 From: UncleGedd <42304551+UncleGedd@users.noreply.github.com> Date: Tue, 15 Oct 2024 14:49:45 -0500 Subject: [PATCH] chore: refactor auth logic (#426) Co-authored-by: decleaver <85503726+decleaver@users.noreply.github.com> --- .github/ISSUE_TEMPLATE/tech_debt.md | 2 +- .github/workflows/api-auth-tests.yaml | 4 +- CONTRIBUTING.md | 2 +- Dockerfile | 2 +- chart/templates/deployment.yaml | 2 +- design-docs/0001-cluster-disconnection.md | 85 --------------- design-docs/0002-api-auth.md | 33 ------ design-docs/images/api-auth-flow.png | Bin 46259 -> 0 bytes design-docs/template.md | 48 -------- pkg/api/auth/cluster/jwt.go | 54 +++++++++ .../{session_test.go => cluster/jwt_test.go} | 57 +++------- pkg/api/auth/configure.go | 70 ++++++++++++ pkg/api/auth/local/session.go | 103 ++++++++++++++++++ pkg/api/auth/local/session_test.go | 99 +++++++++++++++++ pkg/api/auth/random.go | 27 ----- pkg/api/handlers.go | 11 +- pkg/api/middleware/api_auth.go | 16 --- pkg/api/middleware/auth.go | 42 +++++++ pkg/api/start.go | 84 +++----------- pkg/config/config.go | 10 ++ pkg/test/api_test.go | 4 +- tasks/test.yaml | 6 +- ui/package.json | 2 +- ...uth.ts => playwright.config.local-auth.ts} | 2 +- ui/playwright.config.ts | 4 +- .../components/k8s/DataTable/component.svelte | 1 - .../lib/features/{api-auth => auth}/store.ts | 0 .../features/k8s/namespaces/component.test.ts | 6 - ui/src/lib/features/k8s/store.ts | 7 +- .../navigation/navbar/component.svelte | 2 +- .../lib/utils/{api-auth.ts => token-auth.ts} | 10 +- ui/src/routes/+layout.svelte | 8 +- ui/src/routes/+layout.ts | 4 +- ui/src/routes/+page.svelte | 2 +- ui/src/routes/auth/+page.svelte | 2 +- .../{api-auth.spec.ts => local-auth.spec.ts} | 27 ++++- 36 files changed, 470 insertions(+), 368 deletions(-) delete mode 100644 design-docs/0001-cluster-disconnection.md delete mode 100644 design-docs/0002-api-auth.md delete mode 100644 design-docs/images/api-auth-flow.png delete mode 100644 design-docs/template.md create mode 100644 pkg/api/auth/cluster/jwt.go rename pkg/api/auth/{session_test.go => cluster/jwt_test.go} (58%) create mode 100644 pkg/api/auth/configure.go create mode 100644 pkg/api/auth/local/session.go create mode 100644 pkg/api/auth/local/session_test.go delete mode 100644 pkg/api/auth/random.go delete mode 100644 pkg/api/middleware/api_auth.go create mode 100644 pkg/api/middleware/auth.go create mode 100644 pkg/config/config.go rename ui/{playwright.config.apiauth.ts => playwright.config.local-auth.ts} (93%) rename ui/src/lib/features/{api-auth => auth}/store.ts (100%) rename ui/src/lib/utils/{api-auth.ts => token-auth.ts} (75%) rename ui/tests/{api-auth.spec.ts => local-auth.spec.ts} (74%) diff --git a/.github/ISSUE_TEMPLATE/tech_debt.md b/.github/ISSUE_TEMPLATE/tech_debt.md index 70900fe9..eb344146 100644 --- a/.github/ISSUE_TEMPLATE/tech_debt.md +++ b/.github/ISSUE_TEMPLATE/tech_debt.md @@ -2,7 +2,7 @@ name: Tech debt about: Record something that should be investigated or refactored in the future. title: '' -labels: 'tech-debt' +labels: 'tech debt' assignees: '' --- diff --git a/.github/workflows/api-auth-tests.yaml b/.github/workflows/api-auth-tests.yaml index d6659e74..bcd960fa 100644 --- a/.github/workflows/api-auth-tests.yaml +++ b/.github/workflows/api-auth-tests.yaml @@ -32,7 +32,7 @@ jobs: uses: ./.github/actions/setup - name: Run tests - run: uds run test:api-auth + run: uds run test:local-auth timeout-minutes: 30 - name: Debug Output @@ -42,4 +42,4 @@ jobs: if: always() uses: defenseunicorns/uds-common/.github/actions/save-logs@e3008473beab00b12a94f9fcc7340124338d5c08 # v0.13.1 with: - suffix: api-auth + suffix: local-auth diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 21439fb3..0d660df5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -56,7 +56,7 @@ Most of the actions needed for running and testing UDS Runtime are contained in To view a complete list of all runnable tasks, run `uds run --list-all`. -API authentication is enabled by default. To disable it, you can set the `API_AUTH_DISABLED` environment variable to true when running the backend. When running the backend and frontend locally with API auth enabled, when you start the backend, it will print a URL to the console with the api token query parameter as well as launch the app in your browser. If you are also running the frontend locally (via `npm run dev`), you will want to grab the token and update the url in your browser to use port `:5173` which is used by default. Example: `http://localhost:5173/auth?token=your-token-here`. More information on API authentication can be found in the [API Auth docs](./docs/api-auth.md). +Local API authentication is enabled by default. To disable it, you can set the `LOCAL_AUTH_ENABLED` environment variable to false when running the backend. When running the backend and frontend locally with API auth enabled, when you start the backend, it will print a URL to the console with the api token query parameter as well as launch the app in your browser. If you are also running the frontend locally (via `npm run dev`), you will want to grab the token and update the url in your browser to use port `:5173` which is used by default. Example: `http://localhost:5173/auth?token=your-token-here`. More information on API authentication can be found in the [API Auth docs](./docs/api-auth.md). ### Pre-Commit Hooks and Linting diff --git a/Dockerfile b/Dockerfile index 94bc2e4f..e30aaa77 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,7 +13,7 @@ USER 65532:65532 # copy binary from local and expose port COPY --chown=65532:65532 build/uds-runtime-linux-${TARGETARCH} /app/uds-runtime ENV PORT=8080 -ENV API_AUTH_DISABLED=true +ENV LOCAL_AUTH_ENABLED=false EXPOSE 8080 # run binary diff --git a/chart/templates/deployment.yaml b/chart/templates/deployment.yaml index 4d629897..6833d8e0 100644 --- a/chart/templates/deployment.yaml +++ b/chart/templates/deployment.yaml @@ -31,5 +31,5 @@ spec: memory: {{ .Values.resources.limits.memory | quote }} cpu: {{ .Values.resources.limits.cpu | quote }} env: - - name: AUTH_SVC_ENABLED + - name: IN_CLUSTER_AUTH_ENABLED value: {{ .Values.sso.enabled | quote }} diff --git a/design-docs/0001-cluster-disconnection.md b/design-docs/0001-cluster-disconnection.md deleted file mode 100644 index b23987ce..00000000 --- a/design-docs/0001-cluster-disconnection.md +++ /dev/null @@ -1,85 +0,0 @@ -# Design Doc Title: Cluster Disconnection Detection - -Author(s): Runtime Team -Date Created: Sept. 9, 2024 -Status: Implemented -Ticket: https://github.com/defenseunicorns/uds-runtime/issues/10 - -## Problem Statement - -It is important for real-time monitoring and maintaining of a kubernetes cluster, that users are made aware when their connection to the cluster is no longer healthy. This is especially important given that Runtime uses a cache (built from kubernetes informers), which continues to serve potentially outdated information upon cluster disconnection. This design aims to solve this problem for local (out of cluster) deployments of Runtime by implementing a system that detects cluster disconnection and automatically attempts to reconnect, while providing feedback to users. - -## Proposal - -The solution involves creating a mechanism that constantly monitors the health of the cluster connection. If the connection is lost, it will trigger a reconnection attempt in the background and notify the user via toast notifications in the frontend. Upon reconnection, a success message will be shown to the user, the current view will be updated, and the system will continue normal operations. - -## Scope and Requirements - -- Detect disconnection from the cluster -- Automatically attempt to reconnect when a disconnection is detected -- Notify users of both disconnection and successful reconnection via the frontend UI -- Ensure the system can handle reconnection attempts without causing a complete failure or downtime -- Keep monitoring the cluster connection state at regular intervals - -## Implementation Details - -The implementation will consist of the following components: - -### Backend Implementation: - -#### Detection: - -Option 1: Use Informers - -This approach uses the [watch error handler](https://github.com/kubernetes/client-go/blob/v0.20.5/tools/cache/shared_informer.go#L169-L182) that every informer already implements to detect disconnection. By doing so, we would not need to poll the cluster with a separate endpoint. We could then, upon disconnection, implement reconnection logic. The issue found in testing this option is that when informers connect successfully they don't then detect disconnection errors immediately. It seems as though they don't get the error until some timeout is hit, likely closing the TCP connection. An attempt at setting the timeout for TCP connections to a lower value, did not make a difference. - -Option 2: Poll - -We poll the cluster with a server health check. Initiating this health check requires the frontend to make a request to `/health`. If an error is encountered, the system will emit a disconnection event, triggering the reconnection process in a separate go routine. - -**Solution** - -Due to complications mentioned in option 1, we have chosen to implement option 2. - -#### Reconnecting - -When triggered by an update to the disconnected error channel, the reconnection handler will cancel the current cache context (officially stopping the informers), attempt to recreate the Kubernetes client, and reinitialize the cache. This loop will continue until the connection is restored or the application is stopped. **Note:** reconnection attempts will only be made to the originally connected cluster based on the original current-context and cluster name. - -### Frontend Implementation: - -When a user lands on the application, triggering the main `src/routes/layout.svelte`, an EventSource is created for `/health` that will now continuously receive updates from the server on cluster connection health. If an error is received, a toast will be displayed to the user. This error toast should remain on the screen (regardless of user navigation) until a reconnected message is received. Only a single toast will be added regardless of subsequent error messages. When the connection is restored, the toast is updated to indicate reconnection and then removed. - -After the reconnection event is received, a custom event will be dispatched to trigger an event listener on the `Datatable` component. This event handler will then restart the table store, which re-fires the eventSource call and gets data from the new cache. - -## Changes to Existing Systems: - -- Add a new health check route (/health) to monitor the cluster's connection status. -- Introduce reconnection handling logic in the backend to manage the lifecycle of Kubernetes clients and caches. -- Wrap route handlers so they dynamically get the latest cache. Otherwise, routes will always maintain the cache reference they were initialized with and therefore never return data from the new informers. -- Add event listeners and dispatchers in UI and make stores from createStore(), in Datatable, reactive - -## Current Problems and Questions - -1. By intiating the health check via the frontend call to `/health`, this creates a poll per client. Could this cause strain on the server? Could all these potential error events (for the same cluster disconnection) cause unnecessary reconnection attempts? - -1. If the connection check interval is too low, there is potential for unnecessarily initiating reconnection attempts. This occurs when an error kicks off reconnection handling, the reconnection is successful, but the check occurs with the old clientset before the new one can be set and therefore sends another error to the disconnected error channel. - -1. Are there any side-effects of this running in-cluster? - -## Non-Goals - -This solution does not include handling other Kubernetes API failures unrelated to cluster disconnections. It does not provide full error recovery for all possible API failures. - -## Future Improvements - -It would be nice to figure out a way to use the informers for detecting disconnection events without the need for polling. This could also open up the ability to send more specific errors regarding resources to the frontend for admins to see. - -e.g. - -```console -E0909 09:36:59.759711 2545122 reflector.go:158] "Unhandled Error" err="pkg/mod/k8s.io/client-go@v0.31.0/tools/cache/reflector.go:243: Failed to watch uds.dev/v1alpha1, Resource=exemptions: failed to list uds.dev/v1alpha1, Resource=exemptions: the server could not find the requested resource" logger="UnhandledError" -``` - -## Other Considerations - -- The impact of reconnection attempts on system performance should be monitored, particularly in environments with high traffic. diff --git a/design-docs/0002-api-auth.md b/design-docs/0002-api-auth.md deleted file mode 100644 index 9efaed29..00000000 --- a/design-docs/0002-api-auth.md +++ /dev/null @@ -1,33 +0,0 @@ -# API Token Authentication - -Author(s): @decleaver -Date Created: Sept 17, 2024 -Status: APPROVED - -### Problem Statement -API authentication is needed to prevent unauthorized access to the API from other processes when running UDS Runtime locally. - -### Implementation Details -The API uses a token-based authentication system. The token is generated by the backend server and is used to authenticate the user. API authentication is enabled by default, to disable it you can set the `API_AUTH_DISABLED` environment variable to true. - -Instead of managing state in the frontend, the api authentication state is managed in the backend. One way this can be accomplished is by using session cookies. - -The generated api token is used initially by the frontend to authenticate with the backend, but once the token is validated, the backend generates a session ID and sets the session ID as a secure, HttpOnly cookie in the response. The frontend receives the response and the browser automatically stores the session cookie. For subsequent requests, the browser automatically includes the session cookie and the backend authMiddleware extracts and validate the session ID from the cookie. This approach simplifies the frontend codebase and reduces the amount of state management needed in the frontend. - -How does the frontend authenticate? -- Backend generates a token when it is started up and launches UDS Runtime in the browser. - - i.e.(Runtime API connection: `http://127.0.0.1:8080?token=r1hrQ9CcuZMKpY2egjsPrzmge3-YqfqOHjmlIOvdKrLGOLnHPgFWt3dzsdkHwzDdXQAfRRHiH~rbGEx7Jc7rTxTd4riCuqGH`) -- Frontend hits the /api-auth endpoint with the token as a query parameter. -- The backend validates the token and generates a session ID and sets it in a cookie. -- Subsequent requests will include the session cookie and the backend will validate the session ID before processing any requests. -

- -The session cookie is valid for the duration of the browser session and is deleted when the browser is closed. - -

- API auth flow diagram -

- -### Alternatives Considered -#### Manage authentication state in the frontend -Instead of managing the API authentication state on the backend, it can be managed on the frontend. In this approach, when a user is authenticated, the token is stored in `sessionStorage` and remains valid for the duration of the page session. This token is then used when creating the `EventSources` for various views. Reauthentication can be done by hitting the `/auth` endpoint with the token as a query parameter. While this method simplifies the backend codebase, it increases the complexity of the frontend codebase. The frontend must determine if API authentication is enabled and if the user is authenticated, all while maintaining the current state using Svelte stores and `sessionStorage`. diff --git a/design-docs/images/api-auth-flow.png b/design-docs/images/api-auth-flow.png deleted file mode 100644 index 265d648def85e779e04ce673604afe3f463fab78..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 46259 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

(!N)mnI*p|j+REI+HrC68^q$VGc4;}W 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 -Date Created: Mar 13, 2024 -Status: DRAFT | REVIEW | APPROVED | IMPLEMENTED -Ticket: -Reviews Requested By: Mm DD, YYYY - -### Problem Statement - -Give background to the reader about the problem being solved and why a change is necessary. There should be adequate information here for a person with minimal context to understand the author's motivation for creating the document. - -### Proposal - -Briefly outline the proposed solution and explain how it will solve the problem mentioned in the background section. - -### Scope and Requirements - -Explicitly outline any project requirements that must be solved by the solution. - -### Implementation Details - -Expand upon the implementation details here. Draw the reader's attention to: - -* Changes to existing systems. -* Creation of new systems. -* Impacts to the customer. -* Include code samples where possible. - -### Metrics & Alerts - -List any Metrics / Alerts that you plan to include in the system design - -### Alternatives Considered - -List any alternative solutions considered and how the proposed solution is a better fit. - -### Non-Goals - -List out anything that may be related to the solution, but won't be covered by this solution. - -### Future Improvements: - -List out anything that won't be included in this version of the feature/solution, but could be revisited or iterated upon in the future. - -### Other Considerations: - -List anything else that won't be solved by the solution diff --git a/pkg/api/auth/cluster/jwt.go b/pkg/api/auth/cluster/jwt.go new file mode 100644 index 00000000..d807cc71 --- /dev/null +++ b/pkg/api/auth/cluster/jwt.go @@ -0,0 +1,54 @@ +// Copyright 2024 Defense Unicorns +// SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Defense-Unicorns-Commercial + +package cluster + +import ( + "net/http" + "strings" + + "github.com/golang-jwt/jwt/v5" +) + +var allowedGroups = []string{ + "/UDS Core/Admin", + "/UDS Core/Auditor", +} + +// ValidateJWT checks if the request has a valid JWT token with the required groups. +func ValidateJWT(w http.ResponseWriter, r *http.Request) bool { + authHeader := r.Header.Get("Authorization") + + if authHeader == "" { + http.Error(w, "Missing Authorization header", http.StatusUnauthorized) + return false + } + + tokenString := strings.TrimPrefix(authHeader, "Bearer ") + + // parse the JWT token without validation (authservice will validate it, we only need the groups here) + token, _, err := jwt.NewParser(jwt.WithoutClaimsValidation()).ParseUnverified(tokenString, jwt.Claims(jwt.MapClaims{})) + if err != nil { + http.Error(w, "Invalid token", http.StatusUnauthorized) + return false + } + + // Check if the token contains a "groups" claim + if groups, ok := token.Claims.(jwt.MapClaims)["groups"].([]interface{}); ok { + // Check if any of the token's groups match the allowed groups + for _, group := range groups { + for _, allowedGroup := range allowedGroups { + if group == allowedGroup { + // Group is allowed + return true + } + } + } + // If we reach here, no matching group was found + http.Error(w, "Insufficient permissions", http.StatusForbidden) + return false + } + + http.Error(w, "Invalid token claims", http.StatusUnauthorized) + return false +} diff --git a/pkg/api/auth/session_test.go b/pkg/api/auth/cluster/jwt_test.go similarity index 58% rename from pkg/api/auth/session_test.go rename to pkg/api/auth/cluster/jwt_test.go index 556cdb7b..becc335c 100644 --- a/pkg/api/auth/session_test.go +++ b/pkg/api/auth/cluster/jwt_test.go @@ -3,7 +3,7 @@ //go:build unit -package auth +package cluster import ( "net/http" @@ -14,47 +14,7 @@ import ( "github.com/stretchr/testify/require" ) -func TestStoreSession(t *testing.T) { - storage := NewInMemoryStorage() - sessionID := "test-session-id" - - storage.StoreSession(sessionID) - - require.Equal(t, sessionID, storage.sessionID, "expected sessionID to be stored correctly") -} - -func TestValidateSession(t *testing.T) { - storage := NewInMemoryStorage() - sessionID := "test-session-id" - - storage.StoreSession(sessionID) - - require.True(t, storage.ValidateSession(sessionID), "expected sessionID to be valid") - - invalidSessionID := "invalid-session-id" - require.False(t, storage.ValidateSession(invalidSessionID), "expected invalid sessionID to be invalid") -} - -func TestRemoveSession(t *testing.T) { - storage := NewInMemoryStorage() - sessionID := "test-session-id" - - storage.StoreSession(sessionID) - storage.RemoveSession() - - require.Empty(t, storage.sessionID, "expected sessionID to be empty after removal") - require.False(t, storage.ValidateSession(sessionID), "expected sessionID to be invalid after removal") -} - -func TestRequireJWT(t *testing.T) { - // Create a sample handler that the middleware will wrap - nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - }) - - // Create the middleware - middleware := RequireJWT(nextHandler) - +func TestValidateJWT(t *testing.T) { // Helper function to create a JWT token without signing createToken := func(groups []string) string { claims := jwt.MapClaims{ @@ -75,6 +35,11 @@ func TestRequireJWT(t *testing.T) { token: createToken([]string{"/UDS Core/Admin"}), expectedStatus: http.StatusOK, }, + { + name: "Valid token with another allowed group", + token: createToken([]string{"/UDS Core/Auditor"}), + expectedStatus: http.StatusOK, + }, { name: "Valid token without allowed group", token: createToken([]string{"guest"}), @@ -108,11 +73,15 @@ func TestRequireJWT(t *testing.T) { // Create a ResponseRecorder to record the response rr := httptest.NewRecorder() - // Call the middleware - middleware.ServeHTTP(rr, req) + // Call the function directly + result := ValidateJWT(rr, req) // Check the status code require.Equal(t, tt.expectedStatus, rr.Code, "handler returned wrong status code") + + // Check the return value + expectedResult := tt.expectedStatus == http.StatusOK + require.Equal(t, expectedResult, result, "ValidateJWT returned unexpected result") }) } } diff --git a/pkg/api/auth/configure.go b/pkg/api/auth/configure.go new file mode 100644 index 00000000..aedd7508 --- /dev/null +++ b/pkg/api/auth/configure.go @@ -0,0 +1,70 @@ +// Copyright 2024 Defense Unicorns +// SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Defense-Unicorns-Commercial + +package auth + +import ( + "crypto/rand" + "log/slog" + "os" + "strconv" + "strings" + + "github.com/defenseunicorns/uds-runtime/pkg/config" +) + +// LocalAuthToken is the token used for local auth +var LocalAuthToken = "" + +// Configure sets the config vars for local or in-cluster auth +func Configure() { + // check for local auth first + localAuthEnabled, err := strconv.ParseBool(strings.ToLower(os.Getenv("LOCAL_AUTH_ENABLED"))) + if err != nil { + slog.Warn("invalid value for LocalAuthEnabled, must be 'true' or 'false'. Defaulting to 'true'") + localAuthEnabled = true + } + + config.LocalAuthEnabled = localAuthEnabled + if localAuthEnabled { + slog.Info("Local auth enabled") + token, err := randomString(96) + if err != nil { + slog.Error("Failed to generate local auth token") + os.Exit(1) + } + LocalAuthToken = token + return + } + + // If local auth is disabled, check for in-cluster auth + inClusterAuthEnabled, err := strconv.ParseBool(strings.ToLower(os.Getenv("IN_CLUSTER_AUTH_ENABLED"))) + if err != nil { + slog.Warn("invalid value for InClusterAuthEnabled, must be 'true' or 'false'. Defaulting to 'false'") + inClusterAuthEnabled = false + } + + if inClusterAuthEnabled { + config.InClusterAuthEnabled = inClusterAuthEnabled + slog.Info("In-cluster auth enabled") + } +} + +// Very limited special chars for git / basic auth +// https://owasp.org/www-community/password-special-characters has complete list of safe chars. +const randomStringChars = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ~-" + +// randomString generates a secure random string of the specified length. +func randomString(length int) (string, error) { + bytes := make([]byte, length) + + if _, err := rand.Read(bytes); err != nil { + return "", err + } + + for i, b := range bytes { + bytes[i] = randomStringChars[b%byte(len(randomStringChars))] + } + + return string(bytes), nil +} diff --git a/pkg/api/auth/local/session.go b/pkg/api/auth/local/session.go new file mode 100644 index 00000000..101bf2a7 --- /dev/null +++ b/pkg/api/auth/local/session.go @@ -0,0 +1,103 @@ +// Copyright 2024 Defense Unicorns +// SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Defense-Unicorns-Commercial + +package local + +import ( + "crypto/rand" + "encoding/hex" + "net/http" + "sync" + + "github.com/defenseunicorns/uds-runtime/pkg/api/auth" + "github.com/defenseunicorns/uds-runtime/pkg/config" +) + +type BrowserSession struct { + sessionID string + mutex sync.RWMutex +} + +func NewBrowserSession() *BrowserSession { + return &BrowserSession{} +} + +func (s *BrowserSession) Store(sessionID string) { + s.mutex.Lock() + defer s.mutex.Unlock() + + // Replace the old session with the new one + s.sessionID = sessionID +} + +func (s *BrowserSession) Validate(sessionID string) bool { + s.mutex.RLock() + defer s.mutex.RUnlock() + + // Check if the provided sessionID matches the stored session + return s.sessionID == sessionID +} + +func (s *BrowserSession) Remove() { + s.mutex.Lock() + defer s.mutex.Unlock() + + // Clear the session + s.sessionID = "" +} + +// session is a global variable that holds the current session +var session = NewBrowserSession() + +// AuthHandler handle validating tokens and session cookies for local authentication +func AuthHandler(w http.ResponseWriter, r *http.Request) { + if config.LocalAuthEnabled { + token := r.URL.Query().Get("token") + if token == "" { + // Handle session cookie validation + if valid := ValidateSessionCookie(w, r); valid { + w.WriteHeader(http.StatusOK) + return + } + w.WriteHeader(http.StatusUnauthorized) + return + } else if token != auth.LocalAuthToken { + w.WriteHeader(http.StatusUnauthorized) + return + } + // valid token, generate session id and set cookie + sessionID := generateSessionID(w) + session.Store(sessionID) + http.SetCookie(w, &http.Cookie{ + Name: "session_id", + Value: sessionID, + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteStrictMode, + Path: "/", + }) + w.WriteHeader(http.StatusOK) + } + + // not using local auth, return ok + w.WriteHeader(http.StatusOK) +} + +func ValidateSessionCookie(w http.ResponseWriter, r *http.Request) bool { + // Retrieve the session cookie + cookie, err := r.Cookie("session_id") + if err != nil || !session.Validate(cookie.Value) { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return false + } + return true +} + +func generateSessionID(w http.ResponseWriter) string { + bytes := make([]byte, 16) // 16 bytes = 128 bits + if _, err := rand.Read(bytes); err != nil { + http.Error(w, "Failed to generate session ID", http.StatusInternalServerError) + return "" + } + return hex.EncodeToString(bytes) +} diff --git a/pkg/api/auth/local/session_test.go b/pkg/api/auth/local/session_test.go new file mode 100644 index 00000000..4e700129 --- /dev/null +++ b/pkg/api/auth/local/session_test.go @@ -0,0 +1,99 @@ +// Copyright 2024 Defense Unicorns +// SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Defense-Unicorns-Commercial + +package local + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/defenseunicorns/uds-runtime/pkg/api/auth" + "github.com/defenseunicorns/uds-runtime/pkg/config" + "github.com/stretchr/testify/require" +) + +func TestStoreSession(t *testing.T) { + storage := NewBrowserSession() + sessionID := "test-session-id" + + storage.Store(sessionID) + + require.Equal(t, sessionID, storage.sessionID, "expected sessionID to be stored correctly") +} + +func TestValidateSession(t *testing.T) { + storage := NewBrowserSession() + sessionID := "test-session-id" + + storage.Store(sessionID) + + require.True(t, storage.Validate(sessionID), "expected sessionID to be valid") + + invalidSessionID := "invalid-session-id" + require.False(t, storage.Validate(invalidSessionID), "expected invalid sessionID to be invalid") +} + +func TestRemoveSession(t *testing.T) { + storage := NewBrowserSession() + sessionID := "test-session-id" + + storage.Store(sessionID) + storage.Remove() + + require.Empty(t, storage.sessionID, "expected sessionID to be empty after removal") + require.False(t, storage.Validate(sessionID), "expected sessionID to be invalid after removal") +} + +func TestAuthHandler(t *testing.T) { + // Save the original values and restore them after the test + originalLocalAuthEnabled := config.LocalAuthEnabled + originalLocalAuthToken := auth.LocalAuthToken + defer func() { + config.LocalAuthEnabled = originalLocalAuthEnabled + auth.LocalAuthToken = originalLocalAuthToken + }() + + config.LocalAuthEnabled = true + auth.LocalAuthToken = "test-token" + + tests := []struct { + name string + token string + expectedStatus int + withCookie bool + }{ + {name: "Valid token", token: "test-token", expectedStatus: http.StatusOK, withCookie: false}, + {name: "Invalid token", token: "wrong-token", expectedStatus: http.StatusUnauthorized, withCookie: false}, + {name: "No token", token: "", expectedStatus: http.StatusUnauthorized, withCookie: false}, + {name: "Valid cookie", token: "", expectedStatus: http.StatusOK, withCookie: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req, err := http.NewRequest("GET", "/auth", nil) + require.NoError(t, err) + + // if token provided, add it to the URL as a query param + if tt.token != "" { + q := req.URL.Query() + q.Add("token", tt.token) + req.URL.RawQuery = q.Encode() + } + + // create cookie if specified + if tt.withCookie { + sessionID := generateSessionID(httptest.NewRecorder()) + session.Store(sessionID) + req.AddCookie(&http.Cookie{Name: "session_id", Value: sessionID}) + } + + rr := httptest.NewRecorder() + handler := http.HandlerFunc(AuthHandler) + + handler.ServeHTTP(rr, req) + + require.Equal(t, tt.expectedStatus, rr.Code, "handler returned wrong status code") + }) + } +} diff --git a/pkg/api/auth/random.go b/pkg/api/auth/random.go deleted file mode 100644 index bdc8ebf2..00000000 --- a/pkg/api/auth/random.go +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright 2024 Defense Unicorns -// SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Defense-Unicorns-Commercial - -package auth - -import ( - "crypto/rand" -) - -// Very limited special chars for git / basic auth -// https://owasp.org/www-community/password-special-characters has complete list of safe chars. -const randomStringChars = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ~-" - -// RandomString generates a secure random string of the specified length. -func RandomString(length int) (string, error) { - bytes := make([]byte, length) - - if _, err := rand.Read(bytes); err != nil { - return "", err - } - - for i, b := range bytes { - bytes[i] = randomStringChars[b%byte(len(randomStringChars))] - } - - return string(bytes), nil -} diff --git a/pkg/api/handlers.go b/pkg/api/handlers.go index 2cdcc0de..5f381f16 100644 --- a/pkg/api/handlers.go +++ b/pkg/api/handlers.go @@ -6,6 +6,7 @@ package api import ( "net/http" + "github.com/defenseunicorns/uds-runtime/pkg/api/auth/local" _ "github.com/defenseunicorns/uds-runtime/pkg/api/docs" //nolint:staticcheck "github.com/defenseunicorns/uds-runtime/pkg/api/resources" "github.com/defenseunicorns/uds-runtime/pkg/api/rest" @@ -867,6 +868,14 @@ func getStorageClass(cache *resources.Cache) func(w http.ResponseWriter, r *http // @Produce json // @Success 200 // @Router /health [get] -func checkClusteConnection(k8sSession *session.K8sSession) http.HandlerFunc { +func checkClusterConnection(k8sSession *session.K8sSession) http.HandlerFunc { return k8sSession.ServeConnStatus() } + +// @Description Handle auth when running in local mode +// @Tags auth +// @Success 200 +// @Router /auth [head] +func authHandler(w http.ResponseWriter, r *http.Request) { + local.AuthHandler(w, r) +} diff --git a/pkg/api/middleware/api_auth.go b/pkg/api/middleware/api_auth.go deleted file mode 100644 index b3fb2b9d..00000000 --- a/pkg/api/middleware/api_auth.go +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright 2024 Defense Unicorns -// SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Defense-Unicorns-Commercial - -package middleware - -import ( - "net/http" - - "github.com/defenseunicorns/uds-runtime/pkg/api/auth" -) - -func ValidateSession(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - auth.ValidateSessionCookie(next, w, r) - }) -} diff --git a/pkg/api/middleware/auth.go b/pkg/api/middleware/auth.go new file mode 100644 index 00000000..b5e711e1 --- /dev/null +++ b/pkg/api/middleware/auth.go @@ -0,0 +1,42 @@ +// Copyright 2024 Defense Unicorns +// SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Defense-Unicorns-Commercial + +package middleware + +import ( + "net/http" + "strings" + + clusterAuth "github.com/defenseunicorns/uds-runtime/pkg/api/auth/cluster" + localAuth "github.com/defenseunicorns/uds-runtime/pkg/api/auth/local" + "github.com/defenseunicorns/uds-runtime/pkg/config" +) + +// Auth is a middleware that handles all API authentication for UDS Runtime +func Auth(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // allow list endpoints (used for local auth only) + apiAllowList := []string{ + "/api/v1/auth", + } + if config.LocalAuthEnabled { + // check if the request is in the allow list + if strings.HasPrefix(r.URL.Path, "/api/") { + for _, path := range apiAllowList { + if r.URL.Path == path { + next.ServeHTTP(w, r) // path allowed + return + } + } + if valid := localAuth.ValidateSessionCookie(w, r); valid { + next.ServeHTTP(w, r) + } + } + } else if config.InClusterAuthEnabled { + if valid := clusterAuth.ValidateJWT(w, r); valid { + next.ServeHTTP(w, r) + } + } + next.ServeHTTP(w, r) + }) +} diff --git a/pkg/api/start.go b/pkg/api/start.go index c10f9b5f..c3c16268 100644 --- a/pkg/api/start.go +++ b/pkg/api/start.go @@ -10,9 +10,8 @@ import ( "io" "io/fs" "log" + "log/slog" "net/http" - "os" - "strings" "github.com/defenseunicorns/pkg/exec" @@ -21,6 +20,7 @@ import ( udsMiddleware "github.com/defenseunicorns/uds-runtime/pkg/api/middleware" "github.com/defenseunicorns/uds-runtime/pkg/api/monitor" "github.com/defenseunicorns/uds-runtime/pkg/api/resources" + "github.com/defenseunicorns/uds-runtime/pkg/config" "github.com/defenseunicorns/uds-runtime/pkg/k8s/session" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" @@ -33,8 +33,8 @@ import ( // @BasePath /api/v1 // @schemes http https func Setup(assets *embed.FS) (*chi.Mux, bool, error) { - var apiAuth bool - var token string + // configure config vars for local or in-cluster auth + auth.Configure() // Create a k8s session k8sSession, err := session.CreateK8sSession() @@ -45,58 +45,33 @@ func Setup(assets *embed.FS) (*chi.Mux, bool, error) { inCluster := k8sSession.InCluster if !inCluster { - apiAuth, token, err = checkForLocalAuth() - if err != nil { - return nil, inCluster, fmt.Errorf("failed to set auth: %w", err) - } - // Start the cluster monitoring goroutine go k8sSession.StartClusterMonitoring() } - authSVC := checkForClusterAuth() - r := chi.NewRouter() - r.Use(udsMiddleware.ConditionalCompress) + // Add middleware r.Use(middleware.Logger) r.Use(middleware.Recoverer) - - if authSVC { - r.Use(auth.RequireJWT) - } - - // Middleware chain for api token authentication - apiAuthMiddleware := func(next http.Handler) http.Handler { - if apiAuth { - return udsMiddleware.ValidateSession(next) - } - return next - } + r.Use(udsMiddleware.Auth) + r.Use(udsMiddleware.ConditionalCompress) // Add Swagger UI routes r.Get("/swagger", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/swagger/index.html", http.StatusMovedPermanently) }) r.Get("/swagger/*", httpSwagger.WrapHandler) - r.Get("/health", checkClusteConnection(k8sSession)) + r.Get("/health", checkClusterConnection(k8sSession)) r.Route("/api/v1", func(r chi.Router) { - // Require a valid token for API calls - if apiAuth { - // If api auth is enabled, require a valid token for all routes under /api/v1 - // authenticate token - r.With(auth.TokenAuthenticator(token)).Head("/api-auth", func(_ http.ResponseWriter, _ *http.Request) {}) - } else { - r.Head("/api-auth", func(_ http.ResponseWriter, _ *http.Request) {}) - } - - r.With(apiAuthMiddleware).Route("/monitor", func(r chi.Router) { + r.Head("/auth", authHandler) + r.Route("/monitor", func(r chi.Router) { r.Get("/pepr/", monitor.Pepr) r.Get("/pepr/{stream}", monitor.Pepr) r.Get("/cluster-overview", monitor.BindClusterOverviewHandler(k8sSession.Cache)) }) - r.With(apiAuthMiddleware).Route("/resources", func(r chi.Router) { + r.Route("/resources", func(r chi.Router) { r.Get("/nodes", withLatestCache(k8sSession, getNodes)) r.Get("/nodes/{uid}", withLatestCache(k8sSession, getNode)) @@ -204,12 +179,12 @@ func Setup(assets *embed.FS) (*chi.Mux, bool, error) { }) }) - if apiAuth { + if config.LocalAuthEnabled { port := "8443" host := "runtime-local.uds.dev" colorYellow := "\033[33m" colorReset := "\033[0m" - url := fmt.Sprintf("https://%s:%s?token=%s", host, port, token) + url := fmt.Sprintf("https://%s:%s?token=%s", host, port, auth.LocalAuthToken) log.Printf("%sRuntime API connection: %s%s", colorYellow, url, colorReset) err := exec.LaunchURL(url) if err != nil { @@ -277,13 +252,14 @@ func fileServer(r chi.Router, root http.FileSystem) error { func Serve(r *chi.Mux, localCert []byte, localKey []byte, inCluster bool) error { //nolint:gosec,govet if inCluster { - log.Println("Starting server on :8080") + slog.Info("Starting server in in-cluster mode on :8080") if err := http.ListenAndServe(":8080", r); err != nil { message.WarnErrf(err, "server failed to start: %s", err.Error()) return err } } else { + slog.Info("Starting server in local mode on :8443") // create tls config from embedded cert and key cert, err := tls.X509KeyPair(localCert, localKey) if err != nil { @@ -300,7 +276,6 @@ func Serve(r *chi.Mux, localCert []byte, localKey []byte, inCluster bool) error TLSConfig: tlsConfig, } - log.Println("Starting server on :8443") if err = server.ListenAndServeTLS("", ""); err != nil { message.WarnErrf(err, "server failed to start: %s", err.Error()) return err @@ -310,35 +285,6 @@ func Serve(r *chi.Mux, localCert []byte, localKey []byte, inCluster bool) error return nil } -func checkForLocalAuth() (bool, string, error) { - apiAuth := true - if strings.ToLower(os.Getenv("API_AUTH_DISABLED")) == "true" { - apiAuth = false - } - - // If the env variable API_TOKEN is set, use that for the API secret - token := os.Getenv("API_TOKEN") - var err error - // Otherwise, generate a random secret - if token == "" { - token, err = auth.RandomString(96) - if err != nil { - return true, "", fmt.Errorf("failed to generate random string: %w", err) - } - } - - return apiAuth, token, nil -} - -func checkForClusterAuth() bool { - authSVC := false - if strings.ToLower(os.Getenv("AUTH_SVC_ENABLED")) == "true" { - authSVC = true - } - - return authSVC -} - // withLatestCache returns a wrapper lambda function, creating a closure that can dynamically access the latest cache func withLatestCache(k8sSession *session.K8sSession, handler func(cache *resources.Cache) func(w http.ResponseWriter, r *http.Request)) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { diff --git a/pkg/config/config.go b/pkg/config/config.go new file mode 100644 index 00000000..0aaaf827 --- /dev/null +++ b/pkg/config/config.go @@ -0,0 +1,10 @@ +// Copyright 2024 Defense Unicorns +// SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Defense-Unicorns-Commercial + +// Package config contains configuration for the application. +package config + +var ( + LocalAuthEnabled = true + InClusterAuthEnabled = false +) diff --git a/pkg/test/api_test.go b/pkg/test/api_test.go index 333cd3c8..3211292b 100644 --- a/pkg/test/api_test.go +++ b/pkg/test/api_test.go @@ -29,13 +29,13 @@ type TestRoute struct { } func setup() (*chi.Mux, error) { - os.Setenv("API_AUTH_DISABLED", "true") + os.Setenv("LOCAL_AUTH_ENABLED", "false") r, _, err := api.Setup(nil) return r, err } func teardown() { - os.Setenv("API_AUTH_DISABLED", "false") + os.Setenv("LOCAL_AUTH_ENABLED", "true") } func TestQueryParams(t *testing.T) { diff --git a/tasks/test.yaml b/tasks/test.yaml index f59cbec9..b5da3a0c 100644 --- a/tasks/test.yaml +++ b/tasks/test.yaml @@ -6,7 +6,7 @@ includes: - build: ./build.yaml tasks: - - name: api-auth + - name: local-auth description: "run end-to-end tests (assumes api server is running on port 8080)" actions: - task: build:ui @@ -14,11 +14,11 @@ tasks: dir: ui - task: build:api - task: deploy-runtime-cluster - - cmd: npm run test:api-auth + - cmd: npm run test:local-auth dir: ui - name: deploy-runtime-cluster - description: deploy cluster specifically for testing the app + description: deploy cluster specifically for testing UDS Runtime actions: - task: setup:k3d - task: deploy-load diff --git a/ui/package.json b/ui/package.json index 19b934c7..7d97bf5d 100644 --- a/ui/package.json +++ b/ui/package.json @@ -12,7 +12,7 @@ "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "lint": "prettier --check . && eslint .", "format": "prettier --write .", - "test:api-auth": "playwright test tests/api-auth.spec.ts --config=playwright.config.apiauth.ts", + "test:local-auth": "playwright test tests/local-auth.spec.ts --config=playwright.config.local-auth.ts", "test:integration": "playwright test", "test:install": "playwright install", "test:unit": "vitest run" diff --git a/ui/playwright.config.apiauth.ts b/ui/playwright.config.local-auth.ts similarity index 93% rename from ui/playwright.config.apiauth.ts rename to ui/playwright.config.local-auth.ts index 926e3d97..4d629e4e 100644 --- a/ui/playwright.config.apiauth.ts +++ b/ui/playwright.config.local-auth.ts @@ -11,7 +11,7 @@ export default defineConfig({ timeout: 60 * 1000, testDir: 'tests', fullyParallel: false, - retries: 0, + retries: process.env.CI ? 2 : 1, testMatch: /(.+\.)?(test|spec)\.[jt]s/, use: { baseURL: `https://runtime-local.uds.dev:${port}/`, diff --git a/ui/playwright.config.ts b/ui/playwright.config.ts index 65685f85..c4d244f5 100644 --- a/ui/playwright.config.ts +++ b/ui/playwright.config.ts @@ -13,7 +13,7 @@ const host = 'runtime-local.uds.dev' export default defineConfig({ webServer: { - command: 'API_AUTH_DISABLED=true ../build/uds-runtime', + command: 'LOCAL_AUTH_ENABLED=false ../build/uds-runtime', url: `${protocol}://${host}:${port}`, reuseExistingServer: !process.env.CI, }, @@ -22,7 +22,7 @@ export default defineConfig({ /* Run tests in files in parallel */ fullyParallel: true, retries: process.env.CI ? 2 : 1, - testMatch: /^(?!.*api-auth)(.+\.)?(test|spec)\.[jt]s$/, + testMatch: /^(?!.*local-auth)(.+\.)?(test|spec)\.[jt]s$/, use: { baseURL: `${protocol}://${host}:${port}/`, }, diff --git a/ui/src/lib/components/k8s/DataTable/component.svelte b/ui/src/lib/components/k8s/DataTable/component.svelte index cdcabddd..2f57e210 100644 --- a/ui/src/lib/components/k8s/DataTable/component.svelte +++ b/ui/src/lib/components/k8s/DataTable/component.svelte @@ -81,7 +81,6 @@ } catch (e) { // If an error occurs, set the resource to null resource = null - // Display an error toast if the fetch fails addToast({ timeoutSecs: 5, diff --git a/ui/src/lib/features/api-auth/store.ts b/ui/src/lib/features/auth/store.ts similarity index 100% rename from ui/src/lib/features/api-auth/store.ts rename to ui/src/lib/features/auth/store.ts diff --git a/ui/src/lib/features/k8s/namespaces/component.test.ts b/ui/src/lib/features/k8s/namespaces/component.test.ts index 3f49531d..50c2476c 100644 --- a/ui/src/lib/features/k8s/namespaces/component.test.ts +++ b/ui/src/lib/features/k8s/namespaces/component.test.ts @@ -40,12 +40,6 @@ vi.mock('svelte/store', () => { update: vi.fn(), } }), - get: vi.fn((key) => { - if (key === 'apiAuthEnabled') { - return false - } - return true // Default return value for other keys - }), } }) diff --git a/ui/src/lib/features/k8s/store.ts b/ui/src/lib/features/k8s/store.ts index 112a881f..d0941637 100644 --- a/ui/src/lib/features/k8s/store.ts +++ b/ui/src/lib/features/k8s/store.ts @@ -177,12 +177,7 @@ export class ResourceStore impl } /** - * Start the EventSource and update the resources - * - * @param url The URL to the EventSource - * @param createTableCallback The callback to create the table from the resources - * - * @returns A function to stop the EventSource + * Start the store and update resources */ start() { if (this.#initialized) { diff --git a/ui/src/lib/features/navigation/navbar/component.svelte b/ui/src/lib/features/navigation/navbar/component.svelte index bc2905e8..e5e8ba04 100644 --- a/ui/src/lib/features/navigation/navbar/component.svelte +++ b/ui/src/lib/features/navigation/navbar/component.svelte @@ -2,7 +2,7 @@