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
Signed by: decentral1se
GPG Key ID: 92DAD76BD9567B8A
31 changed files with 588 additions and 328 deletions

View File

@ -15,5 +15,7 @@ RUN pip install --no-cache-dir --disable-pip-version-check --no-index --no-deps
COPY . /srv/project
WORKDIR /srv/project
CMD ["uvicorn", "--forwarded-allow-ips='*'", "--proxy-headers", "--host", "0.0.0.0", "keycloak_collective_portal:app"]
RUN apt update && apt install -yq curl
CMD ["uvicorn", "--forwarded-allow-ips='*'", "--proxy-headers", "--host", "0.0.0.0", "keycloak_collective_portal.main:app"]
EXPOSE 8000

View File

@ -40,6 +40,7 @@ your technology stack.
- **Service Account Roles tab**:
- **Client roles**: Under `realm-management` add `manage-users` and `view-users`
- Deploy using [`coop-cloud/keycloak-colective-portal`](https://git.autonomic.zone/coop-cloud/keycloak-collective-portal)
- See the example [.envrc](./.envrc) for the configuration available, more documentation will follow soon.
### From a collective member perspective
@ -49,9 +50,11 @@ your technology stack.
## Hacking
It's a [FastAPI](https://fastapi.tiangolo.com/) application. Currently being
developed with Python 3.9. Once we move out of the prototype stage, more
version compatability will be offered.
It's a [FastAPI](https://fastapi.tiangolo.com/) application (if you know
[Flask](https://flask.palletsprojects.com/en/2.0.x/) /
[Sanic](https://sanic.readthedocs.io/en/stable/) then it is more or less the
same thing). Currently being developed with Python 3.9. Once we move out of the
prototype stage, more version compatability will be offered.
```
$ docker run -p 6379:6379 -d redis:6-alpine

View File

@ -1,239 +0,0 @@
"""Community Keycloak SSO user management."""
import json
from datetime import datetime as dt
from datetime import timedelta
from os import environ
from uuid import uuid4
import httpx
from aioredis import create_redis_pool
from authlib.integrations.starlette_client import OAuth, OAuthError
from fastapi import Depends, FastAPI, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from humanize import naturaldelta
from keycloak import KeycloakAdmin
from starlette.exceptions import HTTPException
from starlette.middleware.sessions import SessionMiddleware
APP_SECRET_KEY = environ.get("APP_SECRET_KEY")
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")
BASE_URL = f"https://{KEYCLOAK_DOMAIN}/auth/realms/{KEYCLOAK_REALM}/protocol/openid-connect" # noqa
REDIS_DB = environ.get("REDIS_DB")
REDIS_HOST = environ.get("REDIS_HOST")
REDIS_PORT = environ.get("REDIS_PORT")
INVITE_TIME_LIMIT = environ.get("INVITE_TIME_LIMIT")
app = FastAPI(docs_url=None, redoc_url=None)
app.add_middleware(SessionMiddleware, secret_key=APP_SECRET_KEY)
templates = Jinja2Templates(directory="templates")
class RequiresLoginException(Exception):
pass
@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>")
async def logged_in(request: Request):
user = request.session.get("user")
if not user:
raise RequiresLoginException
return user
async def get_user(request: Request):
return request.session.get("user")
async def get_invites(request: Request, user=Depends(get_user)):
if not user:
idx, invites = b"0", {}
while idx:
idx, username = await app.state.redis.scan(idx)
invites[username[0]] = json.loads(
await app.state.redis.get(username[0])
)
return invites
username = user["preferred_username"]
invites = await app.state.redis.get(username)
if invites:
humanised = []
for invite in json.loads(invites):
invite["human_time"] = naturaldelta(
dt.fromisoformat(invite["time"])
+ timedelta(days=int(INVITE_TIME_LIMIT))
)
humanised.append(invite)
return humanised
return []
@app.on_event("startup")
async def starup_event():
app.state.redis = await create_redis_pool(
f"redis://{REDIS_HOST}:{REDIS_PORT}/{REDIS_DB}?encoding=utf-8"
)
oauth = OAuth()
oauth.register(
name="keycloak",
client_kwargs={"scope": "openid profile email"},
client_id=KEYCLOAK_CLIENT_ID,
client_secret=KEYCLOAK_CLIENT_SECRET,
authorize_url=f"{BASE_URL}/auth",
access_token_url=f"{BASE_URL}/token",
jwks_uri=f"{BASE_URL}/certs",
)
app.state.oauth = oauth
app.state.keycloak = KeycloakAdmin(
server_url=f"https://{KEYCLOAK_DOMAIN}/auth/",
realm_name=KEYCLOAK_REALM,
client_secret_key=KEYCLOAK_CLIENT_SECRET,
verify=True,
)
@app.on_event("shutdown")
async def shutdown_event():
app.state.redis.close()
await app.state.redis.wait_closed()
@app.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 templates.TemplateResponse("admin.html", context=context)
@app.get("/login")
async def login(request: Request):
return templates.TemplateResponse(
"login.html", context={"request": request}
)
@app.get("/login/keycloak")
async def login_keycloak(request: Request):
redirect_uri = request.url_for("auth_keycloak")
return await app.state.oauth.keycloak.authorize_redirect(
request, redirect_uri
)
@app.get("/auth/keycloak")
async def auth_keycloak(request: Request):
try:
token = await app.state.oauth.keycloak.authorize_access_token(request)
except Exception as exc:
return HTMLResponse(f"<p>{exc} (<a href='{home}'>home</a>)</p>")
user = await app.state.oauth.keycloak.parse_id_token(request, token)
request.session["user"] = dict(user)
return RedirectResponse(request.url_for("home"))
@app.get("/logout", dependencies=[Depends(logged_in)])
async def logout(request: Request):
try:
httpx.get(f"{BASE_URL}/logout")
except Exception as exc:
return HTMLResponse(f"<p>{exc} (<a href='{home}'>home</a>)</p>")
request.session.pop("user", None)
return RedirectResponse(request.url_for("login"))
@app.get("/invite/keycloak/create", dependencies=[Depends(logged_in)])
async def invite_keycloak_create(
request: Request, user=Depends(get_user), invites=Depends(get_invites)
):
invites.append({"link": str(uuid4()), "time": str(dt.now())})
await app.state.redis.set(user["preferred_username"], json.dumps(invites))
print(invites, json.dumps(invites))
return RedirectResponse(request.url_for("home"))
@app.get("/invite/keycloak/delete", dependencies=[Depends(logged_in)])
async def invite_keycloak_delete(
request: Request, user=Depends(get_user), invites=Depends(get_invites)
):
invite_to_delete = request.query_params.get("invite")
purged = [i for i in invites if i["link"] != invite_to_delete]
await app.state.redis.set(user["preferred_username"], json.dumps(purged))
return RedirectResponse(request.url_for("home"))
@app.get("/register/{invite}")
async def register_invite(
request: Request, invite: str, invites=Depends(get_invites)
):
matching, username = False, None
for username in invites:
if invite in [x["link"] for x in invites[username]]:
matching = True
username = username
if not matching:
return RedirectResponse(request.url_for("login"))
context = {"request": request, "username": username}
return templates.TemplateResponse("register.html", context=context)
@app.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(...),
):
user_id = app.state.keycloak.create_user(
{
"email": email,
"username": username,
"enabled": True,
"firstName": first_name,
"lastName": last_name,
"credentials": [
{
"value": password,
"type": "password",
}
],
"realmRoles": [
"user_default",
],
}
)
app.state.keycloak.send_verify_email(user_id=user_id)
context = {"request": request, "success": True}
return templates.TemplateResponse("register.html", context=context)

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 %}

View File

@ -7,7 +7,7 @@ run:
.venv/bin/pip install -U pip setuptools wheel poetry && \
.venv/bin/poetry install --dev; \
fi
.venv/bin/poetry run uvicorn keycloak_collective_portal:app --reload
.venv/bin/poetry run uvicorn keycloak_collective_portal.main:app --reload
redis:
@docker run -p 6379:6379 --name redis -d redis:6-alpine

14
poetry.lock generated
View File

@ -1,3 +1,11 @@
[[package]]
name = "aiofiles"
version = "0.7.0"
description = "File support for asyncio."
category = "main"
optional = false
python-versions = ">=3.6,<4.0"
[[package]]
name = "aioredis"
version = "1.3.1"
@ -634,9 +642,13 @@ python-versions = ">=3.6.1"
[metadata]
lock-version = "1.1"
python-versions = "^3.9"
content-hash = "6df09118b73a6b5e1a00cd4bc78d47d4b35faec4af719bd10f9f8a84bdd57ef0"
content-hash = "d8f978355587c9f76a7888c64b7d1409de886670a4b6a51cdfc0eedbd6ba3009"
[metadata.files]
aiofiles = [
{file = "aiofiles-0.7.0-py3-none-any.whl", hash = "sha256:c67a6823b5f23fcab0a2595a289cec7d8c863ffcb4322fb8cd6b90400aedfdbc"},
{file = "aiofiles-0.7.0.tar.gz", hash = "sha256:a1c4fc9b2ff81568c83e21392a82f344ea9d23da906e4f6a52662764545e19d4"},
]
aioredis = [
{file = "aioredis-1.3.1-py3-none-any.whl", hash = "sha256:b61808d7e97b7cd5a92ed574937a079c9387fdadd22bfbfa7ad2fd319ecc26e3"},
{file = "aioredis-1.3.1.tar.gz", hash = "sha256:15f8af30b044c771aee6787e5ec24694c048184c7b9e54c3b60c750a4b93273a"},

View File

@ -17,6 +17,7 @@ aioredis = "^1.3.1"
humanize = "^3.7.1"
python-multipart = "^0.0.5"
python-keycloak = "^0.25.0"
aiofiles = "^0.7.0"
[tool.poetry.dev-dependencies]
black = "^21.6b0"

View File

@ -1 +0,0 @@
// TODO

View File

@ -1,28 +0,0 @@
<html>
<head>
<title>Home</title>
</head>
<body>
<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 %}
<tr>
<td> <a class="invite" href="{{ url_for('register_invite', invite=invite.link) }}">{{ url_for('register_invite', invite=invite.link) }}</a> </td>
<td> {{ invite.human_time }} </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>
</body>
</html>

View File

@ -1,10 +0,0 @@
<html>
<head>
<title>Login</title>
</head>
<body>
<p>
<a href="{{ url_for('login_keycloak') }}">Login</a>
</p>
</body>
</html>

View File

@ -1,44 +0,0 @@
<html>
<head>
<title>Register</title>
<style>
input {
display: block;
}
</style>
</head>
<body>
<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>
{% if success %}
<p>
An email verification mail has been sent to {{ email }}. Please check your
mail shortly to verify account and set your account password. Please also
check your spam inbox in case it ends up in there.
</p>
{% elif failure %}
<p>Something went wrong, oops! Please contact the system administrator.</p>
{% endif %}
</body>
</html>