diff --git a/Dockerfile b/Dockerfile index 126a006..19119e6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,13 +8,14 @@ WORKDIR /app ENV APP_CONTEXT=${CONTEXT} # curl for healthcheck, gettext for templating (envsubst) -RUN apk add --no-cache curl gettext +RUN apk add --no-cache curl gettext jq # Copy configuration files 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..b36ed02 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -37,19 +37,104 @@ 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}" +IDP_HOST="${IDP_HOST:-zitadel-api}" +IDP_HOST_PORT="${IDP_HOST_PORT:-8080}" +INSTANCE_URL="${INSTANCE_URL:-https://localhost:8000}" +IDP_URL_EXTERNAL="${IDP_URL_EXTERNAL:-https://localhost:8800}" +IDP_URL_INTERNAL="${IDP_URL_INTERNAL:-http://zitadel-api:8080}" # ================================= # = Build static / install config = # ================================= +# Get Zitadel Client ID +IDP_PAT="$(cat /zitadel/bootstrap/admin.pat)" +IDP_CLIENT_ID="$(cat /zitadel/bootstrap/client-id)" +IDP_CLIENT_SECRET="$(cat /zitadel/bootstrap/client-secret)" + +# echo $IDP_PAT + +# Import Data + +#PAYLOAD=$(cat < "$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 +EOF + + # Add dashboard if enabled if [ -n "$ENABLE_DASHBOARD" ]; then echo "Enabling dashboard. 'debug: true' for now. NOT FOR PRODUCTION" @@ -82,6 +167,29 @@ elif [ -n "$ENABLE_AUTO_HTTPS" ]; then domains: - main: ${EXTERNAL_ADDRESS} certResolver: acmeResolver +EOF +fi + +# Add entryPoints in accordance to HTTPS related variables +cat >> "$TRAEFIK_CONFIG" << 'EOF' + idp: + address: ":8800" + http: +EOF + +if [ -n "$ENABLE_LOCAL_HTTPS" ]; then + # Define tls property, which will cause all routers to terminate TLS and + # foward decrypted traffic. + cat >> "$TRAEFIK_CONFIG" << 'EOF' + tls: {} +EOF +elif [ -n "$ENABLE_AUTO_HTTPS" ]; then + # Also needs tls property, but with additional information for cert retrieval + cat >> "$TRAEFIK_CONFIG" << EOF + tls: + domains: + - idp: ${IDP_EXTERNAL_ADDRESS} + certResolver: acmeResolver EOF # Additionally a plain HTTP endpoint to answer ACME challenges on must be # configured @@ -174,6 +282,62 @@ 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: + zitadel-cors: + headers: + accessControlAllowMethods: + - GET + - POST + - PUT + - DELETE + - OPTIONS + - PATCH + accessControlAllowHeaders: + - Authorization + - Content-Type + - X-CSRF-Token + - Accept + - Origin + - Link + accessControlAllowOriginList: + - "https://localhost:8000" + accessControlMaxAge: 600 + addVaryHeader: true + accessControlAllowCredentials: true + oidc-auth: + plugin: + traefik-oidc-auth: + LogLevel: DEBUG + Provider: + Url: "${IDP_URL_EXTERNAL}" + ClientId: "${IDP_CLIENT_ID}" + UsePkce: true + ValidateIssuer: true + ValidIssuer: "${IDP_URL_EXTERNAL}" + InsecureSkipVerify: true + UnauthorizedBehavior: Forward + LoginUri: "/login" + CallbackUri: "https://localhost:8000/" + LogoutUri: "/system/logout" + Headers: + - Name: "Authorization" + Value: "{{\`Bearer: {{ .accessToken }}\`}}" + IncludeWhen: "Public" + Scopes: ["openid", "profile", "email"] + Authorization: + AssertClaims: + - Name: roles + AnyOf: ["admin", "media"] + user-id: + plugin: + user_id_header: {} +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 deleted file mode 100644 index 0282964..0000000 --- a/services/auth.router +++ /dev/null @@ -1,5 +0,0 @@ - auth: - rule: "PathPrefix(`/system/auth`) || PathPrefix(`/system/saml`)" - service: auth - entryPoints: - - main diff --git a/services/auth.service b/services/auth.service deleted file mode 100644 index 6f9cda6..0000000 --- a/services/auth.service +++ /dev/null @@ -1,6 +0,0 @@ - auth: - loadBalancer: - servers: - - url: "http://${AUTH_HOST}:${AUTH_PORT}" - # Forward the original Host header to the backend service - passHostHeader: true 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/idp.router b/services/idp.router new file mode 100644 index 0000000..03a4ec7 --- /dev/null +++ b/services/idp.router @@ -0,0 +1,14 @@ + zitadel-api: + rule: "!PathPrefix(`/ui/v2/login`)" + service: zitadel-api + entryPoints: + - idp + middlewares: + - zitadel-cors + zitadel-login: + rule: "PathPrefix(`/ui/v2/login`)" + service: zitadel-login + entryPoints: + - idp + middlewares: + - zitadel-cors diff --git a/services/idp.service b/services/idp.service new file mode 100644 index 0000000..3a52905 --- /dev/null +++ b/services/idp.service @@ -0,0 +1,11 @@ + zitadel-api: + loadBalancer: + servers: + - url: "h2c://zitadel-api:8080" + # Forward the original Host header to the backend service + passHostHeader: true + zitadel-login: + loadBalancer: + servers: + - url: "http://zitadel-login:3000" + 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