The Big Refactor
All checks were successful
continuous-integration/drone/push Build is passing

Closes #3.
Closes #5.
Closes #7.
Closes #4.
Closes #2.
This commit is contained in:
decentral1se
2021-06-13 11:43:30 +02:00
parent 195c6626c0
commit d22abd2bc5
31 changed files with 588 additions and 328 deletions

View File

View File

@ -0,0 +1,31 @@
"""Application configuraiton."""
from datetime import timedelta
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() / "keycloak_collective_portal" / "static"
TEMPLATE_DIR = Path(".").absolute() / "keycloak_collective_portal" / "templates"
# Theme selection
APP_THEME = environ.get("APP_THEME", "default")

View File

@ -0,0 +1,42 @@
"""Route dependencies."""
from datetime import datetime as dt
from datetime import timedelta
from fastapi import Depends, Request
from humanize import naturaldelta
async def logged_in(request: Request):
"""Ensure the user is logged in."""
from keycloak_collective_portal.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 keycloak_collective_portal.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 keycloak_collective_portal.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,71 @@
"""App entrypoint."""
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 keycloak_collective_portal.config import (
APP_SECRET_KEY,
APP_THEME,
REDIS_DB,
REDIS_HOST,
REDIS_PORT,
STATIC_DIR,
TEMPLATE_DIR,
)
from keycloak_collective_portal.exceptions import RequiresLoginException
from keycloak_collective_portal.keycloak import init_keycloak
from keycloak_collective_portal.oidc import init_oidc
from keycloak_collective_portal.redis import Redis
from keycloak_collective_portal.routes import (
health,
invite,
oidc,
register,
root,
)
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 starup_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():
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()
app.state.keycloak = init_keycloak()
app.state.templates = Jinja2Templates(directory=TEMPLATE_DIR)
app.state.theme = APP_THEME
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 keycloak_collective_portal.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,39 @@
"""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:
return json.loads(await self._redis.get(key))
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,49 @@
"""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 keycloak_collective_portal.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())}
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)
purged = [i for i in invites if i["link"] != invite_to_delete]
await request.app.state.redis.set(user["preferred_username"], purged)
return RedirectResponse(request.url_for("home"))

View File

@ -0,0 +1,53 @@
"""OpenID Connect routes."""
import httpx
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from keycloak_collective_portal.dependencies import logged_in
router = APIRouter()
@router.get("/login")
async def login(request: Request):
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 keycloak_collective_portal.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,96 @@
"""Registration routes."""
import json
from datetime import datetime as dt
from datetime import timedelta
from fastapi import APIRouter, Depends, Form, Request
from keycloak_collective_portal.dependencies import get_invites
router = APIRouter()
@router.get("/register/{invite}")
async def register_invite(
request: Request, invite: str, invites=Depends(get_invites)
):
from keycloak_collective_portal.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, "username": username}
return request.app.state.templates.TemplateResponse(
"register.html", context=context
)
@router.post("/form/keycloak/register")
def form_keycloak_register(
request: Request,
first_name: str = Form(...),
last_name: str = Form(...),
username: str = Form(...),
email: str = Form(...),
password: str = Form(...),
):
payload = {
"email": email,
"username": username,
"enabled": True,
"firstName": first_name,
"lastName": last_name,
"credentials": [
{
"value": password,
"type": "password",
}
],
"realmRoles": [
"user_default",
],
}
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:
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 keycloak_collective_portal.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,3 @@
input {
display: block;
}

View File

@ -0,0 +1,3 @@
input {
display: block;
}

View File

@ -0,0 +1,28 @@
{% extends "base.html" %}
{% block content %}
<p>
Hello, {{ user.preferred_username }}
<small>(<a href="{{ url_for('logout') }}">logout</a>)</small>
</p>
<table>
<tr>
<th>Link</th>
<th>Validity</th>
<th>Operations</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>
<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,26 @@
{% extends "base.html" %}
{% block content %}
<p>
You've been invited by {{ username }} to register an account!
</p>
<form method="post" action="{{ url_for('form_keycloak_register') }}">
<label for="first_name">First name:</label>
<input type="text" name="first_name" />
<label for="last_name">Last name:</label>
<input type="text" name="last_name" />
<label for="username">Username:</label>
<input type="text" name="username" />
<label for="email">Email:</label>
<input type="text" name="email" />
<label for="password">Password:</label>
<input type="password" name="password" />
<input type="submit" />
</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 %}