This commit is contained in:
cellarspoon
2022-01-10 13:34:17 +01:00
commit 566bf395fe
31 changed files with 2695 additions and 0 deletions

View File

View File

@ -0,0 +1,43 @@
"""Application configuraiton."""
import logging
from os import environ
from pathlib import Path
# Application secret key, used for the SessionMiddleware
APP_SECRET_KEY = environ.get("APP_SECRET_KEY")
# Keycloak client details
KEYCLOAK_CLIENT_ID = environ.get("KEYCLOAK_CLIENT_ID")
KEYCLOAK_CLIENT_SECRET = environ.get("KEYCLOAK_CLIENT_SECRET")
KEYCLOAK_DOMAIN = environ.get("KEYCLOAK_DOMAIN")
KEYCLOAK_REALM = environ.get("KEYCLOAK_REALM")
KEYCLOAK_SCOPES = environ.get("KEYCLOAK_SCOPES", "openid profile email")
KEYCLOAK_BASE_URL = f"https://{KEYCLOAK_DOMAIN}/auth/realms/{KEYCLOAK_REALM}/protocol/openid-connect" # noqa
# Redis connection details, our main storage
REDIS_DB = environ.get("REDIS_DB")
REDIS_HOST = environ.get("REDIS_HOST")
REDIS_PORT = environ.get("REDIS_PORT")
# How many days do we want the invites to be valid for?
INVITE_TIME_LIMIT = int(environ.get("INVITE_TIME_LIMIT", 30))
# Static and template configuration
STATIC_DIR = Path(".").absolute() / "members_lumbung_space" / "static"
TEMPLATE_DIR = Path(".").absolute() / "members_lumbung_space" / "templates"
# Theme selection
APP_THEME = environ.get("APP_THEME", "default")
# Log level
LOG_LEVEL = environ.get("APP_LOG_LEVEL", "info")
if LOG_LEVEL == "info":
APP_LOG_LEVEL = logging.INFO
elif LOG_LEVEL == "debug":
APP_LOG_LEVEL = logging.DEBUG
else:
APP_LOG_LEVEL = logging.INFO
# Automatically log folks in or show the default log in page?
AUTOMATICALLY_LOG_IN = environ.get("AUTOMATICALLY_LOG_IN", False)

View File

@ -0,0 +1,51 @@
"""Route dependencies."""
from datetime import datetime as dt
from datetime import timedelta
from fastapi import Depends, Request
from humanize import naturaldelta
async def fresh_token(request: Request):
"""Ensure fresh credentials for speaking to Keycloak."""
from members_lumbung_space.keycloak import init_keycloak
request.app.state.keycloak = init_keycloak()
async def logged_in(request: Request):
"""Ensure the user is logged in."""
from members_lumbung_space.exceptions import RequiresLoginException
user = request.session.get("user")
if not user:
raise RequiresLoginException
return user
async def get_user(request: Request):
"""Retrieve the user object."""
return request.session.get("user")
async def get_invites(request: Request, user=Depends(get_user)):
"""Retrieve all invites from storage."""
from members_lumbung_space.config import INVITE_TIME_LIMIT
all_invites = {}
for username in await request.app.state.redis.keys("*"):
invites = await request.app.state.redis.get(username)
for invite in invites:
invite["validity"] = naturaldelta(
dt.fromisoformat(invite["time"])
+ timedelta(days=INVITE_TIME_LIMIT)
)
all_invites[username] = invites
return all_invites

View File

@ -0,0 +1,7 @@
"""Exceptions."""
class RequiresLoginException(Exception):
"""An exception thrown if the user is not logged in."""
pass

View File

@ -0,0 +1,21 @@
"""Keycloak logic."""
from keycloak import KeycloakAdmin
def init_keycloak():
"""Initialise Keycloak client."""
from members_lumbung_space.config import (
KEYCLOAK_CLIENT_SECRET,
KEYCLOAK_DOMAIN,
KEYCLOAK_REALM,
)
client = KeycloakAdmin(
server_url=f"https://{KEYCLOAK_DOMAIN}/auth/",
realm_name=KEYCLOAK_REALM,
client_secret_key=KEYCLOAK_CLIENT_SECRET,
verify=True,
)
return client

View File

@ -0,0 +1,82 @@
"""App entrypoint."""
import logging
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from starlette.exceptions import HTTPException
from starlette.middleware.sessions import SessionMiddleware
from members_lumbung_space.config import (
APP_LOG_LEVEL,
APP_SECRET_KEY,
APP_THEME,
REDIS_DB,
REDIS_HOST,
REDIS_PORT,
STATIC_DIR,
TEMPLATE_DIR,
)
from members_lumbung_space.exceptions import RequiresLoginException
from members_lumbung_space.keycloak import init_keycloak
from members_lumbung_space.oidc import init_oidc
from members_lumbung_space.redis import Redis
from members_lumbung_space.routes import (
health,
invite,
oidc,
register,
root,
)
log = logging.getLogger("uvicorn")
log.setLevel(APP_LOG_LEVEL)
app = FastAPI(docs_url=None, redoc_url=None)
@app.exception_handler(RequiresLoginException)
async def requires_login(request, exception):
return RedirectResponse(request.url_for("login"))
@app.exception_handler(HTTPException)
async def http_exception_handler(request, exc):
home = request.url_for("login")
return HTMLResponse(f"<p>{exc.detail} (<a href='{home}'>home</a>)</p>")
@app.on_event("startup")
async def startup_event():
redis = Redis()
app.state.redis = await redis.create_pool(
f"redis://{REDIS_HOST}:{REDIS_PORT}/{REDIS_DB}?encoding=utf-8"
)
@app.on_event("shutdown")
async def shutdown_event():
await app.state.redis.close()
app.add_middleware(SessionMiddleware, secret_key=APP_SECRET_KEY)
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
app.state.oidc = init_oidc()
log.info("Initialised OpenID Connect client (for Keycloak logins)")
app.state.keycloak = init_keycloak()
log.info("Initialised Keycloak admin client (for Keycloak REST API)")
app.state.templates = Jinja2Templates(directory=TEMPLATE_DIR)
app.state.theme = APP_THEME
app.state.log = log
app.include_router(invite.router)
app.include_router(oidc.router)
app.include_router(register.router)
app.include_router(root.router)
app.include_router(health.router)

View File

@ -0,0 +1,25 @@
"""OpenID Connect logic."""
from authlib.integrations.starlette_client import OAuth, OAuthError
def init_oidc():
"""Initialise OIDC client."""
from members_lumbung_space.config import (
KEYCLOAK_BASE_URL,
KEYCLOAK_CLIENT_ID,
KEYCLOAK_CLIENT_SECRET,
KEYCLOAK_SCOPES,
)
oidc = OAuth()
oidc.register(
name="keycloak",
client_kwargs={"scope": KEYCLOAK_SCOPES},
client_id=KEYCLOAK_CLIENT_ID,
client_secret=KEYCLOAK_CLIENT_SECRET,
authorize_url=f"{KEYCLOAK_BASE_URL}/auth",
access_token_url=f"{KEYCLOAK_BASE_URL}/token",
jwks_uri=f"{KEYCLOAK_BASE_URL}/certs",
)
return oidc

View File

@ -0,0 +1,41 @@
"""Redis cache."""
import json
from aioredis import create_redis_pool
class Redis:
"""Redis cache."""
def __init__(self):
"""Initialise the object."""
self._redis = None
async def create_pool(self, conn):
"""Initialise pool."""
self._redis = await create_redis_pool(conn)
return self
async def keys(self, pattern):
"""Retrieve keys that match a pattern."""
return await self._redis.keys(pattern)
async def set(self, key, value, dumps=True):
"""Set a key."""
if dumps:
return await self._redis.set(key, json.dumps(value))
return await self._redis.set(key, value)
async def get(self, key, loads=True):
"""Get a specific key."""
if loads:
value = await self._redis.get(key)
if value:
return json.loads(value)
return await self._redis.get(key)
async def close(self):
"""Close the connection."""
self.redis_cache.close()
await self.redis_cache.wait_closed()

View File

@ -0,0 +1,10 @@
"""Healthcheck routes."""
from fastapi import APIRouter, Request
router = APIRouter()
@router.get("/healthz")
async def healthz(request: Request):
return {"detail": "ALL ENGINES FIRING"}

View File

@ -0,0 +1,53 @@
"""Routes for invite logic."""
from datetime import datetime as dt
from uuid import uuid4
from fastapi import APIRouter, Depends, Request
from fastapi.responses import RedirectResponse
from members_lumbung_space.dependencies import (
get_invites,
get_user,
logged_in,
)
router = APIRouter()
@router.get("/invite/keycloak/create", dependencies=[Depends(logged_in)])
async def invite_keycloak_create(
request: Request, user=Depends(get_user), invites=Depends(get_invites)
):
username = user["preferred_username"]
new_invite = {"link": str(uuid4()), "time": str(dt.now())}
request.app.state.log.info(f"Generated new invite: {new_invite}")
invites = await request.app.state.redis.get(username)
if invites:
invites.append(new_invite)
else:
invites = [new_invite]
await request.app.state.redis.set(username, invites)
return RedirectResponse(request.url_for("home"))
@router.get("/invite/keycloak/delete", dependencies=[Depends(logged_in)])
async def invite_keycloak_delete(
request: Request, user=Depends(get_user), invites=Depends(get_invites)
):
username = user["preferred_username"]
invite_to_delete = request.query_params.get("invite")
invites = await request.app.state.redis.get(username)
request.app.state.log.info(f"Retrieved invites: {invites}")
purged = [i for i in invites if i["link"] != invite_to_delete]
request.app.state.log.info(f"Purged invites: {invites}")
await request.app.state.redis.set(user["preferred_username"], purged)
return RedirectResponse(request.url_for("home"))

View File

@ -0,0 +1,58 @@
"""OpenID Connect routes."""
import httpx
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from members_lumbung_space.dependencies import logged_in
router = APIRouter()
@router.get("/login")
async def login(request: Request):
from members_lumbung_space.config import AUTOMATICALLY_LOG_IN
if AUTOMATICALLY_LOG_IN:
return RedirectResponse(request.url_for("login_keycloak"))
return request.app.state.templates.TemplateResponse(
"login.html", context={"request": request}
)
@router.get("/login/keycloak")
async def login_keycloak(request: Request):
redirect_uri = request.url_for("auth_keycloak")
return await request.app.state.oidc.keycloak.authorize_redirect(
request, redirect_uri
)
@router.get("/auth/keycloak")
async def auth_keycloak(request: Request):
try:
token = await request.app.state.oidc.keycloak.authorize_access_token(
request
)
except Exception as exc:
return HTMLResponse(f"<p>{exc} (<a href='/'>home</a>)</p>")
user = await request.app.state.oidc.keycloak.parse_id_token(request, token)
request.session["user"] = dict(user)
return RedirectResponse(request.url_for("home"))
@router.get("/logout", dependencies=[Depends(logged_in)])
async def logout(request: Request):
from members_lumbung_space.config import KEYCLOAK_BASE_URL
try:
httpx.get(f"{KEYCLOAK_BASE_URL}/logout")
except Exception as exc:
return HTMLResponse(f"<p>{exc} (<a href='/'>home</a>)</p>")
request.session.pop("user", None)
return RedirectResponse(request.url_for("login"))

View File

@ -0,0 +1,126 @@
"""Registration routes."""
import json
from datetime import datetime as dt
from datetime import timedelta
from fastapi import APIRouter, Depends, Form, Request
from pydantic import EmailStr, errors
from members_lumbung_space.dependencies import fresh_token, get_invites
router = APIRouter()
@router.get("/register/{invite}")
async def register_invite(
request: Request, invite: str, invites=Depends(get_invites)
):
from members_lumbung_space.config import INVITE_TIME_LIMIT
matching, username, matching_invite = False, None, None
for username in invites:
for _invite in invites[username]:
if invite == _invite["link"]:
matching = True
username = username
matching_invite = _invite
if not matching:
message = "This invite does not exist, sorry."
context = {"request": request, "message": message}
return request.app.state.templates.TemplateResponse(
"invalid.html", context=context
)
expired = (
dt.fromisoformat(matching_invite["time"])
+ timedelta(days=INVITE_TIME_LIMIT)
).day > dt.now().day
if expired:
message = "This invite has expired, sorry."
context = {"request": request, "message": message}
return request.app.state.templates.TemplateResponse(
"invalid.html", context=context
)
context = {"request": request, "invited_by": username}
return request.app.state.templates.TemplateResponse(
"register.html", context=context
)
@router.post("/form/keycloak/register", dependencies=[Depends(fresh_token)])
def form_keycloak_register(
request: Request,
first_name: str = Form(...),
last_name: str = Form(...),
username: str = Form(...),
email: str = Form(...),
password: str = Form(...),
password_again: str = Form(...),
invited_by: str = Form(...),
):
context = {
"request": request,
"invited_by": invited_by,
"first_name": first_name,
"last_name": last_name,
"username": username,
"email": email,
}
try:
EmailStr().validate(email)
except errors.EmailError:
context["exception"] = "email is not valid?"
return request.app.state.templates.TemplateResponse(
"register.html", context=context
)
if password != password_again:
context["exception"] = "passwords don't match?"
return request.app.state.templates.TemplateResponse(
"register.html", context=context
)
payload = {
"email": email,
"username": username,
"enabled": True,
"firstName": first_name,
"lastName": last_name,
"credentials": [
{
"value": password,
"type": "password",
}
],
"realmRoles": [
"user_default",
],
"attributes": {"invited_by": invited_by},
}
try:
user_id = request.app.state.keycloak.create_user(
payload, exist_ok=False
)
request.app.state.keycloak.send_verify_email(user_id=user_id)
except Exception as exception:
request.app.state.log.error(
f"Keycloak user registration failed, saw: {exception}"
)
message = json.loads(exception.error_message).get(
"errorMessage", "Unknown reason"
)
context = {"request": request, "exception": message}
return request.app.state.templates.TemplateResponse(
"submit.html", context=context
)
context = {"request": request, "email": email}
return request.app.state.templates.TemplateResponse(
"submit.html", context=context
)

View File

@ -0,0 +1,21 @@
"""Home routes."""
from fastapi import APIRouter, Depends, Request
from members_lumbung_space.dependencies import (
get_invites,
get_user,
logged_in,
)
router = APIRouter()
@router.get("/", dependencies=[Depends(logged_in)])
async def home(
request: Request, user=Depends(get_user), invites=Depends(get_invites)
):
context = {"request": request, "user": user, "invites": invites}
return request.app.state.templates.TemplateResponse(
"admin.html", context=context
)

View File

@ -0,0 +1,25 @@
input {
display: block;
margin-top: 5px;
margin-bottom: 15px;
}
th, td {
padding: 15px;
}
table, th, td {
border: 1px solid black;
}
input {
border: 2px solid currentcolor;
}
input:invalid {
border: 2px dashed red;
}
input:invalid:focus {
background-image: linear-gradient(magenta, pink);
}

View File

@ -0,0 +1,29 @@
input {
display: block;
margin-top: 5px;
margin-bottom: 15px;
}
th, td {
padding: 15px;
}
table, th, td {
border: 1px solid black;
}
input {
border: 2px solid currentcolor;
}
input:invalid {
border: 2px dashed red;
}
input:invalid:focus {
background-image: linear-gradient(magenta, pink);
}
.error {
color: red;
}

View File

@ -0,0 +1,31 @@
{% extends "base.html" %}
{% block content %}
<p>
Hello, {{ user.preferred_username }} 👋
<small>(<a href="{{ url_for('logout') }}">logout</a>)</small>
</p>
{% if user.preferred_username in invites and invites[user.preferred_username]|length > 0 %}
<table>
<tr>
<th>Link</th>
<th>Validity</th>
<th>Actions</th>
</tr>
{% for invite in invites[user.preferred_username] %}
<tr>
<td>
<a class="invite" href="{{ url_for('register_invite', invite=invite.link) }}">
{{ url_for('register_invite', invite=invite.link) }}
</a>
</td>
<td> {{ invite.validity }} </td>
<td> <a href="{{ url_for('invite_keycloak_delete') }}?invite={{ invite.link }}">delete</a> </td>
</tr>
{% endfor %}
</table>
{% endif %}
<p>
<a href="{{ url_for('invite_keycloak_create') }}">Generate an invite link</a>
</p>
{% endblock %}

View File

@ -0,0 +1,18 @@
<!doctype html>
<html lang="en">
<head>
{% block head %}
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
{% if request.app.state.theme == "default" %}
<link href="{{ url_for('static', path='/default.css') }}" rel="stylesheet">
{% elif request.app.state.theme == "lumbung" %}
<link href="{{ url_for('static', path='/lumbung.css') }}" rel="stylesheet">
{% endif %}
<title>{% block title %}{% endblock %}</title>
{% endblock %}
</head>
<body>
<div id="content">{% block content %}{% endblock %}</div>
</body>
</html>

View File

@ -0,0 +1,5 @@
{% extends "base.html" %}
{% block content %}
<p>Woops, something went wrong: {{ message }}</p>
<p>Please contact your system adminstrator if this is unexpected.</p>
{% endblock %}

View File

@ -0,0 +1,6 @@
{% extends "base.html" %}
{% block content %}
<p>
<a href="{{ url_for('login_keycloak') }}">Login</a>
</p>
{% endblock %}

View File

@ -0,0 +1,35 @@
{% extends "base.html" %}
{% block content %}
<p>
You've been invited by {{ invited_by }} 🎉
</p>
{% if exception %}
<p class="error">Oops, something went wrong: {{ exception }} 😬</p>
{% endif %}
<form method="post" action="{{ url_for('form_keycloak_register') }}">
<label for="first_name">First name:</label>
<input type="text" name="first_name" value="{{ first_name }}" minlength="3" />
<label for="last_name">Last name:</label>
<input type="text" name="last_name" value="{{ last_name }}" minlength="3"/>
<label for="username">Username:</label>
<input type="text" name="username" value="{{ username }}" minlength="3"/>
<label for="email">Email:</label>
<input type="text" name="email" value="{{ email }}" minlength="3"/>
<label for="password">Password:</label>
<input type="password" name="password" minlength="8"/>
<label for="password_again">Password (just to be sure):</label>
<input type="password" name="password_again" minlength="8"/>
<input type="hidden" name="invited_by" value="{{ invited_by }}"/>
<input type="submit" value="Register" />
</form>
{% endblock %}

View File

@ -0,0 +1,10 @@
{% extends "base.html" %}
{% block content %}
{% if email %}
<p>Thank you! You will receive a welcome mail to {{ email }} shortly.</p>
<p>Don't forget to check your Spam folder, in case the email ends up there.</p>
{% elif exception %}
<p>Woops, something went wrong: {{ exception }}.</p>
<p>Please contact your system adminstrator if this is unexpected.</p>
{% endif %}
{% endblock %}