diff --git a/Dockerfile b/Dockerfile index 126a006..6c21ee5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,6 +15,7 @@ COPY entrypoint.sh /entrypoint.sh COPY certs /certs COPY services /services COPY templates /templates +COPY plugins-local ./plugins-local # Create dynamic config directory and make entrypoint executable RUN mkdir -p /etc/traefik/dynamic @@ -56,6 +57,7 @@ FROM base AS prod RUN adduser -S -D -H appuser RUN chown -R appuser /app/ && \ chown -R appuser /etc/traefik/ && \ + chown -R appuser ./plugins-local/ && \ chown appuser /entrypoint.sh USER appuser diff --git a/entrypoint.sh b/entrypoint.sh index 1083260..8651c27 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -37,10 +37,19 @@ MEDIA_HOST="${MEDIA_HOST:-media}" MEDIA_PORT="${MEDIA_PORT:-9006}" MANAGE_HOST="${MANAGE_HOST:-manage}" MANAGE_PORT="${MANAGE_PORT:-9008}" +IDENTITY_HOST="${IDENTITY_HOST:-identity}" +IDENTITY_PORT="${IDENTITY_PORT:-9014}" VOTE_HOST="${VOTE_HOST:-vote}" VOTE_PORT="${VOTE_PORT:-9013}" CLIENT_HOST="${CLIENT_HOST:-client}" CLIENT_PORT="${CLIENT_PORT:-9001}" +KEYCLOAK_HOST="${KEYCLOAK_HOST:-keycloak-server}" +KEYCLOAK_HOST_PORT="${KEYCLOAK_HOST_PORT:-8080}" +OIDC_ISSUER_URL="${OIDC_ISSUER_URL:-http://localhost:8080/realms/openslides}" +OIDC_ISSUER_URL_DOCKER="${OIDC_ISSUER_URL_DOCKER:-http://keycloak-server:8080/realms/openslides}" +OIDC_CLIENT_ID="${OIDC_CLIENT_ID:-proxy-client}" +OIDC_CLIENT_SECRET="${OIDC_CLIENT_SECRET:-proxy-secret}" +OIDC_SECRET="${OIDC_SECRET:-qvAcTGWBIGg7aWKCKRyUsTf33jK3lsmK}" # ================================= @@ -50,6 +59,23 @@ CLIENT_PORT="${CLIENT_PORT:-9001}" # Generate base config from template envsubst < /templates/traefik.yml > "$TRAEFIK_CONFIG" +# Add OIDC plugin +echo "Adding OIDC Plugin" +cat >> "$TRAEFIK_CONFIG" << 'EOF' + +experimental: + plugins: + traefik-oidc-auth: + moduleName: github.com/sevensolutions/traefik-oidc-auth + version: v0.20.0 + localPlugins: + user_id_header: + moduleName: github.com/openslides/user_id_header + access_token_blocklist: + moduleName: github.com/openslides/access_token_blocklist +EOF + + # Add dashboard if enabled if [ -n "$ENABLE_DASHBOARD" ]; then echo "Enabling dashboard. 'debug: true' for now. NOT FOR PRODUCTION" @@ -174,6 +200,40 @@ for service in $SERVICES; do envsubst < "$SERVICES_DIR/${service}.service" >> "$DYNAMIC_CONFIG" done +# OIDC Middleware +echo "Enabling OIDC authentication middleware" + cat >> "$DYNAMIC_CONFIG" << EOF + + middlewares: + oidc-auth: + plugin: + traefik-oidc-auth: + Secret: "${OIDC_SECRET}" + LogLevel: DEBUG + Provider: + Url: "${OIDC_ISSUER_URL_DOCKER}" + ClientId: "${OIDC_CLIENT_ID}" + ClientSecret: "${OIDC_CLIENT_SECRET}" + ValidateIssuer: true + ValidIssuer: "${OIDC_ISSUER_URL}" + UnauthorizedBehavior: Forward + BypassAuthenticationRule: "PathPrefix(\`/\`)" + LoginUri: "/system/login" + LogoutUri: "/system/logout" + Headers: + - Name: "Authorization" + Value: "{{\`Bearer: {{ .accessToken }}\`}}" + IncludeWhen: "Public" + Scopes: ["openid", "profile", "email"] + user-id: + plugin: + user_id_header: {} + access-token-blocklist: + plugin: + access_token_blocklist: {} +EOF + +cat $DYNAMIC_CONFIG # Finally start CMD exec "$@" diff --git a/plugins-local/src/github.com/openslides/access_token_blocklist/.traefik.yml b/plugins-local/src/github.com/openslides/access_token_blocklist/.traefik.yml new file mode 100644 index 0000000..33a0a1a --- /dev/null +++ b/plugins-local/src/github.com/openslides/access_token_blocklist/.traefik.yml @@ -0,0 +1,5 @@ +displayName: Access Token Blocklist +type: middleware +import: github.com/openslides/access_token_blocklist +summary: Invalidates access tokens linked to a logout call +testData: {} diff --git a/plugins-local/src/github.com/openslides/access_token_blocklist/LICENSE b/plugins-local/src/github.com/openslides/access_token_blocklist/LICENSE new file mode 100644 index 0000000..da02d3d --- /dev/null +++ b/plugins-local/src/github.com/openslides/access_token_blocklist/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) Since 2011 Authors of OpenSlides, see https://github.com/OpenSlides/OpenSlides/blob/master/AUTHORS + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins-local/src/github.com/openslides/access_token_blocklist/go.mod b/plugins-local/src/github.com/openslides/access_token_blocklist/go.mod new file mode 100644 index 0000000..22258c5 --- /dev/null +++ b/plugins-local/src/github.com/openslides/access_token_blocklist/go.mod @@ -0,0 +1,3 @@ +module github.com/openslides/access_token_blocklist + +go 1.23.0 diff --git a/plugins-local/src/github.com/openslides/access_token_blocklist/plugin.go b/plugins-local/src/github.com/openslides/access_token_blocklist/plugin.go new file mode 100644 index 0000000..bd2b2e3 --- /dev/null +++ b/plugins-local/src/github.com/openslides/access_token_blocklist/plugin.go @@ -0,0 +1,151 @@ +package access_token_blocklist + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "strings" +) + +// Mandatory config struct +type Config struct { +} + +func CreateConfig() *Config { + return &Config{} +} + +type AccessTokenBlocklist struct { + next http.Handler + name string + blockedSessionIDs map[string]int + id int +} + +// Create new plugin instance +func New(ctx context.Context, next http.Handler, config *Config, name string) (http.Handler, error) { + return &AccessTokenBlocklist{ + next: next, + name: name, + blockedSessionIDs: make(map[string]int), + }, nil +} + +type responseWriter struct { + http.ResponseWriter + flusher http.Flusher + userID string + headerSet bool +} + +func (w *responseWriter) WriteHeader(code int) { + w.Header().Set(userIDHeader, w.userID) + w.headerSet = true + w.ResponseWriter.WriteHeader(code) +} + +func (w *responseWriter) Write(b []byte) (int, error) { + if !w.headerSet { + w.Header().Set(userIDHeader, w.userID) + } + + n, err := w.ResponseWriter.Write(b) + if w.flusher != nil { + w.flusher.Flush() + } + + return n, err +} + +func (w *responseWriter) Flush() { + if w.flusher != nil { + w.flusher.Flush() + } +} + +func extractSessionID(r *http.Request) string { + header := r.Header.Get("Authorization") + encodedToken := strings.TrimPrefix(header, "Bearer: ") + + if header == encodedToken { + // No token. Handle the request as public access requst. + fmt.Println("header error: no token") + return "" + } + + parts := strings.Split(encodedToken, ".") + if len(parts) != 3 { + fmt.Println("header error: JWT partition not length 3") + return "" + } + + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + fmt.Println("header error: decoding token payload:", err) + return "" + } + + var claims map[string]any + if err := json.Unmarshal(payload, &claims); err != nil { + fmt.Println("header error: parsing token claims:", err) + return "" + } + + sid, exists := claims["sid"] + if !exists { + fmt.Println("header error: missing sid in token claims") + return "" + } + + return sid.(string) +} + +func (a *AccessTokenBlocklist) blockSessionID(sessionID string) { + a.blockedSessionIDs[sessionID] = 1 +} + +func (a *AccessTokenBlocklist) isSessionIDBlocked(sessionID string) bool { + if _, exists := a.blockedSessionIDs[sessionID]; exists { + return true + } + return false +} + +func (a *AccessTokenBlocklist) ServeHTTP(w http.ResponseWriter, r *http.Request) { + sessionID := extractSessionID(r) + + if strings.HasSuffix(r.URL.Path, logoutRoute) { + a.blockSessionID(sessionID) + } + + var flusher http.Flusher + if f, ok := w.(http.Flusher); ok { + flusher = f + } + if a.isSessionIDBlocked(sessionID) { + // Write it as a new header + r.Header.Set(userIDHeader, "0") + + // Pass + a.next.ServeHTTP(&responseWriter{ + flusher: flusher, + ResponseWriter: w, + userID: "0", + }, r) + return + } + + // Pass + a.next.ServeHTTP(&responseWriter{ + flusher: flusher, + ResponseWriter: w, + }, r) + +} + +const ( + userIDHeader string = "X-User-ID" + logoutRoute string = "system/logout" +) diff --git a/plugins-local/src/github.com/openslides/user_id_header/.traefik.yml b/plugins-local/src/github.com/openslides/user_id_header/.traefik.yml new file mode 100644 index 0000000..574767f --- /dev/null +++ b/plugins-local/src/github.com/openslides/user_id_header/.traefik.yml @@ -0,0 +1,5 @@ +displayName: User ID Header Insert +type: middleware +import: github.com/openslides/user_id_header +summary: Appends user id to headers +testData: {} diff --git a/plugins-local/src/github.com/openslides/user_id_header/LICENSE b/plugins-local/src/github.com/openslides/user_id_header/LICENSE new file mode 100644 index 0000000..da02d3d --- /dev/null +++ b/plugins-local/src/github.com/openslides/user_id_header/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) Since 2011 Authors of OpenSlides, see https://github.com/OpenSlides/OpenSlides/blob/master/AUTHORS + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins-local/src/github.com/openslides/user_id_header/go.mod b/plugins-local/src/github.com/openslides/user_id_header/go.mod new file mode 100644 index 0000000..292ece6 --- /dev/null +++ b/plugins-local/src/github.com/openslides/user_id_header/go.mod @@ -0,0 +1,3 @@ +module github.com/openslides/user_id_header + +go 1.23.0 diff --git a/plugins-local/src/github.com/openslides/user_id_header/plugin.go b/plugins-local/src/github.com/openslides/user_id_header/plugin.go new file mode 100644 index 0000000..cc3ea95 --- /dev/null +++ b/plugins-local/src/github.com/openslides/user_id_header/plugin.go @@ -0,0 +1,130 @@ +package user_id_header + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" +) + +// Mandatory config struct +type Config struct { +} + +func CreateConfig() *Config { + return &Config{} +} + +type UserIDHeaderInsert struct { + next http.Handler + name string +} + +// Create new plugin instance +func New(ctx context.Context, next http.Handler, config *Config, name string) (http.Handler, error) { + return &UserIDHeaderInsert{ + next: next, + name: name, + }, nil +} + +func extractUserID(r *http.Request) int { + header := r.Header.Get("Authorization") + encodedToken := strings.TrimPrefix(header, "Bearer: ") + + if header == encodedToken { + // No token. Handle the request as public access requst. + fmt.Println("header error: no token") + return 0 + } + + parts := strings.Split(encodedToken, ".") + if len(parts) != 3 { + fmt.Println("header error: JWT partition not length 3") + return 0 + } + + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + fmt.Println("header error: decoding token payload:", err) + return 0 + } + + var claims map[string]any + if err := json.Unmarshal(payload, &claims); err != nil { + fmt.Println("header error: parsing token claims:", err) + return 0 + } + + rawOsID, exists := claims["os_id"] + if !exists { + fmt.Println("header error: missing os_id in token claims") + return 0 + } + + osID, err := strconv.Atoi(fmt.Sprintf("%v", rawOsID)) + if err != nil { + fmt.Println("header error: invalid os_id value:", rawOsID) + return 0 + } + return osID +} + +type responseWriter struct { + http.ResponseWriter + flusher http.Flusher + userID string + headerSet bool +} + +func (w *responseWriter) WriteHeader(code int) { + w.Header().Set(userIDHeader, w.userID) + w.headerSet = true + w.ResponseWriter.WriteHeader(code) +} + +func (w *responseWriter) Write(b []byte) (int, error) { + if !w.headerSet { + w.Header().Set(userIDHeader, w.userID) + } + + n, err := w.ResponseWriter.Write(b) + if w.flusher != nil { + w.flusher.Flush() + } + + return n, err +} + +func (w *responseWriter) Flush() { + if w.flusher != nil { + w.flusher.Flush() + } +} + +func (p *UserIDHeaderInsert) ServeHTTP(w http.ResponseWriter, r *http.Request) { + // Extract User ID + user_id := extractUserID(r) + + // Write it as a new header + r.Header.Set(userIDHeader, fmt.Sprint(user_id)) + + var flusher http.Flusher + if f, ok := w.(http.Flusher); ok { + flusher = f + } + + // Pass + p.next.ServeHTTP(&responseWriter{ + flusher: flusher, + ResponseWriter: w, + userID: fmt.Sprint(user_id), + }, r) +} + +const ( + userIDHeader string = "X-User-ID" +) diff --git a/services/action.router b/services/action.router index 8cdc6b6..4423e3b 100644 --- a/services/action.router +++ b/services/action.router @@ -3,3 +3,6 @@ service: action entryPoints: - main + middlewares: + - oidc-auth + - user-id diff --git a/services/auth.router b/services/auth.router index 0282964..4b2396d 100644 --- a/services/auth.router +++ b/services/auth.router @@ -3,3 +3,6 @@ service: auth entryPoints: - main + middlewares: + - oidc-auth + - user-id diff --git a/services/autoupdate.router b/services/autoupdate.router index 19a3806..ddae1c9 100644 --- a/services/autoupdate.router +++ b/services/autoupdate.router @@ -3,3 +3,6 @@ service: autoupdate entryPoints: - main + middlewares: + - oidc-auth + - user-id diff --git a/services/client.router b/services/client.router index 5064e01..63c501a 100644 --- a/services/client.router +++ b/services/client.router @@ -5,3 +5,6 @@ - main # Priority ensures this catch-all route is evaluated last priority: 1 + middlewares: + - oidc-auth + - user-id diff --git a/services/icc.router b/services/icc.router index eb7ac0f..67cd54e 100644 --- a/services/icc.router +++ b/services/icc.router @@ -3,3 +3,6 @@ service: icc entryPoints: - main + middlewares: + - oidc-auth + - user-id diff --git a/services/identity.router b/services/identity.router new file mode 100644 index 0000000..19a8e13 --- /dev/null +++ b/services/identity.router @@ -0,0 +1,8 @@ + identity: + rule: "PathPrefix(`/system/identity`)" + service: identity + entryPoints: + - main + middlewares: + - oidc-auth + - user-id diff --git a/services/identity.service b/services/identity.service new file mode 100644 index 0000000..09f8c08 --- /dev/null +++ b/services/identity.service @@ -0,0 +1,6 @@ + identity: + loadBalancer: + servers: + - url: "http://${IDENTITY_HOST}:${IDENTITY_PORT}" + # Forward the original Host header to the backend service + passHostHeader: true diff --git a/services/media.router b/services/media.router index c428375..567a417 100644 --- a/services/media.router +++ b/services/media.router @@ -3,3 +3,6 @@ service: media entryPoints: - main + middlewares: + - oidc-auth + - user-id diff --git a/services/presenter.router b/services/presenter.router index 62fd449..8610b88 100644 --- a/services/presenter.router +++ b/services/presenter.router @@ -3,3 +3,6 @@ service: presenter entryPoints: - main + middlewares: + - oidc-auth + - user-id diff --git a/services/projector.router b/services/projector.router index 5e43d5c..93dc66b 100644 --- a/services/projector.router +++ b/services/projector.router @@ -3,3 +3,6 @@ service: projector entryPoints: - main + middlewares: + - oidc-auth + - user-id diff --git a/services/search.router b/services/search.router index d6af74c..99cdf56 100644 --- a/services/search.router +++ b/services/search.router @@ -3,3 +3,6 @@ service: search entryPoints: - main + middlewares: + - oidc-auth + - user-id diff --git a/services/vote.router b/services/vote.router index a357117..b513a40 100644 --- a/services/vote.router +++ b/services/vote.router @@ -3,3 +3,6 @@ service: vote entryPoints: - main + middlewares: + - oidc-auth + - user-id