Skip to content

Commit

Permalink
Initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
manisandro committed Jun 9, 2024
0 parents commit 3d7e814
Show file tree
Hide file tree
Showing 7 changed files with 321 additions and 0 deletions.
28 changes: 28 additions & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
@@ -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 }}"
14 changes: 14 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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/
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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
7 changes: 7 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -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
76 changes: 76 additions & 0 deletions schemas/sogis-mysoch-auth.json
Original file line number Diff line number Diff line change
@@ -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"
]
}
150 changes: 150 additions & 0 deletions src/server.py
Original file line number Diff line number Diff line change
@@ -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)
10 changes: 10 additions & 0 deletions src/server.wsgi
Original file line number Diff line number Diff line change
@@ -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)

0 comments on commit 3d7e814

Please sign in to comment.