commit 150fe2f77db0fd1e0f05d0031369bee635c35bb1 Author: s3lph Date: Sat Jan 1 01:18:13 2022 +0100 Initial commit diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..26997df --- /dev/null +++ b/LICENSE @@ -0,0 +1,16 @@ +Copyright 2022 s3lph + +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/README.md b/README.md new file mode 100644 index 0000000..86b5d41 --- /dev/null +++ b/README.md @@ -0,0 +1,72 @@ +# rC3 Counter-Badge + +This project was created during [rC3 2021 NOWHERE][rc3] in order to +issue badges (i.e. achievements) for a treasure hunt style game on a +[WorkAdventure][wa] map. + +## License + +See LICENSE + +## Dependencies + +* Python 3 >= 3.6 +* Bottle +* Beaker + +## How To Set Up + +### Create your Badges in Maschinenraum + +![Create your badge in Maschinenraum](./howto/01-create-badge.png) + +1. Click the `+` button next to the `Badges` menu entry +1. Give you badge a name +1. Make the badge an achievement +1. Upload a badge picture +1. Save the badge + +Repeat for every badge you want to create. + +### Issue Badge Redeem Tokens + +![Issue Badge Redeem Tokens](./howto/02-issue-badge-tokens.png) + +1. Click on the `Badges` menu entry +1. Select your badge +1. Under `Create Badge Redeem Tokens`, choose the `Permanent` type. +1. Click Create Redeem Token. **Save the redeem token for later use.** + +Repeat for every badge you want to create. + +### Create OAuth2 Client + +![Create OAuth2 Client](./howto/03-register-oauth-client.png) + +1. Click on the `Authentication` menu entry +1. If not already done, create a rC3 API token by clicking `Request a new token`. **Save the API token for later use.* +1. Give your new OAuth2 client a name. +1. Set its type to `Public`. +1. Set the grant type to `Auhtoziation code`. +1. Click `New application` to create the OAuth2 client. + +### Generate Counter Tokens + +![Create Layers in Tiled](./howto/04-create-tiled-layer.png) + +1. For each unique place on your WorkAdventure map, generate a unique token, e.g. using `uuidgen`. +1. Create a layer in Tiled for each unique place and add at least the `openWebsite` layer property with a value of e.g. `https:///counter//`. + +### Deploy + +Set up counterbadge.py as a service on your system, e.g. as a systemd +service (see `counterbadge.service` as an example). Set up a +TLS-terminating reverse proxy. + +### Configure + +See `counterbadge.example.json` as a config example. Put in the API, OAuth2 and Badge credentials you created earlier. + + +[rc3]: http://web.archive.org/web/20211229071129/https://links.rc3.world/ +[wa]: https://workadventu.re/ \ No newline at end of file diff --git a/counterbadge.example.json b/counterbadge.example.json new file mode 100644 index 0000000..adf85e0 --- /dev/null +++ b/counterbadge.example.json @@ -0,0 +1,31 @@ +{ + "counters": { + "my-treasure-hunt": { + "display_name": "My rC3 Treasure Hunt", + "unique_tokens": [ + "bc243542-f7d9-4474-943a-15e5936c9943", + "d6cf7658-a556-4784-bcb7-06d12fc47f54", + "ee81ad35-2a04-4781-9a09-e925ce2b5eab", + "202102f8-aca9-42e1-b6f9-0cb7d0dbb380", + "generate unique, non-guessable tokens for each place to visit" + ], + "profile_url": "https://api.rc3.world/api/me", + "redeem_url": "https://api.rc3.world/api/c/rc3_21/badges/redeem_token", + "api_token": "Your assembly's API token. Generate it at Maschinenraum / Authentication / Request a New Token", + "issue_at": { + "1": "A permanent redeem token", + "5": "Another permanent redeem token. Generate it at Maschinenraum / Badges / / Create Badge Redeem Token" + }, + "oauth": { + "client_id": "Your OAuth2 Client ID. Register a public OAuth2-Client at Maschinenraum / Authentication / New Application", + "client_secret": "Your OAuth2 Client Secret", + "authorize_url": "https://rc3.world/sso/authorize/", + "token_url": "https://rc3.world/sso/token/", + "scope": "rc3_21_attendee", + "redirect_url": "https://example.org/counter/my-treasure-hunt/redirect", + "response_type": "code" + } + } + } +} + diff --git a/counterbadge.py b/counterbadge.py new file mode 100644 index 0000000..385d898 --- /dev/null +++ b/counterbadge.py @@ -0,0 +1,226 @@ +#!/usr/bin/env python3 + +import json +import sqlite3 +import bottle +from bottle import run, get, abort, request, redirect +from beaker.middleware import SessionMiddleware +import urllib.request +import urllib.parse +from urllib.error import HTTPError +from uuid import uuid4 + +session_opts = { + 'session.type': 'file', + 'session.cookie_expires': 3600, + 'session.data_dir': './data', + 'session.auto': True, + 'session.secure': True, + 'session.samesite': 'None' +} +app = SessionMiddleware(bottle.app(), session_opts) + + +DB = sqlite3.connect('counterbadge.sqlite3') +DBC = DB.cursor() +try: + DBC.execute(''' + CREATE TABLE IF NOT EXISTS counters ( + id INTEGER PRIMARY KEY, + counter TEXT NOT NULL, + token TEXT NOT NULL, + username TEXT NOT NULL, + UNIQUE (counter, token, username) + ) + ''') + DBC.execute(''' + CREATE INDEX IF NOT EXISTS idx_counters ON counters (counter, username) + ''') + DB.commit() +except: + DB.rollback() +finally: + DBC.close() + + +def update_counter(counter, token, username): + cur = DB.cursor() + try: + changes = DB.total_changes + cur.execute('INSERT OR IGNORE INTO counters (counter, token, username) VALUES (:counter, :token, :username)', { + 'counter': counter, + 'token': token, + 'username': username + }) + changes = DB.total_changes - changes + cur.execute('SELECT COUNT(token) FROM counters WHERE counter = :counter AND username = :username', { + 'counter': counter, + 'username': username + }) + rows = cur.fetchone() + DB.commit() + except BaseException as e: + DB.rollback() + raise e + finally: + cur.close() + return rows[0], changes > 0 + + +def get_user(counterconf): + oauthconf = counterconf.get('oauth', {}) + for key in ['client_id', 'client_secret', 'authorize_url', 'token_url', 'scope', 'redirect_url', 'response_type']: + if key not in oauthconf: + raise KeyError(key) + s = request.environ.get('beaker.session') + if 'username' in s: + return s['username'] + s['redirect_url'] = request.url + s['state'] = uuid4() + query = { + 'client_id': oauthconf['client_id'], + 'response_type': oauthconf['response_type'], + 'redirect_uri': oauthconf['redirect_url'], + 'scope': oauthconf['scope'], + 'state': s['state'] + } + authorize = oauthconf['authorize_url'] + '?' + urllib.parse.urlencode(query) + redirect(authorize) + + +def redeem_code(oauthconf, code): + query = { + 'grant_type': 'authorization_code', + 'code': code, + 'client_id': oauthconf['client_id'], + 'client_secret': oauthconf['client_secret'], + 'redirect_uri': oauthconf['redirect_url'] + } + req = urllib.request.urlopen(oauthconf['token_url'], data=urllib.parse.urlencode(query).encode()) + resp = json.loads(req.read().decode()) + return resp.get('access_token') + + +def get_user_profile(counterconf, token): + headers = { + 'Authorization': f'Bearer {token}' + } + req = urllib.request.Request(counterconf.get('profile_url'), headers=headers) + req = urllib.request.urlopen(req) + resp = json.loads(req.read().decode()) + return resp.get('username') + + +@get('/counter//redirect') +def oauth_redirect(counter): + with open('counterbadge.json', 'r') as f: + config = json.load(f) + counterconf = config.get('counters', {}).get(counter) + oauthconf = counterconf.get('oauth', {}) + for key in ['client_id', 'client_secret', 'authorize_url', 'token_url', 'scope', 'redirect_url', 'response_type']: + if key not in oauthconf: + raise KeyError(key) + s = request.environ.get('beaker.session') + if 'username' in s and 'redirect_url' in s: + redirect(s['redirect_url']) + for key in ['code', 'state']: + if key not in request.query: + abort(400, 'Bad Request') + if 'state' not in s: + abort(400, 'Unknown state') + state = str(s['state']) + del s['state'] + if state != request.query.state: + abort(401, 'Invalid state') + if 'token' in request.query: + token = request.query.token + else: + token = redeem_code(oauthconf, request.query.code) + username = None + if token: + username = get_user_profile(counterconf, token) + if username: + s['username'] = username + redirect(s['redirect_url']) + abort(401, 'Something went wrong' + token) + +@get('/counter//ping') +def ping(counter): + with open('counterbadge.json', 'r') as f: + config = json.load(f) + counterconf = config.get('counters', {}).get(counter) + username = get_user(counterconf) + if not username: + abort(401, 'Unautorized') + display_name = counterconf.get('display_name', counter) + return f''' +

{display_name}

+

+ Hi {username} +

+ ''' + +@get('/counter//') +def counter(counter, token): + with open('counterbadge.json', 'r') as f: + config = json.load(f) + counterconf = config.get('counters', {}).get(counter) + username = get_user(counterconf) + if not username: + abort(401, 'Unautorized') + display_name = counterconf.get('display_name', counter) + n_max = len(counterconf.get('unique_tokens', [])) + if not counterconf: + abort(404, 'Not found') + if token not in counterconf.get('unique_tokens', []): + abort(404, 'Not found') + redeem_url = counterconf.get('redeem_url') + if not redeem_url: + abort(500, 'Internal Server Error') + api_token = counterconf.get('api_token') + if not api_token: + abort(500, 'Internal Server Error') + issue_at = counterconf.get('issue_at', {}) + n, changed = update_counter(counter, token, username) + issued = False + for i, redeem_token in issue_at.items(): + if int(i) <= n: + body = json.dumps({'token': redeem_token, 'username': username}).encode() + try: + headers = { + 'Authorization': f'Bearer {api_token}', + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'rc3-counterbadge/0.1 (https://git.kabelsalat.ch/rc3-counterbadge)', + } + request = urllib.request.Request(redeem_url, data=body, headers=headers) + response = urllib.request.urlopen(request) + resp = json.loads(response.read().decode()) + # The API documentation is lying :( it doesn't in fact return + # whether the badge was actually issued + #issued = issued or resp.get('created', False) + if changed and int(i) == n: + issued = True + except HTTPError as e: + print(e, e.read().decode()) + abort(500, 'Internal Server Error') + if issued: + if n < n_max: + issued = f'

Du hast eine Badge erhalten. Finde alle {n_max} Gegenstände, um mehr Badges zu erhalten.

' + else: + issued = f'

Du hast alle {n_max} Gegenstände gefunden und die letzte Badge erhalten. Herzlichen Glückwunsch!

' + else: + issued = '' + return f''' +

{display_name}

+

+ Hi {username} +

+

+ Du hast {n} von {n_max} Gegenstände gefunden! +

+ {issued} + ''' + +if __name__ == '__main__': + run(app=app, host='127.0.0.1', port=8080) diff --git a/counterbadge.service b/counterbadge.service new file mode 100644 index 0000000..0c62407 --- /dev/null +++ b/counterbadge.service @@ -0,0 +1,12 @@ +[Unit] +Description=rC3 Counter Badge Service + +[Service] +ExecStart=/usr/bin/python3 /var/lib/counterbadge/rc3counterbadge.py +User=counterbadge +Group=counterbadge +WorkingDirectory=/var/lib/counterbadge +Restart=on-failure + +[Install] +WantedBy=multi-user.target diff --git a/howto/01-create-badge.png b/howto/01-create-badge.png new file mode 100644 index 0000000..6d14436 Binary files /dev/null and b/howto/01-create-badge.png differ diff --git a/howto/02-issue-badge-tokens.png b/howto/02-issue-badge-tokens.png new file mode 100644 index 0000000..26ff64f Binary files /dev/null and b/howto/02-issue-badge-tokens.png differ diff --git a/howto/03-register-oauth-client.png b/howto/03-register-oauth-client.png new file mode 100644 index 0000000..b501d13 Binary files /dev/null and b/howto/03-register-oauth-client.png differ diff --git a/howto/04-create-tiled-layer.png b/howto/04-create-tiled-layer.png new file mode 100644 index 0000000..524e4a1 Binary files /dev/null and b/howto/04-create-tiled-layer.png differ