diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..a839d58 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,28 @@ +name: build + +on: [push, pull_request] + +jobs: + build: + runs-on: ubuntu-latest + if: github.event_name == 'push' && contains(github.ref, 'refs/tags/') + steps: + + - uses: actions/checkout@master + + - name: Get version tag + id: get_tag + run: | + if [ ${{ endsWith(github.ref, '-lts') }} = true ]; then + echo "tag=latest-lts,latest-${GITHUB_REF:11:4}-lts,${GITHUB_REF:10}" >>$GITHUB_OUTPUT + else + echo "tag=latest,${GITHUB_REF:10}" >>$GITHUB_OUTPUT + fi + + - name: Publish to Registry + uses: elgohr/Publish-Docker-Github-Action@v5 + with: + name: sourcepole/${{ github.event.repository.name }} + username: ${{ secrets.DOCKER_HUB_USER }} + password: ${{ secrets.DOCKER_HUB_PASSWORD }} + tags: "${{ steps.get_tag.outputs.tag }}" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6849dc3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +# WSGI service environment +FROM sourcepole/qwc-uwsgi-base:alpine-v2024.01.16 + +ADD requirements.txt /srv/qwc_service/requirements.txt + +# git: Required for pip with git repos +# postgresql-dev g++ python3-dev: Required for psycopg2 +RUN \ + apk add --no-cache --update --virtual runtime-deps postgresql-libs && \ + apk add --no-cache --update --virtual build-deps git postgresql-dev g++ python3-dev && \ + pip3 install --no-cache-dir -r /srv/qwc_service/requirements.txt && \ + apk del build-deps + +ADD src /srv/qwc_service/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..43e63ab --- /dev/null +++ b/README.md @@ -0,0 +1,36 @@ +SOGIS my.so.ch Authentication +============================= + +Authenticates through a my.so.ch JWE token. + +The service will decrypt/decode the JWE, then: + +1. Check whether the `iss` claim of the token matches one of the configured `allowed_iss` +2. Extract the userid from the claims (first non-empty claim of the configured `userid_claims`) +3. Validate whether the userid exists using the configured `userid_verify_sql` query. +4. Issue a JWT for QWC + +Configuration +------------- + +See [sogis-mysoch-auth.json](./schemas/sogis-mysoch-auth.json) configuration schema. + +All configuration options can also be set with the respective UPPER_CASE environment variables. + +Usage/Development +----------------- + +Create and activate a virtual environment: + + python3 -m venv .venv + source .venv/bin/activate + +Install requirements: + + pip install -r requirements.txt + +### Usage + +Run standalone application: + + python src/server.py diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2f32833 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +Flask==3.0.0 +werkzeug==3.0.1 +Flask-JWT-Extended==4.6.0 +qwc-services-core==1.3.25 +psycopg2==2.9.9 +SQLAlchemy==2.0.29 +jwcrypto==1.5.6 diff --git a/schemas/sogis-mysoch-auth.json b/schemas/sogis-mysoch-auth.json new file mode 100644 index 0000000..d61efcc --- /dev/null +++ b/schemas/sogis-mysoch-auth.json @@ -0,0 +1,76 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://raw.githubusercontent.com/qwc-services/sogis-mysoch-auth/master/schemas/sogis-mysoch-auth.json", + "title": "SOGIS my.so.ch authentication service", + "type": "object", + "properties": { + "$schema": { + "title": "JSON Schema", + "description": "Reference to JSON schema of this config", + "type": "string", + "format": "uri", + "default": "https://raw.githubusercontent.com/qwc-services/sogis-mysoch-auth/master/schemas/sogis-mysoch-auth.json", + }, + "service": { + "title": "Service name", + "type": "string", + "const": "mysoch-auth" + }, + "config": { + "title": "Config options", + "type": "object", + "properties": { + "db_url": { + "description": "DB connection URL", + "type": "string" + }, + "jwe_secret": { + "description": "my.so.ch JWE secret key", + "type": "string" + }, + "jwt_secret": { + "description": "my.so.ch JWT secret key", + "type": "string" + }, + "allowed_iss": { + "description": "Allowed values of the iss token claim.", + "type": "array", + "items": { + "type": "string" + } + }, + "userid_claims": { + "description": "Token claims which can contain the userid. The first non-empty claim is used.", + "type": "array", + "items": { + "type": "string" + } + }, + "displayname_claims": { + "description": "Token claims which can contain the display name. The first non-empty claim is used.", + "type": "array", + "items": { + "type": "string" + } + }, + "userid_verify_sql": { + "description": "Query which verifies whether the userid exist. Must contain the :id placeholder", + "type": "string" + } + }, + "required": [ + "db_url", + "jwe_secret", + "jwt_secret", + "iss", + "userid_claims", + "displayname_claims", + "userid_verify_sql" + ] + } + }, + "required": [ + "service", + "config" + ] +} diff --git a/src/server.py b/src/server.py new file mode 100644 index 0000000..314f50a --- /dev/null +++ b/src/server.py @@ -0,0 +1,150 @@ +import json +import logging +import os +import sys +import time +from base64 import b64decode, urlsafe_b64encode +from flask import Flask, jsonify, request, abort, make_response, redirect +from flask_jwt_extended import create_access_token, set_access_cookies +from jwcrypto.jwe import JWE +from jwcrypto.jwk import JWK +from jwcrypto.jwt import JWT +from sqlalchemy.sql import text as sql_text +from urllib.parse import urlencode, urlparse, parse_qsl, urlunparse, unquote + +from qwc_services_core.database import DatabaseEngine +from qwc_services_core.jwt import jwt_manager +from qwc_services_core.runtime_config import RuntimeConfig +from qwc_services_core.tenant_handler import ( + TenantHandler, TenantPrefixMiddleware, TenantSessionInterface) + + +app = Flask(__name__) + +app.config['JWT_COOKIE_SECURE'] = os.environ.get( + 'JWT_COOKIE_SECURE', 'False').lower() == 'true' +app.config['JWT_COOKIE_SAMESITE'] = os.environ.get( + 'JWT_COOKIE_SAMESITE', 'Lax') +app.config['JWT_ACCESS_TOKEN_EXPIRES'] = int(os.environ.get( + 'JWT_ACCESS_TOKEN_EXPIRES', 12*3600)) + +jwt = jwt_manager(app) +app.secret_key = app.config['JWT_SECRET_KEY'] + + +tenant_handler = TenantHandler(app.logger) + +app.wsgi_app = TenantPrefixMiddleware(app.wsgi_app) +app.session_interface = TenantSessionInterface(os.environ) + + +@app.route('/login', methods=['GET']) +def login(): + config_handler = RuntimeConfig("mysochAuth", app.logger) + tenant = tenant_handler.tenant() + config = config_handler.tenant_config(tenant) + + # Request args + token = request.args.get('token') + target_url = request.args.get('url') + if not token: + app.logger.info("login: No token specified") + abort(400, "No token specified") + + if not target_url: + app.logger.info("login: No redirect URL") + abort(400, "No redirect URL") + + # Decode JWE + jwe_secret = urlsafe_b64encode(config.get("jwe_secret", "").encode()).decode() + jwt_secret = urlsafe_b64encode(config.get("jwt_secret", "").encode()).decode() + + jwe_key = JWK.from_json('{"kty" : "oct", "k": "%s"}' % jwe_secret) + jwt_key = JWK.from_json('{"kty": "oct", "k": "%s"}' % jwt_secret) + app.logger.debug("JWE key: %s" % jwe_key.export()) + app.logger.debug("JWT key: %s" % jwt_key.export()) + + jwe = JWE() + try: + jwe.deserialize(token, jwe_key) + except: + abort(400, "Token decryption failed") + + # Verify and decode JWT + jwt = JWT() + # jwt.leeway = 100000000000000 # NOTE Testing only + try: + jwt.deserialize(jwe.payload.decode(), jwt_key) + except Exception as e: + app.logger.debug("Token validation failed: %s" % str(e)) + abort(400, "Token validation failed") + + claims = json.loads(jwt.claims) + + app.logger.debug("Decoded claims %s" % jwt.claims) + + # Verify ISS + if claims["iss"] not in config.get("allowed_iss", []): + app.logger.info("login: Bad value for iss") + abort(400) + + # Verify exp + if claims["exp"] >= round(time.time()): + app.logger.info("login: Token expired") + abort(400) + + # Database + db_url = config.get('db_url') + db_engine = DatabaseEngine() + db = db_engine.db_engine(db_url) + + # Verify login + + userid = next(claims[userid_claim] for userid_claim in config.get("userid_claims", []) if claims.get(userid_claim)) + displayname = next(claims[displayname_claim] for displayname_claim in config.get("displayname_claims", []) if claims.get(displayname_claim)) + app.logger.debug("userid: %s, displayname: %s" % (userid, displayname)) + + user_exists = False + with db.connect() as connection: + + sql = sql_text(config.get("userid_verify_sql", "")) + result = connection.execute(sql, {"id": userid}) + user_exists = result.first() is not None + + if user_exists: + identity = { + 'username': userid, + 'user_infos': { + 'displayname': displayname, + 'mysoch': True + }, + 'autologin': True + } + access_token = create_access_token(identity=identity) + resp = make_response(redirect(target_url)) + set_access_cookies(resp, access_token) + return resp + else: + parts = urlparse(target_url) + target_query = dict(parse_qsl(parts.query)) + target_query.update({'mysoch:unknownidentity': 1}) + parts = parts._replace(query=urlencode(target_query)) + target_url = urlunparse(parts) + return make_response(redirect(target_url)) + + +@app.route("/ready", methods=['GET']) +def ready(): + """ readyness probe endpoint """ + return jsonify({"status": "OK"}) + + +@app.route("/healthz", methods=['GET']) +def healthz(): + """ liveness probe endpoint """ + return jsonify({"status": "OK"}) + + +if __name__ == '__main__': + app.logger.setLevel(logging.DEBUG) + app.run(host='localhost', port=5024, debug=True) diff --git a/src/server.wsgi b/src/server.wsgi new file mode 100644 index 0000000..2a429fc --- /dev/null +++ b/src/server.wsgi @@ -0,0 +1,10 @@ +import sys +import os +sys.path.insert(0, os.path.dirname(os.path.realpath(__file__))) + +def application(environ, start_response): + for key in environ: + if isinstance(environ[key], str): + os.environ[key] = environ[key] + from server import app + return app(environ, start_response)