Compare commits
35 Commits
480cd4a4fe
...
main
Author | SHA1 | Date | |
---|---|---|---|
dd2656e9dd | |||
12fc3ac359 | |||
a4f2149223 | |||
74280d6093 | |||
bae57efaa6 | |||
2835682455 | |||
d0d0a1871d | |||
ce9b07cf50 | |||
98c93ac279 | |||
f4ca0b3f1f | |||
99e968a587 | |||
3f326b74d6 | |||
df35fe181a | |||
53a6abb7a8 | |||
de207e3153 | |||
e50ffa50ee | |||
3138a2964f | |||
e347b1eed1 | |||
d8d0504570
|
|||
89ce72d8cd
|
|||
7b65bc6f26
|
|||
ef40138911
|
|||
777bd824a5
|
|||
c45ee1647c
|
|||
d9e68d997d
|
|||
f6597e8fae
|
|||
ae5309a476
|
|||
8ed276c1b8
|
|||
0ff83c60f4
|
|||
d8dce4de1b
|
|||
4cc3bc4178
|
|||
d22abd2bc5
|
|||
195c6626c0
|
|||
d06656b639
|
|||
80dd93823e
|
@ -5,12 +5,12 @@ steps:
|
||||
- name: publish container
|
||||
image: plugins/docker
|
||||
settings:
|
||||
username:
|
||||
from_secret: docker_reg_username
|
||||
username: autono-bot
|
||||
password:
|
||||
from_secret: docker_reg_passwd
|
||||
repo: decentral1se/keycloak-collective-portal
|
||||
from_secret: git_autonomic_zone_token_autono-bot
|
||||
repo: git.autonomic.zone/autonomic-cooperative/keycloak-collective-portal
|
||||
auto_tag: true
|
||||
registry: git.autonomic.zone
|
||||
trigger:
|
||||
branch:
|
||||
- main
|
||||
|
11
.env.sample
Normal file
11
.env.sample
Normal file
@ -0,0 +1,11 @@
|
||||
APP_SECRET_KEY=foobar
|
||||
APP_THEME=lumbung
|
||||
INVITE_TIME_LIMIT=30
|
||||
KEYCLOAK_CLIENT_ID=admin-cli
|
||||
KEYCLOAK_CLIENT_SECRET=barfoo
|
||||
KEYCLOAK_DOMAIN=login.lumbung.space
|
||||
KEYCLOAK_REALM=lumbung-space
|
||||
REDIS_DB=0
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
AUTOMATICALLY_LOG_IN=False
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,2 +1,3 @@
|
||||
*.pyc
|
||||
.env
|
||||
/__pycache__/
|
||||
|
@ -1,9 +1,10 @@
|
||||
FROM python:3.9 as builder
|
||||
FROM python:3.9 AS builder
|
||||
|
||||
COPY . /project
|
||||
WORKDIR /project
|
||||
|
||||
RUN pip install poetry
|
||||
RUN poetry self add poetry-plugin-export
|
||||
RUN poetry export --without-hashes -o requirements.txt -f requirements.txt
|
||||
RUN poetry build --format=wheel
|
||||
RUN cp dist/* /tmp
|
||||
@ -15,5 +16,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
|
||||
|
75
README.md
75
README.md
@ -1,17 +1,36 @@
|
||||
# keycloak-collective-portal
|
||||
|
||||
> **WARNING**: this software is in a pre-alpha quality state and is an initial
|
||||
> prototype. It is being developed within the context of
|
||||
> [lumbung.space](https://lumbung.space/) and may have hard-coded values and
|
||||
> configuration specifically for that environment. If the idea of this software
|
||||
> sounds interesting to you, please let us know on the issue tracker!
|
||||
|
||||
[](https://drone.autonomic.zone/autonomic-cooperative/keycloak-collective-portal)
|
||||
|
||||
> Community Keycloak SSO user management
|
||||
|
||||
This is a tiny Python app that allows you create custom web pages, outside of
|
||||
the Keycloak administration interface, which can be used to manage users in
|
||||
This is a small Python app that allows you to create custom web pages, outside
|
||||
of the Keycloak administration interface, which can be used to manage users in
|
||||
Keycloak. This is done via the REST API. It was designed with collective
|
||||
management in mind. Existing Keycloak users can authenticate with the app and
|
||||
then do things like invite others, send verification emails and so on. Anything
|
||||
that the REST API supports, this app can support. We aim to strive for the
|
||||
maximum usability which is often lacking in Enterprise Software ™ environments.
|
||||
This is the No Admins, No Masters edition of Keycloak.
|
||||
usability which is often lacking in Enterprise Software ™ environments
|
||||
(Keycloak is made within the context of RedHat / IBM). This is the No Admins,
|
||||
No Masters edition of Keycloak.
|
||||
|
||||
## Feature set
|
||||
|
||||
- **invite links** ([demo video](https://matrix.autonomic.zone/_matrix/media/r0/download/autonomic.zone/HRQwVkAwGdxIwaqFhUttbckB)):
|
||||
- Any collective member with an existing Keycloak account can log in and generate them
|
||||
- They are valid for 30 days by default (configurable via `INVITE_TIME_LIMIT`)
|
||||
- Anyone with an invite link can create an account on the Keycloak, so don't share publicly!
|
||||
- There is no access granularity on the account creation implemented yet, so the accounts are "global"
|
||||
- **New**: it is possible to only allow "admins" to log in, see [feature flags](#feature-flags)
|
||||
- Once the user fills in their name, email, password they will receive an email verification mail
|
||||
|
||||
If you want a feature implemented, please open an issue to discuss.
|
||||
|
||||
## Getting Started
|
||||
|
||||
@ -25,14 +44,16 @@ your technology stack.
|
||||
|
||||
- Ensure that your `admin-cli` client under your Client settings has the following config:
|
||||
- **Settings tab**:
|
||||
- **Access Type**: `confidential`
|
||||
- **Service Accounts Enabled**: `ON`
|
||||
- **Client authentication**: `ON`
|
||||
- **Service Accounts Roles**: `ON`
|
||||
- **Authentication flow**: Make sure "Standard flow" is checked
|
||||
- **Valid redirect URIs**: `https://{your keycloak-collective-portal domain}/auth/keycloak`
|
||||
- **Scope tab**:
|
||||
- **Full scope allowed**: `OFF`
|
||||
- **Client roles**: Under `realm-management` add `manage-users` and `view-users`
|
||||
- **Service Account Roles tab**:
|
||||
- **Client roles**: Under `realm-management` add `manage-users` and `view-users`
|
||||
- Click "To manage detail and group mappings, click on the username service-account-admin-cli", then "Role mappings", "Assign role", then change the dropdown to "Filter by clients", and add `realm-management:manage-users`, `realm-management:view-users`, `account:manage-account` and `account:view-profile`
|
||||
- Deploy using [`coop-cloud/keycloak-colective-portal`](https://git.autonomic.zone/coop-cloud/keycloak-collective-portal)
|
||||
- See the example [.env.sample](.env.sample) for the configuration available, more documentation will follow soon.
|
||||
|
||||
### From a collective member perspective
|
||||
|
||||
@ -40,11 +61,45 @@ your technology stack.
|
||||
- Log in with your usual login details
|
||||
- Follow the instructions on the web page to perform administrative actions
|
||||
|
||||
## Feature Flags
|
||||
|
||||
### Only admins can log in
|
||||
|
||||
#### Keycloak
|
||||
|
||||
- Create a new group under `Groups` called `Administrators` (case sensistive!)
|
||||
- Create a new scope under `Client scopes`
|
||||
- Name: `groups`
|
||||
- Type: `Optional`
|
||||
- Include in token scope: `yes`
|
||||
- Under the `Mappers` tab of this client scope, choose `Add mapper`
|
||||
- Mapper type/Name: `Groups Membership`
|
||||
- Token claim name: `groups`
|
||||
- Add to ID token: `yes`
|
||||
- Add to access token: `yes`
|
||||
- Add to userinfo: `yes`
|
||||
- Add this client scope to your `admin-cli` client as `Optional`
|
||||
- Add a test user to this group under `Users`
|
||||
|
||||
#### Keycloak Community Portal
|
||||
|
||||
- Set `FEATURE_FLAG_ADMINS_ONLY=True` in your `.env`
|
||||
- You may want to customise `KEYCLOAK_GROUPS_KEY` / `KEYCLOAK_ADMINS_GROUP` if
|
||||
you changed the value of `groups` / `Administrators` above
|
||||
|
||||
## Hacking
|
||||
|
||||
It's a [FastAPI](https://fastapi.tiangolo.com/) application.
|
||||
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. You'll need a
|
||||
working Keycloak install as well to fill in correct `.env` values. A more
|
||||
covenient development environment will come along shortly too.
|
||||
|
||||
```
|
||||
$ set -a && source .envrc && set +a
|
||||
$ docker run -p 6379:6379 -d redis:6-alpine
|
||||
$ cp .env.sample .env # fill with real values
|
||||
$ set -a && source .env && set +a
|
||||
$ make
|
||||
```
|
||||
|
@ -1,164 +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, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from humanize import naturaldelta
|
||||
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")
|
||||
|
||||
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",
|
||||
)
|
||||
|
||||
|
||||
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)):
|
||||
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"
|
||||
)
|
||||
|
||||
|
||||
@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 oauth.keycloak.authorize_redirect(request, redirect_uri)
|
||||
|
||||
|
||||
@app.get("/auth/keycloak")
|
||||
async def auth_keycloak(request: Request):
|
||||
try:
|
||||
token = await oauth.keycloak.authorize_access_token(request)
|
||||
except Exception as exc:
|
||||
return HTMLResponse(f"<p>{exc} (<a href='{home}'>home</a>)</p>")
|
||||
|
||||
user = await 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())})
|
||||
app.state.redis.set(user["preferred_username"], 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 =request.query_params.get("invite")
|
||||
# TODO
|
0
keycloak_collective_portal/__init__.py
Normal file
0
keycloak_collective_portal/__init__.py
Normal file
55
keycloak_collective_portal/config.py
Normal file
55
keycloak_collective_portal/config.py
Normal file
@ -0,0 +1,55 @@
|
||||
"""Application configuraiton."""
|
||||
|
||||
import logging
|
||||
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 groups")
|
||||
KEYCLOAK_GROUPS_KEY = environ.get("KEYCLOAK_GROUPS_KEY", "groups")
|
||||
KEYCLOAK_ADMINS_GROUP = environ.get("KEYCLOAK_ADMINS_GROUP", "Administrators")
|
||||
KEYCLOAK_BASE_URL = f"https://{KEYCLOAK_DOMAIN}/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")
|
||||
|
||||
# Log level
|
||||
LOG_LEVEL = environ.get("APP_LOG_LEVEL", "info")
|
||||
if LOG_LEVEL == "info":
|
||||
APP_LOG_LEVEL = logging.INFO
|
||||
elif LOG_LEVEL == "debug":
|
||||
APP_LOG_LEVEL = logging.DEBUG
|
||||
else:
|
||||
APP_LOG_LEVEL = logging.INFO
|
||||
|
||||
|
||||
def to_bool(env_var):
|
||||
"""Parse a bool from the environment."""
|
||||
return environ.get(env_var, "False").lower() in ("true", "1", "t")
|
||||
|
||||
|
||||
# Automatically log folks in or show the default log in page?
|
||||
AUTOMATICALLY_LOG_IN = to_bool("AUTOMATICALLY_LOG_IN")
|
||||
|
||||
# Feature flags
|
||||
# Only admins can log in to the interface
|
||||
FEATURE_FLAG_ADMINS_ONLY = to_bool("FEATURE_FLAG_ADMINS_ONLY")
|
51
keycloak_collective_portal/dependencies.py
Normal file
51
keycloak_collective_portal/dependencies.py
Normal file
@ -0,0 +1,51 @@
|
||||
"""Route dependencies."""
|
||||
|
||||
from datetime import datetime as dt
|
||||
from datetime import timedelta
|
||||
|
||||
from fastapi import Depends, Request
|
||||
from humanize import naturaldelta
|
||||
|
||||
|
||||
async def fresh_token(request: Request):
|
||||
"""Ensure fresh credentials for speaking to Keycloak."""
|
||||
from keycloak_collective_portal.keycloak import init_keycloak
|
||||
|
||||
request.app.state.keycloak = init_keycloak()
|
||||
|
||||
|
||||
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}",
|
||||
realm_name=KEYCLOAK_REALM,
|
||||
client_secret_key=KEYCLOAK_CLIENT_SECRET,
|
||||
verify=True,
|
||||
)
|
||||
|
||||
return client
|
82
keycloak_collective_portal/main.py
Normal file
82
keycloak_collective_portal/main.py
Normal file
@ -0,0 +1,82 @@
|
||||
"""App entrypoint."""
|
||||
|
||||
import logging
|
||||
|
||||
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_LOG_LEVEL,
|
||||
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,
|
||||
)
|
||||
|
||||
log = logging.getLogger("uvicorn")
|
||||
log.setLevel(APP_LOG_LEVEL)
|
||||
|
||||
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 startup_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():
|
||||
await 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()
|
||||
log.info("Initialised OpenID Connect client (for Keycloak logins)")
|
||||
|
||||
app.state.keycloak = init_keycloak()
|
||||
log.info("Initialised Keycloak admin client (for Keycloak REST API)")
|
||||
|
||||
app.state.templates = Jinja2Templates(directory=TEMPLATE_DIR)
|
||||
app.state.theme = APP_THEME
|
||||
app.state.log = log
|
||||
|
||||
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
|
41
keycloak_collective_portal/redis.py
Normal file
41
keycloak_collective_portal/redis.py
Normal file
@ -0,0 +1,41 @@
|
||||
"""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:
|
||||
value = await self._redis.get(key)
|
||||
if value:
|
||||
return json.loads(value)
|
||||
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"}
|
53
keycloak_collective_portal/routes/invite.py
Normal file
53
keycloak_collective_portal/routes/invite.py
Normal file
@ -0,0 +1,53 @@
|
||||
"""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())}
|
||||
request.app.state.log.info(f"Generated new invite: {new_invite}")
|
||||
|
||||
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)
|
||||
request.app.state.log.info(f"Retrieved invites: {invites}")
|
||||
|
||||
purged = [i for i in invites if i["link"] != invite_to_delete]
|
||||
request.app.state.log.info(f"Purged invites: {invites}")
|
||||
|
||||
await request.app.state.redis.set(user["preferred_username"], purged)
|
||||
|
||||
return RedirectResponse(request.url_for("home"))
|
58
keycloak_collective_portal/routes/oidc.py
Normal file
58
keycloak_collective_portal/routes/oidc.py
Normal file
@ -0,0 +1,58 @@
|
||||
"""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):
|
||||
from keycloak_collective_portal.config import AUTOMATICALLY_LOG_IN
|
||||
|
||||
if AUTOMATICALLY_LOG_IN:
|
||||
return RedirectResponse(request.url_for("login_keycloak"))
|
||||
|
||||
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"))
|
126
keycloak_collective_portal/routes/register.py
Normal file
126
keycloak_collective_portal/routes/register.py
Normal file
@ -0,0 +1,126 @@
|
||||
"""Registration routes."""
|
||||
|
||||
import json
|
||||
from datetime import datetime as dt
|
||||
from datetime import timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends, Form, Request
|
||||
from pydantic import EmailStr, errors
|
||||
|
||||
from keycloak_collective_portal.dependencies import fresh_token, 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, "invited_by": username}
|
||||
return request.app.state.templates.TemplateResponse(
|
||||
"register.html", context=context
|
||||
)
|
||||
|
||||
|
||||
@router.post("/form/keycloak/register", dependencies=[Depends(fresh_token)])
|
||||
def form_keycloak_register(
|
||||
request: Request,
|
||||
first_name: str = Form(...),
|
||||
last_name: str = Form(...),
|
||||
username: str = Form(...),
|
||||
email: str = Form(...),
|
||||
password: str = Form(...),
|
||||
password_again: str = Form(...),
|
||||
invited_by: str = Form(...),
|
||||
):
|
||||
context = {
|
||||
"request": request,
|
||||
"invited_by": invited_by,
|
||||
"first_name": first_name,
|
||||
"last_name": last_name,
|
||||
"username": username,
|
||||
"email": email,
|
||||
}
|
||||
|
||||
try:
|
||||
EmailStr().validate(email)
|
||||
except errors.EmailError:
|
||||
context["exception"] = "email is not valid?"
|
||||
return request.app.state.templates.TemplateResponse(
|
||||
"register.html", context=context
|
||||
)
|
||||
|
||||
if password != password_again:
|
||||
context["exception"] = "passwords don't match?"
|
||||
return request.app.state.templates.TemplateResponse(
|
||||
"register.html", context=context
|
||||
)
|
||||
|
||||
payload = {
|
||||
"email": email,
|
||||
"username": username,
|
||||
"enabled": True,
|
||||
"firstName": first_name,
|
||||
"lastName": last_name,
|
||||
"credentials": [
|
||||
{
|
||||
"value": password,
|
||||
"type": "password",
|
||||
}
|
||||
],
|
||||
"realmRoles": [
|
||||
"user_default",
|
||||
],
|
||||
"attributes": {"invited_by": invited_by},
|
||||
}
|
||||
|
||||
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:
|
||||
request.app.state.log.error(
|
||||
f"Keycloak user registration failed, saw: {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
|
||||
)
|
40
keycloak_collective_portal/routes/root.py
Normal file
40
keycloak_collective_portal/routes/root.py
Normal file
@ -0,0 +1,40 @@
|
||||
"""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)
|
||||
):
|
||||
from keycloak_collective_portal.config import (
|
||||
FEATURE_FLAG_ADMINS_ONLY,
|
||||
KEYCLOAK_ADMINS_GROUP,
|
||||
KEYCLOAK_GROUPS_KEY,
|
||||
)
|
||||
|
||||
context = {"request": request, "user": user, "invites": invites}
|
||||
|
||||
if FEATURE_FLAG_ADMINS_ONLY:
|
||||
context["message"] = "only admins can access this service"
|
||||
if KEYCLOAK_GROUPS_KEY not in user:
|
||||
return request.app.state.templates.TemplateResponse(
|
||||
"invalid.html", context=context
|
||||
)
|
||||
|
||||
if KEYCLOAK_ADMINS_GROUP not in user[KEYCLOAK_GROUPS_KEY]:
|
||||
return request.app.state.templates.TemplateResponse(
|
||||
"invalid.html", context=context
|
||||
)
|
||||
|
||||
return request.app.state.templates.TemplateResponse(
|
||||
"admin.html", context=context
|
||||
)
|
25
keycloak_collective_portal/static/default.css
Normal file
25
keycloak_collective_portal/static/default.css
Normal file
@ -0,0 +1,25 @@
|
||||
input {
|
||||
display: block;
|
||||
margin-top: 5px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
table, th, td {
|
||||
border: 1px solid black;
|
||||
}
|
||||
|
||||
input {
|
||||
border: 2px solid currentcolor;
|
||||
}
|
||||
|
||||
input:invalid {
|
||||
border: 2px dashed red;
|
||||
}
|
||||
|
||||
input:invalid:focus {
|
||||
background-image: linear-gradient(magenta, pink);
|
||||
}
|
29
keycloak_collective_portal/static/lumbung.css
Normal file
29
keycloak_collective_portal/static/lumbung.css
Normal file
@ -0,0 +1,29 @@
|
||||
input {
|
||||
display: block;
|
||||
margin-top: 5px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
table, th, td {
|
||||
border: 1px solid black;
|
||||
}
|
||||
|
||||
input {
|
||||
border: 2px solid currentcolor;
|
||||
}
|
||||
|
||||
input:invalid {
|
||||
border: 2px dashed red;
|
||||
}
|
||||
|
||||
input:invalid:focus {
|
||||
background-image: linear-gradient(magenta, pink);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: red;
|
||||
}
|
31
keycloak_collective_portal/templates/admin.html
Normal file
31
keycloak_collective_portal/templates/admin.html
Normal file
@ -0,0 +1,31 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<p>
|
||||
Hello, {{ user.preferred_username }} 👋
|
||||
<small>(<a href="{{ url_for('logout') }}">logout</a>)</small>
|
||||
</p>
|
||||
|
||||
{% if user.preferred_username in invites and invites[user.preferred_username]|length > 0 %}
|
||||
<table>
|
||||
<tr>
|
||||
<th>Link</th>
|
||||
<th>Validity</th>
|
||||
<th>Actions</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>
|
||||
{% endif %}
|
||||
<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 %}
|
35
keycloak_collective_portal/templates/register.html
Normal file
35
keycloak_collective_portal/templates/register.html
Normal file
@ -0,0 +1,35 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<p>
|
||||
You've been invited by {{ invited_by }} 🎉
|
||||
</p>
|
||||
|
||||
{% if exception %}
|
||||
<p class="error">Oops, something went wrong: {{ exception }} 😬</p>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="{{ url_for('form_keycloak_register') }}">
|
||||
<label for="first_name">First name:</label>
|
||||
<input type="text" name="first_name" value="{{ first_name }}" minlength="3" />
|
||||
|
||||
<label for="last_name">Last name:</label>
|
||||
<input type="text" name="last_name" value="{{ last_name }}" minlength="3"/>
|
||||
|
||||
<label for="username">Username:</label>
|
||||
<input type="text" name="username" value="{{ username }}" minlength="3"/>
|
||||
|
||||
<label for="email">Email:</label>
|
||||
<input type="text" name="email" value="{{ email }}" minlength="3"/>
|
||||
|
||||
<label for="password">Password:</label>
|
||||
<input type="password" name="password" minlength="8"/>
|
||||
|
||||
<label for="password_again">Password (just to be sure):</label>
|
||||
<input type="password" name="password_again" minlength="8"/>
|
||||
|
||||
<input type="hidden" name="invited_by" value="{{ invited_by }}"/>
|
||||
|
||||
<input type="submit" value="Register" />
|
||||
</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 %}
|
4
makefile
4
makefile
@ -5,9 +5,9 @@ run:
|
||||
@if [ ! -d ".venv" ]; then \
|
||||
python3 -m venv .venv && \
|
||||
.venv/bin/pip install -U pip setuptools wheel poetry && \
|
||||
.venv/bin/poetry install --dev; \
|
||||
.venv/bin/poetry install; \
|
||||
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
|
||||
|
228
poetry.lock
generated
228
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"
|
||||
@ -109,6 +117,14 @@ python-versions = "*"
|
||||
[package.dependencies]
|
||||
pycparser = "*"
|
||||
|
||||
[[package]]
|
||||
name = "chardet"
|
||||
version = "4.0.0"
|
||||
description = "Universal encoding detector for Python 2 and 3"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.0.1"
|
||||
@ -147,6 +163,48 @@ sdist = ["setuptools-rust (>=0.11.4)"]
|
||||
ssh = ["bcrypt (>=3.1.5)"]
|
||||
test = ["pytest (>=6.0)", "pytest-cov", "pytest-subtests", "pytest-xdist", "pretend", "iso8601", "pytz", "hypothesis (>=1.11.4,!=3.79.2)"]
|
||||
|
||||
[[package]]
|
||||
name = "dnspython"
|
||||
version = "2.1.0"
|
||||
description = "DNS toolkit"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
|
||||
[package.extras]
|
||||
dnssec = ["cryptography (>=2.6)"]
|
||||
doh = ["requests", "requests-toolbelt"]
|
||||
idna = ["idna (>=2.1)"]
|
||||
curio = ["curio (>=1.2)", "sniffio (>=1.1)"]
|
||||
trio = ["trio (>=0.14.0)", "sniffio (>=1.1)"]
|
||||
|
||||
[[package]]
|
||||
name = "ecdsa"
|
||||
version = "0.17.0"
|
||||
description = "ECDSA cryptographic signature library (pure python)"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
|
||||
|
||||
[package.dependencies]
|
||||
six = ">=1.9.0"
|
||||
|
||||
[package.extras]
|
||||
gmpy = ["gmpy"]
|
||||
gmpy2 = ["gmpy2"]
|
||||
|
||||
[[package]]
|
||||
name = "email-validator"
|
||||
version = "1.1.3"
|
||||
description = "A robust email syntax and deliverability validation library for Python 2.x/3.x."
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7"
|
||||
|
||||
[package.dependencies]
|
||||
dnspython = ">=1.15.0"
|
||||
idna = ">=2.0.0"
|
||||
|
||||
[[package]]
|
||||
name = "fastapi"
|
||||
version = "0.65.2"
|
||||
@ -252,11 +310,11 @@ tests = ["freezegun", "pytest", "pytest-cov"]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.2"
|
||||
version = "2.10"
|
||||
description = "Internationalized Domain Names in Applications (IDNA)"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.5"
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
|
||||
|
||||
[[package]]
|
||||
name = "isort"
|
||||
@ -342,6 +400,14 @@ category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
|
||||
|
||||
[[package]]
|
||||
name = "pyasn1"
|
||||
version = "0.4.8"
|
||||
description = "ASN.1 types and codecs"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
|
||||
[[package]]
|
||||
name = "pycodestyle"
|
||||
version = "2.7.0"
|
||||
@ -392,6 +458,47 @@ python-versions = "*"
|
||||
[package.extras]
|
||||
cli = ["click (>=5.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "python-jose"
|
||||
version = "3.3.0"
|
||||
description = "JOSE implementation in Python"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
|
||||
[package.dependencies]
|
||||
ecdsa = "!=0.15"
|
||||
pyasn1 = "*"
|
||||
rsa = "*"
|
||||
|
||||
[package.extras]
|
||||
cryptography = ["cryptography (>=3.4.0)"]
|
||||
pycrypto = ["pycrypto (>=2.6.0,<2.7.0)", "pyasn1"]
|
||||
pycryptodome = ["pycryptodome (>=3.3.1,<4.0.0)", "pyasn1"]
|
||||
|
||||
[[package]]
|
||||
name = "python-keycloak"
|
||||
version = "0.25.0"
|
||||
description = "python-keycloak is a Python package providing access to the Keycloak API."
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
|
||||
[package.dependencies]
|
||||
python-jose = ">=1.4.0"
|
||||
requests = ">=2.20.0"
|
||||
|
||||
[[package]]
|
||||
name = "python-multipart"
|
||||
version = "0.0.5"
|
||||
description = "A streaming multipart parser for Python"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
|
||||
[package.dependencies]
|
||||
six = ">=1.4.0"
|
||||
|
||||
[[package]]
|
||||
name = "pyyaml"
|
||||
version = "5.4.1"
|
||||
@ -408,6 +515,24 @@ category = "dev"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
version = "2.25.1"
|
||||
description = "Python HTTP for Humans."
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
|
||||
|
||||
[package.dependencies]
|
||||
certifi = ">=2017.4.17"
|
||||
chardet = ">=3.0.2,<5"
|
||||
idna = ">=2.5,<3"
|
||||
urllib3 = ">=1.21.1,<1.27"
|
||||
|
||||
[package.extras]
|
||||
security = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)"]
|
||||
socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"]
|
||||
|
||||
[[package]]
|
||||
name = "rfc3986"
|
||||
version = "1.5.0"
|
||||
@ -422,6 +547,25 @@ idna = {version = "*", optional = true, markers = "extra == \"idna2008\""}
|
||||
[package.extras]
|
||||
idna2008 = ["idna"]
|
||||
|
||||
[[package]]
|
||||
name = "rsa"
|
||||
version = "4.7.2"
|
||||
description = "Pure-Python RSA implementation"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.5, <4"
|
||||
|
||||
[package.dependencies]
|
||||
pyasn1 = ">=0.1.3"
|
||||
|
||||
[[package]]
|
||||
name = "six"
|
||||
version = "1.16.0"
|
||||
description = "Python 2 and 3 compatibility utilities"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
|
||||
|
||||
[[package]]
|
||||
name = "sniffio"
|
||||
version = "1.2.0"
|
||||
@ -457,6 +601,19 @@ category = "main"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "1.26.5"
|
||||
description = "HTTP library with thread-safe connection pooling, file post, and more."
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4"
|
||||
|
||||
[package.extras]
|
||||
brotli = ["brotlipy (>=0.6.0)"]
|
||||
secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"]
|
||||
socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "uvicorn"
|
||||
version = "0.14.0"
|
||||
@ -512,9 +669,13 @@ python-versions = ">=3.6.1"
|
||||
[metadata]
|
||||
lock-version = "1.1"
|
||||
python-versions = "^3.9"
|
||||
content-hash = "0ba44935dd5a68706610677a7e54fa3ebcd6f5a68edd05a1e1ecb8dbd9ca5947"
|
||||
content-hash = "ecf2a823da9679d30575e15c6fcb2eac6f1d9c323870831ba8c2f1fdc47e4fd1"
|
||||
|
||||
[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"},
|
||||
@ -598,6 +759,10 @@ cffi = [
|
||||
{file = "cffi-1.14.5-cp39-cp39-win_amd64.whl", hash = "sha256:f2d45f97ab6bb54753eab54fffe75aaf3de4ff2341c9daee1987ee1837636f1d"},
|
||||
{file = "cffi-1.14.5.tar.gz", hash = "sha256:fd78e5fee591709f32ef6edb9a015b4aa1a5022598e36227500c8f4e02328d9c"},
|
||||
]
|
||||
chardet = [
|
||||
{file = "chardet-4.0.0-py2.py3-none-any.whl", hash = "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5"},
|
||||
{file = "chardet-4.0.0.tar.gz", hash = "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa"},
|
||||
]
|
||||
click = [
|
||||
{file = "click-8.0.1-py3-none-any.whl", hash = "sha256:fba402a4a47334742d782209a7c79bc448911afe1149d07bdabdf480b3e2f4b6"},
|
||||
{file = "click-8.0.1.tar.gz", hash = "sha256:8c04c11192119b1ef78ea049e0a6f0463e4c48ef00a30160c704337586f3ad7a"},
|
||||
@ -620,6 +785,18 @@ cryptography = [
|
||||
{file = "cryptography-3.4.7-pp37-pypy37_pp73-manylinux2014_x86_64.whl", hash = "sha256:ee77aa129f481be46f8d92a1a7db57269a2f23052d5f2433b4621bb457081cc9"},
|
||||
{file = "cryptography-3.4.7.tar.gz", hash = "sha256:3d10de8116d25649631977cb37da6cbdd2d6fa0e0281d014a5b7d337255ca713"},
|
||||
]
|
||||
dnspython = [
|
||||
{file = "dnspython-2.1.0-py3-none-any.whl", hash = "sha256:95d12f6ef0317118d2a1a6fc49aac65ffec7eb8087474158f42f26a639135216"},
|
||||
{file = "dnspython-2.1.0.zip", hash = "sha256:e4a87f0b573201a0f3727fa18a516b055fd1107e0e5477cded4a2de497df1dd4"},
|
||||
]
|
||||
ecdsa = [
|
||||
{file = "ecdsa-0.17.0-py2.py3-none-any.whl", hash = "sha256:5cf31d5b33743abe0dfc28999036c849a69d548f994b535e527ee3cb7f3ef676"},
|
||||
{file = "ecdsa-0.17.0.tar.gz", hash = "sha256:b9f500bb439e4153d0330610f5d26baaf18d17b8ced1bc54410d189385ea68aa"},
|
||||
]
|
||||
email-validator = [
|
||||
{file = "email_validator-1.1.3-py2.py3-none-any.whl", hash = "sha256:5675c8ceb7106a37e40e2698a57c056756bf3f272cfa8682a4f87ebd95d8440b"},
|
||||
{file = "email_validator-1.1.3.tar.gz", hash = "sha256:aa237a65f6f4da067119b7df3f13e89c25c051327b2b5b66dc075f33d62480d7"},
|
||||
]
|
||||
fastapi = [
|
||||
{file = "fastapi-0.65.2-py3-none-any.whl", hash = "sha256:39569a18914075b2f1aaa03bcb9dc96a38e0e5dabaf3972e088c9077dfffa379"},
|
||||
{file = "fastapi-0.65.2.tar.gz", hash = "sha256:8359e55d8412a5571c0736013d90af235d6949ec4ce978e9b63500c8f4b6f714"},
|
||||
@ -705,8 +882,8 @@ humanize = [
|
||||
{file = "humanize-3.7.1.tar.gz", hash = "sha256:b8e7878f3063174b212bb82b9e5bee3b24bc47931e44df0bd34bcb1d8e0acf2f"},
|
||||
]
|
||||
idna = [
|
||||
{file = "idna-3.2-py3-none-any.whl", hash = "sha256:14475042e284991034cb48e06f6851428fb14c4dc953acd9be9a5e95c7b6dd7a"},
|
||||
{file = "idna-3.2.tar.gz", hash = "sha256:467fbad99067910785144ce333826c71fb0e63a425657295239737f7ecd125f3"},
|
||||
{file = "idna-2.10-py2.py3-none-any.whl", hash = "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"},
|
||||
{file = "idna-2.10.tar.gz", hash = "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6"},
|
||||
]
|
||||
isort = [
|
||||
{file = "isort-5.8.0-py3-none-any.whl", hash = "sha256:2bb1680aad211e3c9944dbce1d4ba09a989f04e238296c87fe2139faa26d655d"},
|
||||
@ -793,6 +970,21 @@ pathspec = [
|
||||
{file = "pathspec-0.8.1-py2.py3-none-any.whl", hash = "sha256:aa0cb481c4041bf52ffa7b0d8fa6cd3e88a2ca4879c533c9153882ee2556790d"},
|
||||
{file = "pathspec-0.8.1.tar.gz", hash = "sha256:86379d6b86d75816baba717e64b1a3a3469deb93bb76d613c9ce79edc5cb68fd"},
|
||||
]
|
||||
pyasn1 = [
|
||||
{file = "pyasn1-0.4.8-py2.4.egg", hash = "sha256:fec3e9d8e36808a28efb59b489e4528c10ad0f480e57dcc32b4de5c9d8c9fdf3"},
|
||||
{file = "pyasn1-0.4.8-py2.5.egg", hash = "sha256:0458773cfe65b153891ac249bcf1b5f8f320b7c2ce462151f8fa74de8934becf"},
|
||||
{file = "pyasn1-0.4.8-py2.6.egg", hash = "sha256:5c9414dcfede6e441f7e8f81b43b34e834731003427e5b09e4e00e3172a10f00"},
|
||||
{file = "pyasn1-0.4.8-py2.7.egg", hash = "sha256:6e7545f1a61025a4e58bb336952c5061697da694db1cae97b116e9c46abcf7c8"},
|
||||
{file = "pyasn1-0.4.8-py2.py3-none-any.whl", hash = "sha256:39c7e2ec30515947ff4e87fb6f456dfc6e84857d34be479c9d4a4ba4bf46aa5d"},
|
||||
{file = "pyasn1-0.4.8-py3.1.egg", hash = "sha256:78fa6da68ed2727915c4767bb386ab32cdba863caa7dbe473eaae45f9959da86"},
|
||||
{file = "pyasn1-0.4.8-py3.2.egg", hash = "sha256:08c3c53b75eaa48d71cf8c710312316392ed40899cb34710d092e96745a358b7"},
|
||||
{file = "pyasn1-0.4.8-py3.3.egg", hash = "sha256:03840c999ba71680a131cfaee6fab142e1ed9bbd9c693e285cc6aca0d555e576"},
|
||||
{file = "pyasn1-0.4.8-py3.4.egg", hash = "sha256:7ab8a544af125fb704feadb008c99a88805126fb525280b2270bb25cc1d78a12"},
|
||||
{file = "pyasn1-0.4.8-py3.5.egg", hash = "sha256:e89bf84b5437b532b0803ba5c9a5e054d21fec423a89952a74f87fa2c9b7bce2"},
|
||||
{file = "pyasn1-0.4.8-py3.6.egg", hash = "sha256:014c0e9976956a08139dc0712ae195324a75e142284d5f87f1a87ee1b068a359"},
|
||||
{file = "pyasn1-0.4.8-py3.7.egg", hash = "sha256:99fcc3c8d804d1bc6d9a099921e39d827026409a58f2a720dcdb89374ea0c776"},
|
||||
{file = "pyasn1-0.4.8.tar.gz", hash = "sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba"},
|
||||
]
|
||||
pycodestyle = [
|
||||
{file = "pycodestyle-2.7.0-py2.py3-none-any.whl", hash = "sha256:514f76d918fcc0b55c6680472f0a37970994e07bbb80725808c17089be302068"},
|
||||
{file = "pycodestyle-2.7.0.tar.gz", hash = "sha256:c389c1d06bf7904078ca03399a4816f974a1d590090fecea0c63ec26ebaf1cef"},
|
||||
@ -833,6 +1025,16 @@ python-dotenv = [
|
||||
{file = "python-dotenv-0.17.1.tar.gz", hash = "sha256:b1ae5e9643d5ed987fc57cc2583021e38db531946518130777734f9589b3141f"},
|
||||
{file = "python_dotenv-0.17.1-py2.py3-none-any.whl", hash = "sha256:00aa34e92d992e9f8383730816359647f358f4a3be1ba45e5a5cefd27ee91544"},
|
||||
]
|
||||
python-jose = [
|
||||
{file = "python-jose-3.3.0.tar.gz", hash = "sha256:55779b5e6ad599c6336191246e95eb2293a9ddebd555f796a65f838f07e5d78a"},
|
||||
{file = "python_jose-3.3.0-py2.py3-none-any.whl", hash = "sha256:9b1376b023f8b298536eedd47ae1089bcdb848f1535ab30555cd92002d78923a"},
|
||||
]
|
||||
python-keycloak = [
|
||||
{file = "python-keycloak-0.25.0.tar.gz", hash = "sha256:d02a7a4ed609583587482eacfdce409a00b633dff04ccf1cb3d478e1f0c50529"},
|
||||
]
|
||||
python-multipart = [
|
||||
{file = "python-multipart-0.0.5.tar.gz", hash = "sha256:f7bb5f611fc600d15fa47b3974c8aa16e93724513b49b5f95c81e6624c83fa43"},
|
||||
]
|
||||
pyyaml = [
|
||||
{file = "PyYAML-5.4.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:3b2b1824fe7112845700f815ff6a489360226a5609b96ec2190a45e62a9fc922"},
|
||||
{file = "PyYAML-5.4.1-cp27-cp27m-win32.whl", hash = "sha256:129def1b7c1bf22faffd67b8f3724645203b79d8f4cc81f674654d9902cb4393"},
|
||||
@ -907,10 +1109,22 @@ regex = [
|
||||
{file = "regex-2021.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:97f29f57d5b84e73fbaf99ab3e26134e6687348e95ef6b48cfd2c06807005a07"},
|
||||
{file = "regex-2021.4.4.tar.gz", hash = "sha256:52ba3d3f9b942c49d7e4bc105bb28551c44065f139a65062ab7912bef10c9afb"},
|
||||
]
|
||||
requests = [
|
||||
{file = "requests-2.25.1-py2.py3-none-any.whl", hash = "sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e"},
|
||||
{file = "requests-2.25.1.tar.gz", hash = "sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804"},
|
||||
]
|
||||
rfc3986 = [
|
||||
{file = "rfc3986-1.5.0-py2.py3-none-any.whl", hash = "sha256:a86d6e1f5b1dc238b218b012df0aa79409667bb209e58da56d0b94704e712a97"},
|
||||
{file = "rfc3986-1.5.0.tar.gz", hash = "sha256:270aaf10d87d0d4e095063c65bf3ddbc6ee3d0b226328ce21e036f946e421835"},
|
||||
]
|
||||
rsa = [
|
||||
{file = "rsa-4.7.2-py3-none-any.whl", hash = "sha256:78f9a9bf4e7be0c5ded4583326e7461e3a3c5aae24073648b4bdfa797d78c9d2"},
|
||||
{file = "rsa-4.7.2.tar.gz", hash = "sha256:9d689e6ca1b3038bc82bf8d23e944b6b6037bc02301a574935b2dd946e0353b9"},
|
||||
]
|
||||
six = [
|
||||
{file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"},
|
||||
{file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
|
||||
]
|
||||
sniffio = [
|
||||
{file = "sniffio-1.2.0-py3-none-any.whl", hash = "sha256:471b71698eac1c2112a40ce2752bb2f4a4814c22a54a3eed3676bc0f5ca9f663"},
|
||||
{file = "sniffio-1.2.0.tar.gz", hash = "sha256:c4666eecec1d3f50960c6bdf61ab7bc350648da6c126e3cf6898d8cd4ddcd3de"},
|
||||
@ -928,6 +1142,10 @@ typing-extensions = [
|
||||
{file = "typing_extensions-3.10.0.0-py3-none-any.whl", hash = "sha256:779383f6086d90c99ae41cf0ff39aac8a7937a9283ce0a414e5dd782f4c94a84"},
|
||||
{file = "typing_extensions-3.10.0.0.tar.gz", hash = "sha256:50b6f157849174217d0656f99dc82fe932884fb250826c18350e159ec6cdf342"},
|
||||
]
|
||||
urllib3 = [
|
||||
{file = "urllib3-1.26.5-py2.py3-none-any.whl", hash = "sha256:753a0374df26658f99d826cfe40394a686d05985786d946fbe4165b5148f5a7c"},
|
||||
{file = "urllib3-1.26.5.tar.gz", hash = "sha256:a7acd0977125325f516bda9735fa7142b909a8d01e8b2e4c8108d0984e6e0098"},
|
||||
]
|
||||
uvicorn = [
|
||||
{file = "uvicorn-0.14.0-py3-none-any.whl", hash = "sha256:2a76bb359171a504b3d1c853409af3adbfa5cef374a4a59e5881945a97a93eae"},
|
||||
{file = "uvicorn-0.14.0.tar.gz", hash = "sha256:45ad7dfaaa7d55cab4cd1e85e03f27e9d60bc067ddc59db52a2b0aeca8870292"},
|
||||
|
@ -15,6 +15,10 @@ Authlib = "^0.15.4"
|
||||
httpx = "^0.18.1"
|
||||
aioredis = "^1.3.1"
|
||||
humanize = "^3.7.1"
|
||||
python-multipart = "^0.0.5"
|
||||
python-keycloak = "^0.25.0"
|
||||
aiofiles = "^0.7.0"
|
||||
email-validator = "^1.1.3"
|
||||
|
||||
[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('home') }}{{ invite.link }}">{{ url_for('home') }}{{ 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>
|
Reference in New Issue
Block a user