Getting started

This commit is contained in:
Stefan Bethke 2025-05-21 14:44:29 +02:00
commit 9565ba3596
19 changed files with 2829 additions and 717 deletions

View file

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

@ -0,0 +1,2 @@
class AppException(Exception):
pass

13
hmdooris/CSRF.py Normal file
View 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
View 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
View 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
View 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
View file

@ -0,0 +1,3 @@
/*
* foo
*/

3
hmdooris/static/main.js Normal file
View file

@ -0,0 +1,3 @@
(function(){
console.log("Setup complete")
})();

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

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

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

File diff suppressed because it is too large Load diff

1378
poetry.lock generated

File diff suppressed because it is too large Load diff

View file

@ -7,9 +7,14 @@ authors = [
]
license = {text = "Apache-2.0"}
readme = "README.md"
requires-python = ">=3.13"
requires-python = ">=3.13,<4.0"
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)"
]