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
|
COPY . /srv/project
|
||||||
WORKDIR /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
|
EXPOSE 8000
|
||||||
|
@ -40,6 +40,7 @@ your technology stack.
|
|||||||
- **Service Account Roles tab**:
|
- **Service Account Roles tab**:
|
||||||
- **Client roles**: Under `realm-management` add `manage-users` and `view-users`
|
- **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)
|
- 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
|
### From a collective member perspective
|
||||||
|
|
||||||
@ -49,9 +50,11 @@ your technology stack.
|
|||||||
|
|
||||||
## Hacking
|
## Hacking
|
||||||
|
|
||||||
It's a [FastAPI](https://fastapi.tiangolo.com/) application. Currently being
|
It's a [FastAPI](https://fastapi.tiangolo.com/) application (if you know
|
||||||
developed with Python 3.9. Once we move out of the prototype stage, more
|
[Flask](https://flask.palletsprojects.com/en/2.0.x/) /
|
||||||
version compatability will be offered.
|
[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
|
$ 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/pip install -U pip setuptools wheel poetry && \
|
||||||
.venv/bin/poetry install --dev; \
|
.venv/bin/poetry install --dev; \
|
||||||
fi
|
fi
|
||||||
.venv/bin/poetry run uvicorn keycloak_collective_portal:app --reload
|
.venv/bin/poetry run uvicorn keycloak_collective_portal.main:app --reload
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
@docker run -p 6379:6379 --name redis -d redis:6-alpine
|
@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]]
|
[[package]]
|
||||||
name = "aioredis"
|
name = "aioredis"
|
||||||
version = "1.3.1"
|
version = "1.3.1"
|
||||||
@ -634,9 +642,13 @@ python-versions = ">=3.6.1"
|
|||||||
[metadata]
|
[metadata]
|
||||||
lock-version = "1.1"
|
lock-version = "1.1"
|
||||||
python-versions = "^3.9"
|
python-versions = "^3.9"
|
||||||
content-hash = "6df09118b73a6b5e1a00cd4bc78d47d4b35faec4af719bd10f9f8a84bdd57ef0"
|
content-hash = "d8f978355587c9f76a7888c64b7d1409de886670a4b6a51cdfc0eedbd6ba3009"
|
||||||
|
|
||||||
[metadata.files]
|
[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 = [
|
aioredis = [
|
||||||
{file = "aioredis-1.3.1-py3-none-any.whl", hash = "sha256:b61808d7e97b7cd5a92ed574937a079c9387fdadd22bfbfa7ad2fd319ecc26e3"},
|
{file = "aioredis-1.3.1-py3-none-any.whl", hash = "sha256:b61808d7e97b7cd5a92ed574937a079c9387fdadd22bfbfa7ad2fd319ecc26e3"},
|
||||||
{file = "aioredis-1.3.1.tar.gz", hash = "sha256:15f8af30b044c771aee6787e5ec24694c048184c7b9e54c3b60c750a4b93273a"},
|
{file = "aioredis-1.3.1.tar.gz", hash = "sha256:15f8af30b044c771aee6787e5ec24694c048184c7b9e54c3b60c750a4b93273a"},
|
||||||
|
@ -17,6 +17,7 @@ aioredis = "^1.3.1"
|
|||||||
humanize = "^3.7.1"
|
humanize = "^3.7.1"
|
||||||
python-multipart = "^0.0.5"
|
python-multipart = "^0.0.5"
|
||||||
python-keycloak = "^0.25.0"
|
python-keycloak = "^0.25.0"
|
||||||
|
aiofiles = "^0.7.0"
|
||||||
|
|
||||||
[tool.poetry.dev-dependencies]
|
[tool.poetry.dev-dependencies]
|
||||||
black = "^21.6b0"
|
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