2021-06-11 11:32:41 +00:00
|
|
|
"""Community Keycloak SSO user management."""
|
|
|
|
|
2021-06-11 22:53:57 +00:00
|
|
|
import json
|
2021-06-12 00:15:50 +00:00
|
|
|
from datetime import datetime as dt
|
|
|
|
from datetime import timedelta
|
2021-06-11 13:58:28 +00:00
|
|
|
from os import environ
|
2021-06-11 22:53:57 +00:00
|
|
|
from uuid import uuid4
|
2021-06-11 13:58:28 +00:00
|
|
|
|
2021-06-11 20:30:13 +00:00
|
|
|
import httpx
|
2021-06-11 22:53:57 +00:00
|
|
|
from aioredis import create_redis_pool
|
2021-06-11 13:58:28 +00:00
|
|
|
from authlib.integrations.starlette_client import OAuth, OAuthError
|
2021-06-12 17:46:48 +00:00
|
|
|
from fastapi import Depends, FastAPI, Form, HTTPException, Request
|
2021-06-11 12:23:13 +00:00
|
|
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
2021-06-11 12:14:04 +00:00
|
|
|
from fastapi.templating import Jinja2Templates
|
2021-06-12 00:15:50 +00:00
|
|
|
from humanize import naturaldelta
|
2021-06-12 17:46:48 +00:00
|
|
|
from keycloak import KeycloakAdmin
|
2021-06-11 22:53:57 +00:00
|
|
|
from starlette.exceptions import HTTPException
|
2021-06-11 12:23:13 +00:00
|
|
|
from starlette.middleware.sessions import SessionMiddleware
|
2021-06-11 11:32:41 +00:00
|
|
|
|
2021-06-11 13:58:28 +00:00
|
|
|
APP_SECRET_KEY = environ.get("APP_SECRET_KEY")
|
2021-06-11 22:53:57 +00:00
|
|
|
|
2021-06-11 13:58:28 +00:00
|
|
|
KEYCLOAK_CLIENT_ID = environ.get("KEYCLOAK_CLIENT_ID")
|
|
|
|
KEYCLOAK_CLIENT_SECRET = environ.get("KEYCLOAK_CLIENT_SECRET")
|
2021-06-11 22:53:57 +00:00
|
|
|
|
2021-06-11 14:39:22 +00:00
|
|
|
KEYCLOAK_DOMAIN = environ.get("KEYCLOAK_DOMAIN")
|
|
|
|
KEYCLOAK_REALM = environ.get("KEYCLOAK_REALM")
|
2021-06-11 22:53:57 +00:00
|
|
|
BASE_URL = f"https://{KEYCLOAK_DOMAIN}/auth/realms/{KEYCLOAK_REALM}/protocol/openid-connect" # noqa
|
2021-06-11 13:58:28 +00:00
|
|
|
|
2021-06-11 22:53:57 +00:00
|
|
|
REDIS_DB = environ.get("REDIS_DB")
|
|
|
|
REDIS_HOST = environ.get("REDIS_HOST")
|
|
|
|
REDIS_PORT = environ.get("REDIS_PORT")
|
|
|
|
|
2021-06-12 00:15:50 +00:00
|
|
|
INVITE_TIME_LIMIT = environ.get("INVITE_TIME_LIMIT")
|
|
|
|
|
2021-06-11 22:53:57 +00:00
|
|
|
app = FastAPI(docs_url=None, redoc_url=None)
|
2021-06-11 13:58:28 +00:00
|
|
|
app.add_middleware(SessionMiddleware, secret_key=APP_SECRET_KEY)
|
2021-06-11 12:14:04 +00:00
|
|
|
templates = Jinja2Templates(directory="templates")
|
2021-06-11 11:32:41 +00:00
|
|
|
|
|
|
|
|
2021-06-11 22:53:57 +00:00
|
|
|
class RequiresLoginException(Exception):
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
@app.exception_handler(RequiresLoginException)
|
|
|
|
async def requires_login(request, exception):
|
2021-06-11 20:28:43 +00:00
|
|
|
return RedirectResponse(request.url_for("login"))
|
|
|
|
|
|
|
|
|
2021-06-11 22:53:57 +00:00
|
|
|
@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")
|
|
|
|
|
|
|
|
|
2021-06-12 00:15:50 +00:00
|
|
|
async def get_invites(request: Request, user=Depends(get_user)):
|
2021-06-12 17:46:48 +00:00
|
|
|
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
|
|
|
|
|
2021-06-12 00:15:50 +00:00
|
|
|
username = user["preferred_username"]
|
|
|
|
invites = await app.state.redis.get(username)
|
2021-06-12 17:46:48 +00:00
|
|
|
|
2021-06-12 00:15:50 +00:00
|
|
|
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
|
2021-06-12 17:46:48 +00:00
|
|
|
|
2021-06-12 00:15:50 +00:00
|
|
|
return []
|
|
|
|
|
|
|
|
|
2021-06-11 22:53:57 +00:00
|
|
|
@app.on_event("startup")
|
|
|
|
async def starup_event():
|
2021-06-12 00:15:50 +00:00
|
|
|
app.state.redis = await create_redis_pool(
|
2021-06-11 22:53:57 +00:00
|
|
|
f"redis://{REDIS_HOST}:{REDIS_PORT}/{REDIS_DB}?encoding=utf-8"
|
|
|
|
)
|
|
|
|
|
2021-06-12 17:46:48 +00:00
|
|
|
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,
|
|
|
|
)
|
|
|
|
|
2021-06-11 22:53:57 +00:00
|
|
|
|
|
|
|
@app.on_event("shutdown")
|
|
|
|
async def shutdown_event():
|
|
|
|
app.state.redis.close()
|
|
|
|
await app.state.redis.wait_closed()
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/", dependencies=[Depends(logged_in)])
|
2021-06-12 00:15:50 +00:00
|
|
|
async def home(
|
|
|
|
request: Request, user=Depends(get_user), invites=Depends(get_invites)
|
|
|
|
):
|
|
|
|
context = {"request": request, "user": user, "invites": invites}
|
2021-06-11 22:53:57 +00:00
|
|
|
return templates.TemplateResponse("admin.html", context=context)
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/login")
|
2021-06-11 20:28:43 +00:00
|
|
|
async def login(request: Request):
|
|
|
|
return templates.TemplateResponse(
|
|
|
|
"login.html", context={"request": request}
|
|
|
|
)
|
2021-06-11 13:58:28 +00:00
|
|
|
|
|
|
|
|
|
|
|
@app.get("/login/keycloak")
|
|
|
|
async def login_keycloak(request: Request):
|
2021-06-11 17:44:19 +00:00
|
|
|
redirect_uri = request.url_for("auth_keycloak")
|
2021-06-12 17:46:48 +00:00
|
|
|
return await app.state.oauth.keycloak.authorize_redirect(
|
|
|
|
request, redirect_uri
|
|
|
|
)
|
2021-06-11 13:58:28 +00:00
|
|
|
|
|
|
|
|
2021-06-11 16:14:34 +00:00
|
|
|
@app.get("/auth/keycloak")
|
2021-06-11 16:15:50 +00:00
|
|
|
async def auth_keycloak(request: Request):
|
2021-06-11 13:58:28 +00:00
|
|
|
try:
|
2021-06-12 17:46:48 +00:00
|
|
|
token = await app.state.oauth.keycloak.authorize_access_token(request)
|
2021-06-11 22:53:57 +00:00
|
|
|
except Exception as exc:
|
|
|
|
return HTMLResponse(f"<p>{exc} (<a href='{home}'>home</a>)</p>")
|
2021-06-11 12:23:13 +00:00
|
|
|
|
2021-06-12 17:46:48 +00:00
|
|
|
user = await app.state.oauth.keycloak.parse_id_token(request, token)
|
2021-06-11 22:53:57 +00:00
|
|
|
request.session["user"] = dict(user)
|
2021-06-11 12:23:13 +00:00
|
|
|
|
2021-06-11 22:53:57 +00:00
|
|
|
return RedirectResponse(request.url_for("home"))
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/logout", dependencies=[Depends(logged_in)])
|
2021-06-11 13:58:28 +00:00
|
|
|
async def logout(request: Request):
|
2021-06-11 22:53:57 +00:00
|
|
|
try:
|
|
|
|
httpx.get(f"{BASE_URL}/logout")
|
|
|
|
except Exception as exc:
|
|
|
|
return HTMLResponse(f"<p>{exc} (<a href='{home}'>home</a>)</p>")
|
|
|
|
|
2021-06-11 13:58:28 +00:00
|
|
|
request.session.pop("user", None)
|
2021-06-11 22:53:57 +00:00
|
|
|
|
2021-06-11 20:28:43 +00:00
|
|
|
return RedirectResponse(request.url_for("login"))
|
2021-06-11 22:53:57 +00:00
|
|
|
|
|
|
|
|
2021-06-12 00:15:50 +00:00
|
|
|
@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())})
|
2021-06-12 17:46:48 +00:00
|
|
|
await app.state.redis.set(user["preferred_username"], json.dumps(invites))
|
|
|
|
print(invites, json.dumps(invites))
|
2021-06-12 00:15:50 +00:00
|
|
|
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)
|
|
|
|
):
|
2021-06-12 17:46:48 +00:00
|
|
|
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)
|