Initial commit

This commit is contained in:
s3lph 2022-01-01 01:18:13 +01:00
commit 150fe2f77d
Signed by: s3lph
GPG key ID: 8AC98A811E5BEFF5
9 changed files with 357 additions and 0 deletions

16
LICENSE Normal file
View file

@ -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.

72
README.md Normal file
View file

@ -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://<yourdomain>/counter/<my-counter-name>/<the-unique-token>`.
### 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/

31
counterbadge.example.json Normal file
View file

@ -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 / <the badge> / 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"
}
}
}
}

226
counterbadge.py Normal file
View file

@ -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/<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/<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'''
<h1>{display_name}</h1>
<p>
Hi {username}
</p>
'''
@get('/counter/<counter>/<token>')
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'<p>Du hast eine Badge erhalten. Finde alle {n_max} Gegenstände, um mehr Badges zu erhalten.</p>'
else:
issued = f'<p>Du hast alle {n_max} Gegenstände gefunden und die letzte Badge erhalten. Herzlichen Glückwunsch!</p>'
else:
issued = ''
return f'''
<h1>{display_name}</h1>
<p>
Hi {username}
</p>
<p>
Du hast <strong>{n}</strong> von {n_max} Gegenstände gefunden!
</p>
{issued}
'''
if __name__ == '__main__':
run(app=app, host='127.0.0.1', port=8080)

12
counterbadge.service Normal file
View file

@ -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

BIN
howto/01-create-badge.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB