Closes #3. Closes #5. Closes #7. Closes #4. Closes #2.
This commit is contained in:
parent
195c6626c0
commit
d22abd2bc5
@ -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
|
||||
|
@ -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
|
||||
|
@ -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)
|
0
keycloak_collective_portal/__init__.py
Normal file
0
keycloak_collective_portal/__init__.py
Normal file
31
keycloak_collective_portal/config.py
Normal file
31
keycloak_collective_portal/config.py
Normal 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")
|
42
keycloak_collective_portal/dependencies.py
Normal file
42
keycloak_collective_portal/dependencies.py
Normal 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
|
7
keycloak_collective_portal/exceptions.py
Normal file
7
keycloak_collective_portal/exceptions.py
Normal file
@ -0,0 +1,7 @@
|
||||
"""Exceptions."""
|
||||
|
||||
|
||||
class RequiresLoginException(Exception):
|
||||
"""An exception thrown if the user is not logged in."""
|
||||
|
||||
pass
|
21
keycloak_collective_portal/keycloak.py
Normal file
21
keycloak_collective_portal/keycloak.py
Normal 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
|
71
keycloak_collective_portal/main.py
Normal file
71
keycloak_collective_portal/main.py
Normal 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)
|
25
keycloak_collective_portal/oidc.py
Normal file
25
keycloak_collective_portal/oidc.py
Normal 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
|
39
keycloak_collective_portal/redis.py
Normal file
39
keycloak_collective_portal/redis.py
Normal 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()
|
10
keycloak_collective_portal/routes/health.py
Normal file
10
keycloak_collective_portal/routes/health.py
Normal 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"}
|
49
keycloak_collective_portal/routes/invite.py
Normal file
49
keycloak_collective_portal/routes/invite.py
Normal 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"))
|
53
keycloak_collective_portal/routes/oidc.py
Normal file
53
keycloak_collective_portal/routes/oidc.py
Normal 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"))
|
96
keycloak_collective_portal/routes/register.py
Normal file
96
keycloak_collective_portal/routes/register.py
Normal 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
|
||||
)
|
21
keycloak_collective_portal/routes/root.py
Normal file
21
keycloak_collective_portal/routes/root.py
Normal 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
|
||||
)
|
3
keycloak_collective_portal/static/default.css
Normal file
3
keycloak_collective_portal/static/default.css
Normal file
@ -0,0 +1,3 @@
|
||||
input {
|
||||
display: block;
|
||||
}
|
3
keycloak_collective_portal/static/lumbung.css
Normal file
3
keycloak_collective_portal/static/lumbung.css
Normal file
@ -0,0 +1,3 @@
|
||||
input {
|
||||
display: block;
|
||||
}
|
28
keycloak_collective_portal/templates/admin.html
Normal file
28
keycloak_collective_portal/templates/admin.html
Normal 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 %}
|
18
keycloak_collective_portal/templates/base.html
Normal file
18
keycloak_collective_portal/templates/base.html
Normal 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>
|
5
keycloak_collective_portal/templates/invalid.html
Normal file
5
keycloak_collective_portal/templates/invalid.html
Normal 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 %}
|
6
keycloak_collective_portal/templates/login.html
Normal file
6
keycloak_collective_portal/templates/login.html
Normal file
@ -0,0 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<p>
|
||||
<a href="{{ url_for('login_keycloak') }}">Login</a>
|
||||
</p>
|
||||
{% endblock %}
|
26
keycloak_collective_portal/templates/register.html
Normal file
26
keycloak_collective_portal/templates/register.html
Normal 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 %}
|
10
keycloak_collective_portal/templates/submit.html
Normal file
10
keycloak_collective_portal/templates/submit.html
Normal 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 %}
|
2
makefile
2
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
|
||||
|
14
poetry.lock
generated
14
poetry.lock
generated
@ -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"},
|
||||
|
@ -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"
|
||||
|
@ -1 +0,0 @@
|
||||
// TODO
|
@ -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>
|
@ -1,10 +0,0 @@
|
||||
<html>
|
||||
<head>
|
||||
<title>Login</title>
|
||||
</head>
|
||||
<body>
|
||||
<p>
|
||||
<a href="{{ url_for('login_keycloak') }}">Login</a>
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
@ -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>
|
Loading…
Reference in New Issue
Block a user