Getting started
This commit is contained in:
parent
df4525672d
commit
9565ba3596
19 changed files with 2829 additions and 717 deletions
31
README.md
31
README.md
|
@ -1 +1,32 @@
|
||||||
# hmdooris - Dooris via HomeMatic
|
# hmdooris - Dooris via HomeMatic
|
||||||
|
|
||||||
|
|
||||||
|
## Local Development Setup with Docker Compose
|
||||||
|
|
||||||
|
The included docker-compose.yaml will bring up a local Keycloak instance with a preconfigured realm that includes a
|
||||||
|
client that can be used to test the application locally. You can log in to the admin console
|
||||||
|
at http://localhost:8080/admin/master/console/ using "admin"/"admin".
|
||||||
|
|
||||||
|
### Realm `Keycloak`: Client `hmdooris` and User `hmdooris`
|
||||||
|
|
||||||
|
In order for ID Invite to create users, it needs to access the Keycloak REST API with suitable credentials. This is
|
||||||
|
implemented through a client `hmdooris` in the `Keycloak` realm, with the client secret `XXX`, and a username
|
||||||
|
of `hmdooris` and password `geheim`.
|
||||||
|
|
||||||
|
### Realm `testing`: Client `hmdooris` and User `tony`
|
||||||
|
|
||||||
|
Keycloak will import the realm export from [`local-dev/import/testing.json`](local-dev/import/testing.json) and create a
|
||||||
|
realm `testing`, including a client and a user.
|
||||||
|
|
||||||
|
The client ID is `hmdooris` and the secret is ´8p21riiYPDEhpgRh2rgRDNu9uWVZ9KRj`.
|
||||||
|
|
||||||
|
You can log in to the realm and the application with user `tony` and password `tester`.
|
||||||
|
|
||||||
|
### Updating the testing realm
|
||||||
|
|
||||||
|
If you'd like to make changes to the configuration of the testing realm, and have it persist across restarts, you can
|
||||||
|
export the realm. Run this command:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
docker compose exec -it keycloak /opt/keycloak/data/import/export.sh
|
||||||
|
```
|
15
docker-compose.yaml
Normal file
15
docker-compose.yaml
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
services:
|
||||||
|
keycloak:
|
||||||
|
image: quay.io/keycloak/keycloak:24.0
|
||||||
|
command: start-dev --import-realm
|
||||||
|
environment:
|
||||||
|
- "KEYCLOAK_ADMIN=admin"
|
||||||
|
- "KEYCLOAK_ADMIN_PASSWORD=admin"
|
||||||
|
- "KEYCLOAK_IMPORT=/opt/keycloak/data/import/keycloak-realm.json"
|
||||||
|
- "JAVA_OPTS_APPEND=-Dkeycloak.profile.feature.upload_scripts=enabled -Dkeycloak.migration.strategy=OVERWRITE_EXISTING"
|
||||||
|
- KEYCLOAK_LOGLEVEL=DEBUG
|
||||||
|
ports:
|
||||||
|
- 8080:8080
|
||||||
|
- 8443:8443
|
||||||
|
volumes:
|
||||||
|
- ./local-dev/import:/opt/keycloak/data/import
|
21
hmdooris/AppConfig.py
Normal file
21
hmdooris/AppConfig.py
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
import json
|
||||||
|
from os import getenv, path
|
||||||
|
|
||||||
|
|
||||||
|
class AppConfig:
|
||||||
|
def __init__(self):
|
||||||
|
"""
|
||||||
|
Gets the config from environment variables
|
||||||
|
"""
|
||||||
|
self.basepath = path.dirname(__file__)
|
||||||
|
self.app_email_base_uri = getenv('APP_EMAIL_BASE_URI', 'http://localhost:3000')
|
||||||
|
self.app_keycloak_base_uri = getenv('APP_KEYCLOAK_BASE_URI', 'http://localhost:3000')
|
||||||
|
self.url = getenv('IDINVITE_URL', 'http://localhost:3000')
|
||||||
|
self.discovery_url = getenv('HMDOORIS_DISCOVERY_URL',
|
||||||
|
'http://localhost:8080/realms/testing/.well-known/openid-configuration')
|
||||||
|
self.client_id = getenv('HMDOORIS_CLIENT_ID', 'hmdooris')
|
||||||
|
self.client_secret = getenv('HMDOORIS_CLIENT_SECRET')
|
||||||
|
self.requires_group = getenv('HMDOORIS_REQUIRES_GROUP', None)
|
||||||
|
|
||||||
|
if self.client_secret is None or self.client_secret == '':
|
||||||
|
raise ValueError('You need to provide HMDOORIS_CLIENT_SECRET')
|
2
hmdooris/AppException.py
Normal file
2
hmdooris/AppException.py
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
class AppException(Exception):
|
||||||
|
pass
|
13
hmdooris/CSRF.py
Normal file
13
hmdooris/CSRF.py
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
from bottle import request
|
||||||
|
|
||||||
|
|
||||||
|
def get_token() -> str:
|
||||||
|
"""
|
||||||
|
Returns a string suitable as a csrf token. The token is stored in the session.
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
if 'csrf_token' not in request.session:
|
||||||
|
request.session['csrf_token'] = secrets.token_urlsafe(16)
|
||||||
|
return request.session['csrf_token']
|
20
hmdooris/Template.py
Normal file
20
hmdooris/Template.py
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
import logging
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
from bottle import jinja2_template
|
||||||
|
|
||||||
|
from hmdooris.AppException import AppException
|
||||||
|
|
||||||
|
|
||||||
|
def template_or_error(template: str):
|
||||||
|
def outer(f: Callable[[], str]):
|
||||||
|
def wrapper():
|
||||||
|
try:
|
||||||
|
return jinja2_template(template, f())
|
||||||
|
except AppException as e:
|
||||||
|
logging.error(f'unable to process request: {e}')
|
||||||
|
return jinja2_template('error', {'message': e})
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
return outer
|
71
hmdooris/WebEndpoints.py
Normal file
71
hmdooris/WebEndpoints.py
Normal file
|
@ -0,0 +1,71 @@
|
||||||
|
import re
|
||||||
|
import secrets
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
import BottleOIDC
|
||||||
|
from bottle import static_file, request, redirect, Bottle
|
||||||
|
|
||||||
|
from hmdooris import CSRF
|
||||||
|
from hmdooris.AppConfig import AppConfig
|
||||||
|
from hmdooris.Template import template_or_error
|
||||||
|
|
||||||
|
|
||||||
|
class WebEndpoints:
|
||||||
|
"""
|
||||||
|
Defines endpoints for interaction with the user's browser.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, app: Bottle, auth: BottleOIDC, basepath: str, config: AppConfig):
|
||||||
|
self.app = app
|
||||||
|
self.auth = auth
|
||||||
|
self.basepath = basepath
|
||||||
|
self.config = config
|
||||||
|
self.valid_username_re = '^[a-zA-Z0-9_-]+$'
|
||||||
|
|
||||||
|
# set up routing directly, since decorators can only be used in a global scope
|
||||||
|
app.route(path='/static/<filename>', callback=self.static)
|
||||||
|
app.get(path='/', callback=template_or_error('home')(self.home))
|
||||||
|
app.get(path='/foo', callback=self.require_login(template_or_error('home')(self.home)))
|
||||||
|
app.get(path='/api', callback=self.require_login(self.api_get))
|
||||||
|
# app.get(path='/invite', callback=self.get_invite)
|
||||||
|
# app.post(path='/invite', callback=self.require_login(template_or_error('invite_result')(self.post_invite)))
|
||||||
|
# app.get(path='/claim', callback=template_or_error('claim_form')(self.get_claim))
|
||||||
|
# app.post(path='/claim', callback=template_or_error('claim_result')(self.post_claim))
|
||||||
|
|
||||||
|
def require_login(self, func: Callable) -> Callable:
|
||||||
|
if self.config.requires_group is not None:
|
||||||
|
return self.auth.require_login(self.auth.require_attribute('groups', self.config.requires_group)(func))
|
||||||
|
else:
|
||||||
|
return self.auth.require_login(func)
|
||||||
|
|
||||||
|
def static(self, filename):
|
||||||
|
return static_file(filename, root=f'{self.basepath}/static')
|
||||||
|
|
||||||
|
def home(self):
|
||||||
|
"""
|
||||||
|
Present the home page
|
||||||
|
:param username:
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
'_csrf': CSRF.get_token(),
|
||||||
|
# 'username': self.auth.my_attrs['username'],
|
||||||
|
}
|
||||||
|
|
||||||
|
def api_get(self):
|
||||||
|
"""
|
||||||
|
Interact withe HomeMatic CCU through ccu-jack
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
'_csrf': CSRF.get_token(),
|
||||||
|
'foo': 'bar'
|
||||||
|
}
|
||||||
|
|
||||||
|
def api_put(self):
|
||||||
|
"""
|
||||||
|
Interact withe HomeMatic CCU through ccu-jack
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
return {}
|
46
hmdooris/__main__.py
Normal file
46
hmdooris/__main__.py
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
import logging
|
||||||
|
from argparse import ArgumentParser
|
||||||
|
from os import getenv
|
||||||
|
|
||||||
|
import waitress
|
||||||
|
from BottleOIDC import BottleOIDC
|
||||||
|
from BottleSessions import BottleSessions
|
||||||
|
from bottle import Bottle, TEMPLATE_PATH
|
||||||
|
|
||||||
|
from hmdooris.AppConfig import AppConfig
|
||||||
|
from hmdooris.WebEndpoints import WebEndpoints
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
argp = ArgumentParser(prog="hmdooris")
|
||||||
|
argp.add_argument('-d', '--debug', action='store_true')
|
||||||
|
args = argp.parse_args()
|
||||||
|
|
||||||
|
if args.debug:
|
||||||
|
logging.basicConfig(level=logging.DEBUG)
|
||||||
|
|
||||||
|
config = AppConfig()
|
||||||
|
|
||||||
|
app = Bottle()
|
||||||
|
TEMPLATE_PATH.insert(0, f'{config.basepath}/views')
|
||||||
|
|
||||||
|
BottleSessions(app)
|
||||||
|
auth = BottleOIDC(app, config={
|
||||||
|
"discovery_url": config.discovery_url,
|
||||||
|
"client_id": config.client_id,
|
||||||
|
"client_secret": config.client_secret,
|
||||||
|
})
|
||||||
|
WebEndpoints(app, auth, config.basepath, config)
|
||||||
|
|
||||||
|
bottle_params = {
|
||||||
|
'debug': bool(getenv('BOTTLE_DEBUG', 'False')) or args.debug,
|
||||||
|
}
|
||||||
|
waitress_params = {
|
||||||
|
'host': getenv('BOTTLE_HOST', 'localhost'),
|
||||||
|
'port': int(getenv('BOTTLE_PORT', '3000')),
|
||||||
|
'trusted_proxy': getenv('BOTTLE_TRUSTED_PROXY', ''),
|
||||||
|
'trusted_proxy_headers': getenv('BOTTLE_TRUSTED_PROXY_HEADERS', ''),
|
||||||
|
}
|
||||||
|
if getenv('BOTTLE_URL_SCHEME') is not None:
|
||||||
|
waitress_params['url_scheme'] = getenv('BOTTLE_URL_SCHEME')
|
||||||
|
app.config.update(**bottle_params)
|
||||||
|
waitress.serve(app, **waitress_params)
|
3
hmdooris/static/main.css
Normal file
3
hmdooris/static/main.css
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
/*
|
||||||
|
* foo
|
||||||
|
*/
|
3
hmdooris/static/main.js
Normal file
3
hmdooris/static/main.js
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
(function(){
|
||||||
|
console.log("Setup complete")
|
||||||
|
})();
|
21
hmdooris/views/claim_form.tpl
Normal file
21
hmdooris/views/claim_form.tpl
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>ID Invite - Create Account</title>
|
||||||
|
<link rel=stylesheet type="text/css" href="static/main.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>ID Invite - Create Account</h1>
|
||||||
|
<p>Please complete the information. First and last name are optional.</p>
|
||||||
|
<form action="/claim" method="POST">
|
||||||
|
<input type="hidden" name="_csrf" value="{{_csrf}}" />
|
||||||
|
<input type="hidden" name="p" value="{{p}}" />
|
||||||
|
<input type="hidden" name="s" value="{{s}}" />
|
||||||
|
<p><label for="email">Email Address:</label><input type="text" name="email" id="email" value="{{email | e}}" readonly /></p>
|
||||||
|
<p><label for="username">Username:</label><input type="text" name="username" id="username" value="{{username | e}}" readonly /></p>
|
||||||
|
<p><label for="first">First:</label><input type="text" name="first" id="first" /></p>
|
||||||
|
<p><label for="last">Last:</label><input type="text" name="last" id="last" /></p>
|
||||||
|
<p><input type="submit" value="Create Account"></p>
|
||||||
|
</form>
|
||||||
|
<p></p>
|
||||||
|
</body>
|
||||||
|
</html>
|
10
hmdooris/views/claim_result.tpl
Normal file
10
hmdooris/views/claim_result.tpl
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>ID Invite - Account Created</title>
|
||||||
|
<link rel=stylesheet type="text/css" href="static/main.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>ID Invite - Account Created</h1>
|
||||||
|
<p>An account has been created, and a password-reset email should arrive shortly at {{email | e}}.</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
11
hmdooris/views/error.tpl
Normal file
11
hmdooris/views/error.tpl
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>ID Invite - Error</title>
|
||||||
|
<link rel=stylesheet type="text/css" href="static/main.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>ID Invite - Error</h1>
|
||||||
|
<p>Unfortunately, your request could not be processed.</p>
|
||||||
|
<p>{{ message | e}}</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
11
hmdooris/views/home.tpl
Normal file
11
hmdooris/views/home.tpl
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<title>HM Dooris</title>
|
||||||
|
<link rel=stylesheet type="text/css" href="static/main.css">
|
||||||
|
<script src="static/main.js" defer></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>HM Dooris</h1>
|
||||||
|
<p>Hello, world!</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
17
hmdooris/views/invite_form.tpl
Normal file
17
hmdooris/views/invite_form.tpl
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>ID Invite - Create an Invite</title>
|
||||||
|
<link rel=stylesheet type="text/css" href="static/main.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>ID Invite - Create an Invite</h1>
|
||||||
|
<p>Please enter the email address the invite should be sent to</p>
|
||||||
|
<form action="/invite" method="POST">
|
||||||
|
<input type="hidden" name="_csrf" value="{{_csrf}}" />
|
||||||
|
<p><label for="email">Email Address:</label><input type="text" name="email" id="email" /></p>
|
||||||
|
<p><label for="username">Username:</label><input type="text" name="username" id="username" /></p>
|
||||||
|
<p><input type="submit" value="Send Invite"></p>
|
||||||
|
</form>
|
||||||
|
<p>The recipient will receive the invite from {{username}}</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
10
local-dev/import/export.sh
Executable file
10
local-dev/import/export.sh
Executable file
|
@ -0,0 +1,10 @@
|
||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Helper script to export database
|
||||||
|
#
|
||||||
|
cp -rp /opt/keycloak/data/h2 /tmp
|
||||||
|
/opt/keycloak/bin/kc.sh export --file /opt/keycloak/data/import/testing.json --realm testing \
|
||||||
|
--optimized \
|
||||||
|
--db-url 'jdbc:h2:file:/tmp/h2/keycloakdb;NON_KEYWORDS=VALUE'
|
||||||
|
rm -rf /tmp/h2
|
1852
local-dev/import/testing.json
Normal file
1852
local-dev/import/testing.json
Normal file
File diff suppressed because it is too large
Load diff
1378
poetry.lock
generated
1378
poetry.lock
generated
File diff suppressed because it is too large
Load diff
|
@ -7,9 +7,14 @@ authors = [
|
||||||
]
|
]
|
||||||
license = {text = "Apache-2.0"}
|
license = {text = "Apache-2.0"}
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.13"
|
requires-python = ">=3.13,<4.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"hahomematic (>=2025.5.0,<2026.0.0)"
|
"bottle (>=0.13.3,<0.14.0)",
|
||||||
|
"bottleoidc (>=21.8.30,<22.0.0)",
|
||||||
|
"python-keycloak (>=5.5.0,<6.0.0)",
|
||||||
|
"jinja2 (>=3.1.6,<4.0.0)",
|
||||||
|
"requests (>=2.32.3,<3.0.0)",
|
||||||
|
"waitress (>=3.0.2,<4.0.0)"
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue