From d22abd2bc51b518fc37212ce841af6e7b6db9362 Mon Sep 17 00:00:00 2001 From: decentral1se Date: Sun, 13 Jun 2021 11:43:30 +0200 Subject: [PATCH] The Big Refactor Closes https://git.autonomic.zone/autonomic-cooperative/keycloak-collective-portal/issues/3. Closes https://git.autonomic.zone/autonomic-cooperative/keycloak-collective-portal/issues/5. Closes https://git.autonomic.zone/autonomic-cooperative/keycloak-collective-portal/issues/7. Closes https://git.autonomic.zone/autonomic-cooperative/keycloak-collective-portal/issues/4. Closes https://git.autonomic.zone/autonomic-cooperative/keycloak-collective-portal/issues/2. --- Dockerfile | 4 +- README.md | 9 +- keycloak_collective_portal.py | 239 ------------------ keycloak_collective_portal/__init__.py | 0 keycloak_collective_portal/config.py | 31 +++ keycloak_collective_portal/dependencies.py | 42 +++ keycloak_collective_portal/exceptions.py | 7 + keycloak_collective_portal/keycloak.py | 21 ++ keycloak_collective_portal/main.py | 71 ++++++ keycloak_collective_portal/oidc.py | 25 ++ keycloak_collective_portal/redis.py | 39 +++ keycloak_collective_portal/routes/health.py | 10 + keycloak_collective_portal/routes/invite.py | 49 ++++ keycloak_collective_portal/routes/oidc.py | 53 ++++ keycloak_collective_portal/routes/register.py | 96 +++++++ keycloak_collective_portal/routes/root.py | 21 ++ keycloak_collective_portal/static/default.css | 3 + keycloak_collective_portal/static/lumbung.css | 3 + .../templates/admin.html | 28 ++ .../templates/base.html | 18 ++ .../templates/invalid.html | 5 + .../templates/login.html | 6 + .../templates/register.html | 26 ++ .../templates/submit.html | 10 + makefile | 2 +- poetry.lock | 14 +- pyproject.toml | 1 + styles.css | 1 - templates/admin.html | 28 -- templates/login.html | 10 - templates/register.html | 44 ---- 31 files changed, 588 insertions(+), 328 deletions(-) delete mode 100644 keycloak_collective_portal.py create mode 100644 keycloak_collective_portal/__init__.py create mode 100644 keycloak_collective_portal/config.py create mode 100644 keycloak_collective_portal/dependencies.py create mode 100644 keycloak_collective_portal/exceptions.py create mode 100644 keycloak_collective_portal/keycloak.py create mode 100644 keycloak_collective_portal/main.py create mode 100644 keycloak_collective_portal/oidc.py create mode 100644 keycloak_collective_portal/redis.py create mode 100644 keycloak_collective_portal/routes/health.py create mode 100644 keycloak_collective_portal/routes/invite.py create mode 100644 keycloak_collective_portal/routes/oidc.py create mode 100644 keycloak_collective_portal/routes/register.py create mode 100644 keycloak_collective_portal/routes/root.py create mode 100644 keycloak_collective_portal/static/default.css create mode 100644 keycloak_collective_portal/static/lumbung.css create mode 100644 keycloak_collective_portal/templates/admin.html create mode 100644 keycloak_collective_portal/templates/base.html create mode 100644 keycloak_collective_portal/templates/invalid.html create mode 100644 keycloak_collective_portal/templates/login.html create mode 100644 keycloak_collective_portal/templates/register.html create mode 100644 keycloak_collective_portal/templates/submit.html delete mode 100644 styles.css delete mode 100644 templates/admin.html delete mode 100644 templates/login.html delete mode 100644 templates/register.html diff --git a/Dockerfile b/Dockerfile index 63b267b..0c68ea4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md index 14d0185..fb558f4 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/keycloak_collective_portal.py b/keycloak_collective_portal.py deleted file mode 100644 index 08944f1..0000000 --- a/keycloak_collective_portal.py +++ /dev/null @@ -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"

{exc.detail} (home)

") - - -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"

{exc} (home)

") - - 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"

{exc} (home)

") - - 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) diff --git a/keycloak_collective_portal/__init__.py b/keycloak_collective_portal/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/keycloak_collective_portal/config.py b/keycloak_collective_portal/config.py new file mode 100644 index 0000000..72b3a1d --- /dev/null +++ b/keycloak_collective_portal/config.py @@ -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") diff --git a/keycloak_collective_portal/dependencies.py b/keycloak_collective_portal/dependencies.py new file mode 100644 index 0000000..0605eac --- /dev/null +++ b/keycloak_collective_portal/dependencies.py @@ -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 diff --git a/keycloak_collective_portal/exceptions.py b/keycloak_collective_portal/exceptions.py new file mode 100644 index 0000000..2395b3e --- /dev/null +++ b/keycloak_collective_portal/exceptions.py @@ -0,0 +1,7 @@ +"""Exceptions.""" + + +class RequiresLoginException(Exception): + """An exception thrown if the user is not logged in.""" + + pass diff --git a/keycloak_collective_portal/keycloak.py b/keycloak_collective_portal/keycloak.py new file mode 100644 index 0000000..61da418 --- /dev/null +++ b/keycloak_collective_portal/keycloak.py @@ -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 diff --git a/keycloak_collective_portal/main.py b/keycloak_collective_portal/main.py new file mode 100644 index 0000000..156f8c7 --- /dev/null +++ b/keycloak_collective_portal/main.py @@ -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"

{exc.detail} (home)

") + + +@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) diff --git a/keycloak_collective_portal/oidc.py b/keycloak_collective_portal/oidc.py new file mode 100644 index 0000000..04c8f64 --- /dev/null +++ b/keycloak_collective_portal/oidc.py @@ -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 diff --git a/keycloak_collective_portal/redis.py b/keycloak_collective_portal/redis.py new file mode 100644 index 0000000..855a81f --- /dev/null +++ b/keycloak_collective_portal/redis.py @@ -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() diff --git a/keycloak_collective_portal/routes/health.py b/keycloak_collective_portal/routes/health.py new file mode 100644 index 0000000..e54d0b7 --- /dev/null +++ b/keycloak_collective_portal/routes/health.py @@ -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"} diff --git a/keycloak_collective_portal/routes/invite.py b/keycloak_collective_portal/routes/invite.py new file mode 100644 index 0000000..4162633 --- /dev/null +++ b/keycloak_collective_portal/routes/invite.py @@ -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")) diff --git a/keycloak_collective_portal/routes/oidc.py b/keycloak_collective_portal/routes/oidc.py new file mode 100644 index 0000000..952ce07 --- /dev/null +++ b/keycloak_collective_portal/routes/oidc.py @@ -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"

{exc} (home)

") + + 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"

{exc} (home)

") + + request.session.pop("user", None) + + return RedirectResponse(request.url_for("login")) diff --git a/keycloak_collective_portal/routes/register.py b/keycloak_collective_portal/routes/register.py new file mode 100644 index 0000000..ee9ec57 --- /dev/null +++ b/keycloak_collective_portal/routes/register.py @@ -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 + ) diff --git a/keycloak_collective_portal/routes/root.py b/keycloak_collective_portal/routes/root.py new file mode 100644 index 0000000..eb2a806 --- /dev/null +++ b/keycloak_collective_portal/routes/root.py @@ -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 + ) diff --git a/keycloak_collective_portal/static/default.css b/keycloak_collective_portal/static/default.css new file mode 100644 index 0000000..d6b5cb0 --- /dev/null +++ b/keycloak_collective_portal/static/default.css @@ -0,0 +1,3 @@ +input { + display: block; +} diff --git a/keycloak_collective_portal/static/lumbung.css b/keycloak_collective_portal/static/lumbung.css new file mode 100644 index 0000000..d6b5cb0 --- /dev/null +++ b/keycloak_collective_portal/static/lumbung.css @@ -0,0 +1,3 @@ +input { + display: block; +} diff --git a/keycloak_collective_portal/templates/admin.html b/keycloak_collective_portal/templates/admin.html new file mode 100644 index 0000000..f98b2f2 --- /dev/null +++ b/keycloak_collective_portal/templates/admin.html @@ -0,0 +1,28 @@ +{% extends "base.html" %} +{% block content %} +

+ Hello, {{ user.preferred_username }} + (logout) +

+ + + + + + + {% for invite in invites[user.preferred_username] %} + + + + + + {% endfor %} +
LinkValidityOperations
+ + {{ url_for('register_invite', invite=invite.link) }} + + {{ invite.validity }} delete
+

+ Generate an invite link +

+{% endblock %} diff --git a/keycloak_collective_portal/templates/base.html b/keycloak_collective_portal/templates/base.html new file mode 100644 index 0000000..14a0e5b --- /dev/null +++ b/keycloak_collective_portal/templates/base.html @@ -0,0 +1,18 @@ + + + + {% block head %} + + + {% if request.app.state.theme == "default" %} + + {% elif request.app.state.theme == "lumbung" %} + + {% endif %} + {% block title %}{% endblock %} + {% endblock %} + + +
{% block content %}{% endblock %}
+ + diff --git a/keycloak_collective_portal/templates/invalid.html b/keycloak_collective_portal/templates/invalid.html new file mode 100644 index 0000000..987489b --- /dev/null +++ b/keycloak_collective_portal/templates/invalid.html @@ -0,0 +1,5 @@ +{% extends "base.html" %} +{% block content %} +

Woops, something went wrong: {{ message }}

+

Please contact your system adminstrator if this is unexpected.

+{% endblock %} diff --git a/keycloak_collective_portal/templates/login.html b/keycloak_collective_portal/templates/login.html new file mode 100644 index 0000000..29e56ca --- /dev/null +++ b/keycloak_collective_portal/templates/login.html @@ -0,0 +1,6 @@ +{% extends "base.html" %} +{% block content %} +

+ Login +

+{% endblock %} diff --git a/keycloak_collective_portal/templates/register.html b/keycloak_collective_portal/templates/register.html new file mode 100644 index 0000000..ee5268b --- /dev/null +++ b/keycloak_collective_portal/templates/register.html @@ -0,0 +1,26 @@ +{% extends "base.html" %} +{% block content %} +

+ You've been invited by {{ username }} to register an account! +

+ +
+ + + + + + + + + + + + + + + + +
+ +{% endblock %} diff --git a/keycloak_collective_portal/templates/submit.html b/keycloak_collective_portal/templates/submit.html new file mode 100644 index 0000000..daf8d55 --- /dev/null +++ b/keycloak_collective_portal/templates/submit.html @@ -0,0 +1,10 @@ +{% extends "base.html" %} +{% block content %} + {% if email %} +

Thank you! You will receive a welcome mail to {{ email }} shortly.

+

Don't forget to check your Spam folder, in case the email ends up there.

+ {% elif exception %} +

Woops, something went wrong: {{ exception }}.

+

Please contact your system adminstrator if this is unexpected.

+ {% endif %} +{% endblock %} diff --git a/makefile b/makefile index 70d311b..603540c 100644 --- a/makefile +++ b/makefile @@ -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 diff --git a/poetry.lock b/poetry.lock index 1dacbe6..e1fd73b 100644 --- a/poetry.lock +++ b/poetry.lock @@ -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"}, diff --git a/pyproject.toml b/pyproject.toml index ea67945..bc0dde5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/styles.css b/styles.css deleted file mode 100644 index 70b786d..0000000 --- a/styles.css +++ /dev/null @@ -1 +0,0 @@ -// TODO diff --git a/templates/admin.html b/templates/admin.html deleted file mode 100644 index a6e2e83..0000000 --- a/templates/admin.html +++ /dev/null @@ -1,28 +0,0 @@ - - - Home - - -

- Hello, {{ user.preferred_username }} - (logout) -

- - - - - - - {% for invite in invites %} - - - - - - {% endfor %} -
LinkValidityOperations
{{ url_for('register_invite', invite=invite.link) }} {{ invite.human_time }} delete
-

- Generate an invite link -

- - diff --git a/templates/login.html b/templates/login.html deleted file mode 100644 index 4b31681..0000000 --- a/templates/login.html +++ /dev/null @@ -1,10 +0,0 @@ - - - Login - - -

- Login -

- - diff --git a/templates/register.html b/templates/register.html deleted file mode 100644 index 11c7201..0000000 --- a/templates/register.html +++ /dev/null @@ -1,44 +0,0 @@ - - - Register - - - -

- You've been invited by {{ username }} to register an account! -

- -
- - - - - - - - - - - - - - - - -
- - {% if success %} -

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

- {% elif failure %} -

Something went wrong, oops! Please contact the system administrator.

- {% endif %} - -