Skip to content

Commit

Permalink
chore: put back python auth service
Browse files Browse the repository at this point in the history
  • Loading branch information
olevski committed Apr 17, 2024
1 parent c280a57 commit 6d73d44
Show file tree
Hide file tree
Showing 34 changed files with 3,911 additions and 29 deletions.
11 changes: 11 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,17 @@ jobs:
if: "!startsWith(github.ref, 'refs/tags/') && github.ref != 'refs/heads/master'"

test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@master
- uses: actions/setup-go@v3
with:
go-version: 1.21
- name: Test
run: |
make auth_tests
test-revproxy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@master
Expand Down
7 changes: 6 additions & 1 deletion .github/workflows/codeql-analysis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ jobs:
contents: read
security-events: write

strategy:
fail-fast: false
matrix:
language: [ 'python', 'go' ]

steps:
- name: Checkout repository
uses: actions/checkout@v2
Expand All @@ -37,7 +42,7 @@ jobs:
- name: Initialize CodeQL
uses: github/codeql-action/init@v1
with:
languages: "go"
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
Expand Down
14 changes: 0 additions & 14 deletions .vscode/settings.json

This file was deleted.

31 changes: 20 additions & 11 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,13 +1,22 @@
FROM golang:1.21.6-alpine3.19 as builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY cmd/gateway cmd/gateway
COPY internal internal
RUN go build -o /gateway github.com/SwissDataScienceCenter/renku-gateway/cmd/gateway
FROM python:3.11-slim-bookworm as builder
WORKDIR /code
RUN pip install --upgrade pip && \
pip install poetry && \
virtualenv .venv
COPY pyproject.toml poetry.lock ./
RUN poetry install --without dev --no-root
COPY app ./app
RUN poetry install --without dev

FROM alpine:3.19
FROM python:3.11-slim-bookworm
WORKDIR /code
ENV TINI_VERSION v0.19.0
ADD https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini /tini
RUN chmod a+x /tini && \
addgroup renku --gid 1000 && \
adduser renku --uid 1000 --gid 1000
COPY --chown=1000:1000 --from=builder /code/.venv .venv
COPY --chown=1000:1000 --from=builder /code/app app
USER 1000:1000
COPY --from=builder /gateway /gateway
ENTRYPOINT [ "/gateway" ]

ENTRYPOINT [ "/tini", "-g", "--", "./.venv/bin/gunicorn", "-b", "0.0.0.0:5000", "app:app" ]
EXPOSE 5000
13 changes: 13 additions & 0 deletions Dockerfile.revproxy
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
FROM golang:1.21.6-alpine3.19 as builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY cmd/revproxy cmd/revproxy
COPY internal internal
RUN go build -o /revproxy github.com/SwissDataScienceCenter/renku-gateway/cmd/revproxy

FROM alpine:3.19
USER 1000:1000
COPY --from=builder /revproxy /revproxy
ENTRYPOINT [ "/revproxy" ]

4 changes: 4 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ PKG_NAME=github.com/SwissDataScienceCenter/renku-gateway

.PHONY: build clean tests

auth_tests:
poetry run flake8 -v
poetry run pytest

build: internal/login/spec.gen.go
go mod download
go build -o gateway $(PKG_NAME)/cmd/gateway
Expand Down
277 changes: 277 additions & 0 deletions app/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,277 @@
# -*- coding: utf-8 -*-
#
# Copyright 2017-2019 - Swiss Data Science Center (SDSC)
# A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and
# Eidgenössische Technische Hochschule Zürich (ETHZ).
#
# 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.
"""Quart initialization."""

import json
import logging
import os
import re
import sys

import jwt
import redis
import requests
import sentry_sdk
from flask import Flask, Response, current_app, jsonify, request
from flask_cors import CORS
from flask_kvsession import KVSessionExtension
from sentry_sdk.integrations.flask import FlaskIntegration
from simplekv.decorator import PrefixDecorator
from simplekv.memory.redisstore import RedisStore

from . import config
from .auth import (
cli_auth,
gitlab_auth,
renku_auth,
web,
notebook_auth,
keycloak_gitlab_auth,
search_auth,
)
from .auth.oauth_redis import OAuthRedis
from .auth.utils import decode_keycloak_jwt

# Wait for the VS Code debugger to attach if requested
# TODO: try using debugpy instead of ptvsd to avoid noreload limitations

VSCODE_DEBUG = os.environ.get("VSCODE_DEBUG") == "1"
if VSCODE_DEBUG:
import debugpy

print("Waiting for a debugger to attach")
# 5678 is the default attach port in the VS Code debug configurations
debugpy.listen(("localhost", 5678))
debugpy.wait_for_client()
breakpoint()

app = Flask(__name__, static_url_path="/")

# We activate all log levels and prevent logs from showing twice.
app.logger.setLevel(logging.DEBUG if config.DEBUG else logging.INFO)
app.logger.propagate = False

# Initialize Sentry when required
if os.environ.get("SENTRY_ENABLED", "").lower() == "true":
try:
sentry_sdk.init(
dsn=os.environ.get("SENTRY_DSN"),
integrations=[FlaskIntegration()],
environment=os.environ.get("SENTRY_ENVIRONMENT"),
sample_rate=float(os.environ.get("SENTRY_SAMPLE_RATE", 0.2)),
)
except Exception:
app.logger.warning("Error while trying to initialize Sentry", exc_info=True)

app.config.from_object(config)

CORS(
app,
allow_headers=["X-Requested-With"],
allow_origin=app.config["ALLOW_ORIGIN"],
)

url_prefix = app.config["SERVICE_PREFIX"]
blueprints = (
cli_auth.blueprint,
gitlab_auth.blueprint,
web.blueprint,
)


@app.before_first_request
def setup_redis_client():
"""Set up a redis connection to the master by querying sentinel."""

if "pytest" not in sys.modules:
_config = {
"db": current_app.config["REDIS_DB"],
"password": current_app.config["REDIS_PASSWORD"],
"retry_on_timeout": True,
"health_check_interval": 60,
}
if current_app.config["REDIS_IS_SENTINEL"]:
_sentinel = redis.Sentinel(
[(current_app.config["REDIS_HOST"], current_app.config["REDIS_PORT"])],
sentinel_kwargs={"password": current_app.config["REDIS_PASSWORD"]},
)
_client = _sentinel.master_for(
current_app.config["REDIS_MASTER_SET"], **_config
)
else:
_client = redis.Redis(
host=current_app.config["REDIS_HOST"],
port=current_app.config["REDIS_PORT"],
**_config,
)

# Set up the redis store for tokens
current_app.store = OAuthRedis(_client, current_app.config["SECRET_KEY"])
# We use the same store for sessions.
session_store = PrefixDecorator("sessions_", RedisStore(_client))
KVSessionExtension(session_store, app)


@app.route("/", methods=["GET"])
def auth():
if "auth" not in request.args:
return Response("", status=200)

auths = {
"gitlab": gitlab_auth.GitlabUserToken,
"renku": renku_auth.RenkuCoreAuthHeaders,
"notebook": notebook_auth.NotebookAuthHeaders,
"search": search_auth.SearchHeaders,
"cli-gitlab": cli_auth.RenkuCLIGitlabAuthHeaders,
"keycloak_gitlab": keycloak_gitlab_auth.KeycloakGitlabAuthHeaders,
}

# Keycloak public key is not defined so error
if current_app.config["OIDC_PUBLIC_KEY"] is None:
response = json.dumps("Ooops, something went wrong internally.")
return Response(response, status=500)

auth_arg = request.args.get("auth")
headers = dict(request.headers)

try:
auth = auths[auth_arg]()

# validate incoming authentication
# it can either be in session-cookie or Authorization header
new_token = web.get_valid_token(headers)
if new_token:
headers["Authorization"] = f"Bearer {new_token}"

if "Authorization" in headers and "Referer" in headers:
allowed = False
origins = decode_keycloak_jwt(token=headers["Authorization"][7:]).get(
"allowed-origins"
)
for o in origins:
if re.match(o.replace("*", ".*"), headers["Referer"]):
allowed = True
break
if not allowed:
return (
jsonify(
{
"error": "origin not allowed: {} not matching {}".format(
headers["Referer"], origins
)
}
),
403,
)

# auth processors always assume a valid Authorization in header, if any
headers = auth.process(request, headers)
except jwt.ExpiredSignatureError:
current_app.logger.warning(
f"Error while authenticating request, token expired. Target: {auth_arg}",
exc_info=True,
)
message = {
"error": "authentication",
"message": "token expired",
"target": auth_arg,
}
return jsonify(message), 401
except AttributeError as error:
if "access_token" in str(error):
current_app.logger.warning(
(
"Error while authenticating request, can't "
f"refresh access token. Target: {auth_arg}"
),
exc_info=True,
)
message = {
"error": "authentication",
"message": "can't refresh access token",
"target": auth_arg,
}
logging.error(error)
return jsonify(message), 401
raise
except Exception as error:
current_app.logger.warning(
f"Error while authenticating request, unknown. Target: {auth_arg}",
exc_info=True,
)
message = {
"error": "authentication",
"message": "unknown",
"target": auth_arg,
}
logging.error(error)
return jsonify(message), 401

if (
"anon-id" not in request.cookies
and request.headers.get("X-Requested-With", "") == "XMLHttpRequest"
and "Authorization" not in headers
):
resp = Response(
json.dumps({"message": "401 Unauthorized"}),
content_type="application/json",
status=401,
)
return resp

return Response(
json.dumps("Up and running"),
headers=headers,
status=200,
)


@app.route("/health", methods=["GET"])
def healthcheck():
return Response(json.dumps("Up and running"), status=200)


def _join_url_prefix(*parts):
"""Join prefixes."""
parts = [part.strip("/") for part in parts if part and part.strip("/")]
if parts:
return "/" + "/".join(parts)


for blueprint in blueprints:
app.register_blueprint(
blueprint,
url_prefix=_join_url_prefix(url_prefix, blueprint.url_prefix),
)


@app.before_request
def load_public_key():
if current_app.config.get("OIDC_PUBLIC_KEY"):
return

current_app.logger.info(
"Obtaining public key from {}".format(current_app.config["OIDC_ISSUER"])
)

raw_key = requests.get(current_app.config["OIDC_ISSUER"]).json()["public_key"]
current_app.config[
"OIDC_PUBLIC_KEY"
] = "-----BEGIN PUBLIC KEY-----\n{}\n-----END PUBLIC KEY-----".format(raw_key)

current_app.logger.info(current_app.config["OIDC_PUBLIC_KEY"])

0 comments on commit 6d73d44

Please sign in to comment.