-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
0 parents
commit 3d7e814
Showing
7 changed files
with
321 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }}" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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/ |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" | ||
] | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |