Compare commits
48 Commits
011b68c179
...
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
|
|||
480cd4a4fe
|
|||
c5c856e6e9
|
|||
96e718db3a
|
|||
1acc7705df
|
|||
30edb39163
|
|||
5942468164
|
|||
9d83b7191d
|
|||
3ad0cef90f
|
|||
f84f9958a1
|
|||
7a22c6f4e0
|
|||
0aaf99da97
|
|||
f2593e209e
|
|||
65475912c1
|
@ -5,12 +5,12 @@ steps:
|
|||||||
- name: publish container
|
- name: publish container
|
||||||
image: plugins/docker
|
image: plugins/docker
|
||||||
settings:
|
settings:
|
||||||
username:
|
username: autono-bot
|
||||||
from_secret: docker_reg_username
|
|
||||||
password:
|
password:
|
||||||
from_secret: docker_reg_passwd
|
from_secret: git_autonomic_zone_token_autono-bot
|
||||||
repo: decentral1se/keycloak-collective-portal
|
repo: git.autonomic.zone/autonomic-cooperative/keycloak-collective-portal
|
||||||
auto_tag: true
|
auto_tag: true
|
||||||
|
registry: git.autonomic.zone
|
||||||
trigger:
|
trigger:
|
||||||
branch:
|
branch:
|
||||||
- main
|
- 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
|
*.pyc
|
||||||
|
.env
|
||||||
/__pycache__/
|
/__pycache__/
|
||||||
|
@ -1,9 +1,10 @@
|
|||||||
FROM python:3.9 as builder
|
FROM python:3.9 AS builder
|
||||||
|
|
||||||
COPY . /project
|
COPY . /project
|
||||||
WORKDIR /project
|
WORKDIR /project
|
||||||
|
|
||||||
RUN pip install poetry
|
RUN pip install poetry
|
||||||
|
RUN poetry self add poetry-plugin-export
|
||||||
RUN poetry export --without-hashes -o requirements.txt -f requirements.txt
|
RUN poetry export --without-hashes -o requirements.txt -f requirements.txt
|
||||||
RUN poetry build --format=wheel
|
RUN poetry build --format=wheel
|
||||||
RUN cp dist/* /tmp
|
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
|
COPY . /srv/project
|
||||||
WORKDIR /srv/project
|
WORKDIR /srv/project
|
||||||
|
|
||||||
CMD ["uvicorn", "--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
|
||||||
|
74
README.md
74
README.md
@ -1,17 +1,36 @@
|
|||||||
# keycloak-collective-portal
|
# 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)
|
[](https://drone.autonomic.zone/autonomic-cooperative/keycloak-collective-portal)
|
||||||
|
|
||||||
> Community Keycloak SSO user management
|
> Community Keycloak SSO user management
|
||||||
|
|
||||||
This is a tiny Python app that allows you create custom web pages, outside of
|
This is a small Python app that allows you to create custom web pages, outside
|
||||||
the Keycloak administration interface, which can be used to manage users in
|
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
|
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
|
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
|
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
|
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.
|
usability which is often lacking in Enterprise Software ™ environments
|
||||||
This is the No Admins, No Masters edition of Keycloak.
|
(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
|
## Getting Started
|
||||||
|
|
||||||
@ -25,14 +44,16 @@ your technology stack.
|
|||||||
|
|
||||||
- Ensure that your `admin-cli` client under your Client settings has the following config:
|
- Ensure that your `admin-cli` client under your Client settings has the following config:
|
||||||
- **Settings tab**:
|
- **Settings tab**:
|
||||||
- **Access Type**: `confidential`
|
- **Client authentication**: `ON`
|
||||||
- **Service Accounts Enabled**: `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**:
|
- **Scope tab**:
|
||||||
- **Full scope allowed**: `OFF`
|
- **Full scope allowed**: `OFF`
|
||||||
- **Client roles**: Under `realm-management` add `manage-users` and `view-users`
|
|
||||||
- **Service Account Roles tab**:
|
- **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)
|
- 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
|
### From a collective member perspective
|
||||||
|
|
||||||
@ -40,10 +61,45 @@ your technology stack.
|
|||||||
- Log in with your usual login details
|
- Log in with your usual login details
|
||||||
- Follow the instructions on the web page to perform administrative actions
|
- 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
|
## 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.
|
||||||
|
|
||||||
```
|
```
|
||||||
|
$ docker run -p 6379:6379 -d redis:6-alpine
|
||||||
|
$ cp .env.sample .env # fill with real values
|
||||||
|
$ set -a && source .env && set +a
|
||||||
$ make
|
$ make
|
||||||
```
|
```
|
||||||
|
@ -1,62 +0,0 @@
|
|||||||
"""Community Keycloak SSO user management."""
|
|
||||||
|
|
||||||
from os import environ
|
|
||||||
|
|
||||||
from authlib.integrations.starlette_client import OAuth, OAuthError
|
|
||||||
from fastapi import FastAPI, Request
|
|
||||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
|
||||||
from fastapi.templating import Jinja2Templates
|
|
||||||
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")
|
|
||||||
|
|
||||||
app = FastAPI()
|
|
||||||
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 offline_access"},
|
|
||||||
client_id=KEYCLOAK_CLIENT_ID,
|
|
||||||
client_secret=KEYCLOAK_CLIENT_SECRET,
|
|
||||||
authorize_url=f"https://{KEYCLOAK_DOMAIN}/auth/realms/{KEYCLOAK_REALM}/protocol/openid-connect/auth",
|
|
||||||
access_token_url=f"https://{KEYCLOAK_DOMAIN}/auth/realms/{KEYCLOAK_REALM}/protocol/openid-connect/token",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/", response_class=HTMLResponse)
|
|
||||||
async def home(request: Request):
|
|
||||||
user = request.session.get("user")
|
|
||||||
if user:
|
|
||||||
return templates.TemplateResponse(
|
|
||||||
"index.html", context={"request": request, "user": user}
|
|
||||||
)
|
|
||||||
return RedirectResponse(request.url_for("login_keycloak"))
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/login/keycloak")
|
|
||||||
async def login_keycloak(request: Request):
|
|
||||||
redirect_uri = request.url_for("auth")
|
|
||||||
return await oauth.keycloak.authorize_redirect(request, redirect_uri)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/auth")
|
|
||||||
async def auth(request: Request):
|
|
||||||
try:
|
|
||||||
token = await oauth.keycloak.authorize_access_token(request)
|
|
||||||
user = await oauth.keycloak.parse_id_token(request, token)
|
|
||||||
request.session["user"] = dict(user)
|
|
||||||
return RedirectResponse(request.url_for("home"))
|
|
||||||
except Exception as exception:
|
|
||||||
return HTMLResponse(f"<h1>{str(exception)}</h1>")
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/logout")
|
|
||||||
async def logout(request: Request):
|
|
||||||
request.session.pop("user", None)
|
|
||||||
return RedirectResponse(request.url_for("home"))
|
|
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 %}
|
9
makefile
9
makefile
@ -1,10 +1,13 @@
|
|||||||
.DEFAULT: run
|
.DEFAULT: run
|
||||||
.PHONY: run
|
.PHONY: run redis
|
||||||
|
|
||||||
run:
|
run:
|
||||||
@if [ ! -d ".venv" ]; then \
|
@if [ ! -d ".venv" ]; then \
|
||||||
python3 -m venv .venv && \
|
python3 -m venv .venv && \
|
||||||
.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; \
|
||||||
fi
|
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
|
||||||
|
322
poetry.lock
generated
322
poetry.lock
generated
@ -1,3 +1,23 @@
|
|||||||
|
[[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"
|
||||||
|
description = "asyncio (PEP 3156) Redis support"
|
||||||
|
category = "main"
|
||||||
|
optional = false
|
||||||
|
python-versions = "*"
|
||||||
|
|
||||||
|
[package.dependencies]
|
||||||
|
async-timeout = "*"
|
||||||
|
hiredis = "*"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anyio"
|
name = "anyio"
|
||||||
version = "3.1.0"
|
version = "3.1.0"
|
||||||
@ -34,6 +54,14 @@ python-versions = ">=3.6"
|
|||||||
[package.extras]
|
[package.extras]
|
||||||
tests = ["pytest", "pytest-asyncio", "mypy (>=0.800)"]
|
tests = ["pytest", "pytest-asyncio", "mypy (>=0.800)"]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-timeout"
|
||||||
|
version = "3.0.1"
|
||||||
|
description = "Timeout context manager for asyncio programs"
|
||||||
|
category = "main"
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.5.3"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "authlib"
|
name = "authlib"
|
||||||
version = "0.15.4"
|
version = "0.15.4"
|
||||||
@ -89,6 +117,14 @@ python-versions = "*"
|
|||||||
[package.dependencies]
|
[package.dependencies]
|
||||||
pycparser = "*"
|
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]]
|
[[package]]
|
||||||
name = "click"
|
name = "click"
|
||||||
version = "8.0.1"
|
version = "8.0.1"
|
||||||
@ -127,6 +163,48 @@ sdist = ["setuptools-rust (>=0.11.4)"]
|
|||||||
ssh = ["bcrypt (>=3.1.5)"]
|
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)"]
|
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]]
|
[[package]]
|
||||||
name = "fastapi"
|
name = "fastapi"
|
||||||
version = "0.65.2"
|
version = "0.65.2"
|
||||||
@ -166,6 +244,14 @@ category = "main"
|
|||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.6"
|
python-versions = ">=3.6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hiredis"
|
||||||
|
version = "2.0.0"
|
||||||
|
description = "Python wrapper for hiredis"
|
||||||
|
category = "main"
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.6"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "httpcore"
|
name = "httpcore"
|
||||||
version = "0.13.4"
|
version = "0.13.4"
|
||||||
@ -211,13 +297,24 @@ sniffio = "*"
|
|||||||
brotli = ["brotlicffi (>=1.0.0,<2.0.0)"]
|
brotli = ["brotlicffi (>=1.0.0,<2.0.0)"]
|
||||||
http2 = ["h2 (>=3.0.0,<4.0.0)"]
|
http2 = ["h2 (>=3.0.0,<4.0.0)"]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "humanize"
|
||||||
|
version = "3.7.1"
|
||||||
|
description = "Python humanize utilities"
|
||||||
|
category = "main"
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.6"
|
||||||
|
|
||||||
|
[package.extras]
|
||||||
|
tests = ["freezegun", "pytest", "pytest-cov"]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "idna"
|
name = "idna"
|
||||||
version = "3.2"
|
version = "2.10"
|
||||||
description = "Internationalized Domain Names in Applications (IDNA)"
|
description = "Internationalized Domain Names in Applications (IDNA)"
|
||||||
category = "main"
|
category = "main"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.5"
|
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "isort"
|
name = "isort"
|
||||||
@ -303,6 +400,14 @@ category = "dev"
|
|||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
|
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]]
|
[[package]]
|
||||||
name = "pycodestyle"
|
name = "pycodestyle"
|
||||||
version = "2.7.0"
|
version = "2.7.0"
|
||||||
@ -353,6 +458,47 @@ python-versions = "*"
|
|||||||
[package.extras]
|
[package.extras]
|
||||||
cli = ["click (>=5.0)"]
|
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]]
|
[[package]]
|
||||||
name = "pyyaml"
|
name = "pyyaml"
|
||||||
version = "5.4.1"
|
version = "5.4.1"
|
||||||
@ -369,6 +515,24 @@ category = "dev"
|
|||||||
optional = false
|
optional = false
|
||||||
python-versions = "*"
|
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]]
|
[[package]]
|
||||||
name = "rfc3986"
|
name = "rfc3986"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
@ -383,6 +547,25 @@ idna = {version = "*", optional = true, markers = "extra == \"idna2008\""}
|
|||||||
[package.extras]
|
[package.extras]
|
||||||
idna2008 = ["idna"]
|
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]]
|
[[package]]
|
||||||
name = "sniffio"
|
name = "sniffio"
|
||||||
version = "1.2.0"
|
version = "1.2.0"
|
||||||
@ -418,6 +601,19 @@ category = "main"
|
|||||||
optional = false
|
optional = false
|
||||||
python-versions = "*"
|
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]]
|
[[package]]
|
||||||
name = "uvicorn"
|
name = "uvicorn"
|
||||||
version = "0.14.0"
|
version = "0.14.0"
|
||||||
@ -473,9 +669,17 @@ 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 = "5c484b3f866449256a1928794c2787de8672804952c7887f059cb944beecdaf1"
|
content-hash = "ecf2a823da9679d30575e15c6fcb2eac6f1d9c323870831ba8c2f1fdc47e4fd1"
|
||||||
|
|
||||||
[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 = [
|
||||||
|
{file = "aioredis-1.3.1-py3-none-any.whl", hash = "sha256:b61808d7e97b7cd5a92ed574937a079c9387fdadd22bfbfa7ad2fd319ecc26e3"},
|
||||||
|
{file = "aioredis-1.3.1.tar.gz", hash = "sha256:15f8af30b044c771aee6787e5ec24694c048184c7b9e54c3b60c750a4b93273a"},
|
||||||
|
]
|
||||||
anyio = [
|
anyio = [
|
||||||
{file = "anyio-3.1.0-py3-none-any.whl", hash = "sha256:5e335cef65fbd1a422bbfbb4722e8e9a9fadbd8c06d5afe9cd614d12023f6e5a"},
|
{file = "anyio-3.1.0-py3-none-any.whl", hash = "sha256:5e335cef65fbd1a422bbfbb4722e8e9a9fadbd8c06d5afe9cd614d12023f6e5a"},
|
||||||
{file = "anyio-3.1.0.tar.gz", hash = "sha256:43e20711a9d003d858d694c12356dc44ab82c03ccc5290313c3392fa349dad0e"},
|
{file = "anyio-3.1.0.tar.gz", hash = "sha256:43e20711a9d003d858d694c12356dc44ab82c03ccc5290313c3392fa349dad0e"},
|
||||||
@ -488,6 +692,10 @@ asgiref = [
|
|||||||
{file = "asgiref-3.3.4-py3-none-any.whl", hash = "sha256:92906c611ce6c967347bbfea733f13d6313901d54dcca88195eaeb52b2a8e8ee"},
|
{file = "asgiref-3.3.4-py3-none-any.whl", hash = "sha256:92906c611ce6c967347bbfea733f13d6313901d54dcca88195eaeb52b2a8e8ee"},
|
||||||
{file = "asgiref-3.3.4.tar.gz", hash = "sha256:d1216dfbdfb63826470995d31caed36225dcaf34f182e0fa257a4dd9e86f1b78"},
|
{file = "asgiref-3.3.4.tar.gz", hash = "sha256:d1216dfbdfb63826470995d31caed36225dcaf34f182e0fa257a4dd9e86f1b78"},
|
||||||
]
|
]
|
||||||
|
async-timeout = [
|
||||||
|
{file = "async-timeout-3.0.1.tar.gz", hash = "sha256:0c3c816a028d47f659d6ff5c745cb2acf1f966da1fe5c19c77a70282b25f4c5f"},
|
||||||
|
{file = "async_timeout-3.0.1-py3-none-any.whl", hash = "sha256:4291ca197d287d274d0b6cb5d6f8f8f82d434ed288f962539ff18cc9012f9ea3"},
|
||||||
|
]
|
||||||
authlib = [
|
authlib = [
|
||||||
{file = "Authlib-0.15.4-py2.py3-none-any.whl", hash = "sha256:d9fe5edb59801b16583faa86f88d798d99d952979b9616d5c735b9170b41ae2c"},
|
{file = "Authlib-0.15.4-py2.py3-none-any.whl", hash = "sha256:d9fe5edb59801b16583faa86f88d798d99d952979b9616d5c735b9170b41ae2c"},
|
||||||
{file = "Authlib-0.15.4.tar.gz", hash = "sha256:37df3a2554bc6fe0da3cc6848c44fac2ae40634a7f8fc72543947f4330b26464"},
|
{file = "Authlib-0.15.4.tar.gz", hash = "sha256:37df3a2554bc6fe0da3cc6848c44fac2ae40634a7f8fc72543947f4330b26464"},
|
||||||
@ -551,6 +759,10 @@ cffi = [
|
|||||||
{file = "cffi-1.14.5-cp39-cp39-win_amd64.whl", hash = "sha256:f2d45f97ab6bb54753eab54fffe75aaf3de4ff2341c9daee1987ee1837636f1d"},
|
{file = "cffi-1.14.5-cp39-cp39-win_amd64.whl", hash = "sha256:f2d45f97ab6bb54753eab54fffe75aaf3de4ff2341c9daee1987ee1837636f1d"},
|
||||||
{file = "cffi-1.14.5.tar.gz", hash = "sha256:fd78e5fee591709f32ef6edb9a015b4aa1a5022598e36227500c8f4e02328d9c"},
|
{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 = [
|
click = [
|
||||||
{file = "click-8.0.1-py3-none-any.whl", hash = "sha256:fba402a4a47334742d782209a7c79bc448911afe1149d07bdabdf480b3e2f4b6"},
|
{file = "click-8.0.1-py3-none-any.whl", hash = "sha256:fba402a4a47334742d782209a7c79bc448911afe1149d07bdabdf480b3e2f4b6"},
|
||||||
{file = "click-8.0.1.tar.gz", hash = "sha256:8c04c11192119b1ef78ea049e0a6f0463e4c48ef00a30160c704337586f3ad7a"},
|
{file = "click-8.0.1.tar.gz", hash = "sha256:8c04c11192119b1ef78ea049e0a6f0463e4c48ef00a30160c704337586f3ad7a"},
|
||||||
@ -573,6 +785,18 @@ cryptography = [
|
|||||||
{file = "cryptography-3.4.7-pp37-pypy37_pp73-manylinux2014_x86_64.whl", hash = "sha256:ee77aa129f481be46f8d92a1a7db57269a2f23052d5f2433b4621bb457081cc9"},
|
{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"},
|
{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 = [
|
fastapi = [
|
||||||
{file = "fastapi-0.65.2-py3-none-any.whl", hash = "sha256:39569a18914075b2f1aaa03bcb9dc96a38e0e5dabaf3972e088c9077dfffa379"},
|
{file = "fastapi-0.65.2-py3-none-any.whl", hash = "sha256:39569a18914075b2f1aaa03bcb9dc96a38e0e5dabaf3972e088c9077dfffa379"},
|
||||||
{file = "fastapi-0.65.2.tar.gz", hash = "sha256:8359e55d8412a5571c0736013d90af235d6949ec4ce978e9b63500c8f4b6f714"},
|
{file = "fastapi-0.65.2.tar.gz", hash = "sha256:8359e55d8412a5571c0736013d90af235d6949ec4ce978e9b63500c8f4b6f714"},
|
||||||
@ -585,6 +809,49 @@ h11 = [
|
|||||||
{file = "h11-0.12.0-py3-none-any.whl", hash = "sha256:36a3cb8c0a032f56e2da7084577878a035d3b61d104230d4bd49c0c6b555a9c6"},
|
{file = "h11-0.12.0-py3-none-any.whl", hash = "sha256:36a3cb8c0a032f56e2da7084577878a035d3b61d104230d4bd49c0c6b555a9c6"},
|
||||||
{file = "h11-0.12.0.tar.gz", hash = "sha256:47222cb6067e4a307d535814917cd98fd0a57b6788ce715755fa2b6c28b56042"},
|
{file = "h11-0.12.0.tar.gz", hash = "sha256:47222cb6067e4a307d535814917cd98fd0a57b6788ce715755fa2b6c28b56042"},
|
||||||
]
|
]
|
||||||
|
hiredis = [
|
||||||
|
{file = "hiredis-2.0.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:b4c8b0bc5841e578d5fb32a16e0c305359b987b850a06964bd5a62739d688048"},
|
||||||
|
{file = "hiredis-2.0.0-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:0adea425b764a08270820531ec2218d0508f8ae15a448568109ffcae050fee26"},
|
||||||
|
{file = "hiredis-2.0.0-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:3d55e36715ff06cdc0ab62f9591607c4324297b6b6ce5b58cb9928b3defe30ea"},
|
||||||
|
{file = "hiredis-2.0.0-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:5d2a48c80cf5a338d58aae3c16872f4d452345e18350143b3bf7216d33ba7b99"},
|
||||||
|
{file = "hiredis-2.0.0-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:240ce6dc19835971f38caf94b5738092cb1e641f8150a9ef9251b7825506cb05"},
|
||||||
|
{file = "hiredis-2.0.0-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:5dc7a94bb11096bc4bffd41a3c4f2b958257085c01522aa81140c68b8bf1630a"},
|
||||||
|
{file = "hiredis-2.0.0-cp36-cp36m-win32.whl", hash = "sha256:139705ce59d94eef2ceae9fd2ad58710b02aee91e7fa0ccb485665ca0ecbec63"},
|
||||||
|
{file = "hiredis-2.0.0-cp36-cp36m-win_amd64.whl", hash = "sha256:c39c46d9e44447181cd502a35aad2bb178dbf1b1f86cf4db639d7b9614f837c6"},
|
||||||
|
{file = "hiredis-2.0.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:adf4dd19d8875ac147bf926c727215a0faf21490b22c053db464e0bf0deb0485"},
|
||||||
|
{file = "hiredis-2.0.0-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:0f41827028901814c709e744060843c77e78a3aca1e0d6875d2562372fcb405a"},
|
||||||
|
{file = "hiredis-2.0.0-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:508999bec4422e646b05c95c598b64bdbef1edf0d2b715450a078ba21b385bcc"},
|
||||||
|
{file = "hiredis-2.0.0-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:0d5109337e1db373a892fdcf78eb145ffb6bbd66bb51989ec36117b9f7f9b579"},
|
||||||
|
{file = "hiredis-2.0.0-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:04026461eae67fdefa1949b7332e488224eac9e8f2b5c58c98b54d29af22093e"},
|
||||||
|
{file = "hiredis-2.0.0-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:a00514362df15af041cc06e97aebabf2895e0a7c42c83c21894be12b84402d79"},
|
||||||
|
{file = "hiredis-2.0.0-cp37-cp37m-win32.whl", hash = "sha256:09004096e953d7ebd508cded79f6b21e05dff5d7361771f59269425108e703bc"},
|
||||||
|
{file = "hiredis-2.0.0-cp37-cp37m-win_amd64.whl", hash = "sha256:f8196f739092a78e4f6b1b2172679ed3343c39c61a3e9d722ce6fcf1dac2824a"},
|
||||||
|
{file = "hiredis-2.0.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:294a6697dfa41a8cba4c365dd3715abc54d29a86a40ec6405d677ca853307cfb"},
|
||||||
|
{file = "hiredis-2.0.0-cp38-cp38-manylinux1_i686.whl", hash = "sha256:3dddf681284fe16d047d3ad37415b2e9ccdc6c8986c8062dbe51ab9a358b50a5"},
|
||||||
|
{file = "hiredis-2.0.0-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:dcef843f8de4e2ff5e35e96ec2a4abbdf403bd0f732ead127bd27e51f38ac298"},
|
||||||
|
{file = "hiredis-2.0.0-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:87c7c10d186f1743a8fd6a971ab6525d60abd5d5d200f31e073cd5e94d7e7a9d"},
|
||||||
|
{file = "hiredis-2.0.0-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:7f0055f1809b911ab347a25d786deff5e10e9cf083c3c3fd2dd04e8612e8d9db"},
|
||||||
|
{file = "hiredis-2.0.0-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:11d119507bb54e81f375e638225a2c057dda748f2b1deef05c2b1a5d42686048"},
|
||||||
|
{file = "hiredis-2.0.0-cp38-cp38-win32.whl", hash = "sha256:7492af15f71f75ee93d2a618ca53fea8be85e7b625e323315169977fae752426"},
|
||||||
|
{file = "hiredis-2.0.0-cp38-cp38-win_amd64.whl", hash = "sha256:65d653df249a2f95673976e4e9dd7ce10de61cfc6e64fa7eeaa6891a9559c581"},
|
||||||
|
{file = "hiredis-2.0.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ae8427a5e9062ba66fc2c62fb19a72276cf12c780e8db2b0956ea909c48acff5"},
|
||||||
|
{file = "hiredis-2.0.0-cp39-cp39-manylinux1_i686.whl", hash = "sha256:3f5f7e3a4ab824e3de1e1700f05ad76ee465f5f11f5db61c4b297ec29e692b2e"},
|
||||||
|
{file = "hiredis-2.0.0-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:e3447d9e074abf0e3cd85aef8131e01ab93f9f0e86654db7ac8a3f73c63706ce"},
|
||||||
|
{file = "hiredis-2.0.0-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:8b42c0dc927b8d7c0eb59f97e6e34408e53bc489f9f90e66e568f329bff3e443"},
|
||||||
|
{file = "hiredis-2.0.0-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:b84f29971f0ad4adaee391c6364e6f780d5aae7e9226d41964b26b49376071d0"},
|
||||||
|
{file = "hiredis-2.0.0-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:0b39ec237459922c6544d071cdcf92cbb5bc6685a30e7c6d985d8a3e3a75326e"},
|
||||||
|
{file = "hiredis-2.0.0-cp39-cp39-win32.whl", hash = "sha256:a7928283143a401e72a4fad43ecc85b35c27ae699cf5d54d39e1e72d97460e1d"},
|
||||||
|
{file = "hiredis-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:a4ee8000454ad4486fb9f28b0cab7fa1cd796fc36d639882d0b34109b5b3aec9"},
|
||||||
|
{file = "hiredis-2.0.0-pp36-pypy36_pp73-macosx_10_9_x86_64.whl", hash = "sha256:1f03d4dadd595f7a69a75709bc81902673fa31964c75f93af74feac2f134cc54"},
|
||||||
|
{file = "hiredis-2.0.0-pp36-pypy36_pp73-manylinux1_x86_64.whl", hash = "sha256:04927a4c651a0e9ec11c68e4427d917e44ff101f761cd3b5bc76f86aaa431d27"},
|
||||||
|
{file = "hiredis-2.0.0-pp36-pypy36_pp73-manylinux2010_x86_64.whl", hash = "sha256:a39efc3ade8c1fb27c097fd112baf09d7fd70b8cb10ef1de4da6efbe066d381d"},
|
||||||
|
{file = "hiredis-2.0.0-pp36-pypy36_pp73-win32.whl", hash = "sha256:07bbf9bdcb82239f319b1f09e8ef4bdfaec50ed7d7ea51a56438f39193271163"},
|
||||||
|
{file = "hiredis-2.0.0-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:807b3096205c7cec861c8803a6738e33ed86c9aae76cac0e19454245a6bbbc0a"},
|
||||||
|
{file = "hiredis-2.0.0-pp37-pypy37_pp73-manylinux1_x86_64.whl", hash = "sha256:1233e303645f468e399ec906b6b48ab7cd8391aae2d08daadbb5cad6ace4bd87"},
|
||||||
|
{file = "hiredis-2.0.0-pp37-pypy37_pp73-manylinux2010_x86_64.whl", hash = "sha256:cb2126603091902767d96bcb74093bd8b14982f41809f85c9b96e519c7e1dc41"},
|
||||||
|
{file = "hiredis-2.0.0-pp37-pypy37_pp73-win32.whl", hash = "sha256:f52010e0a44e3d8530437e7da38d11fb822acfb0d5b12e9cd5ba655509937ca0"},
|
||||||
|
{file = "hiredis-2.0.0.tar.gz", hash = "sha256:81d6d8e39695f2c37954d1011c0480ef7cf444d4e3ae24bc5e89ee5de360139a"},
|
||||||
|
]
|
||||||
httpcore = [
|
httpcore = [
|
||||||
{file = "httpcore-0.13.4-py3-none-any.whl", hash = "sha256:38e09649bb3906c913a2917c4eb3e3b3e11c83d4edebad8b53b7d757abc49267"},
|
{file = "httpcore-0.13.4-py3-none-any.whl", hash = "sha256:38e09649bb3906c913a2917c4eb3e3b3e11c83d4edebad8b53b7d757abc49267"},
|
||||||
{file = "httpcore-0.13.4.tar.gz", hash = "sha256:9fa4c623bb9d2280c009c34658cc6315e4fd425a395145645bee205d827263e4"},
|
{file = "httpcore-0.13.4.tar.gz", hash = "sha256:9fa4c623bb9d2280c009c34658cc6315e4fd425a395145645bee205d827263e4"},
|
||||||
@ -610,9 +877,13 @@ httpx = [
|
|||||||
{file = "httpx-0.18.1-py3-none-any.whl", hash = "sha256:ad2e3db847be736edc4b272c4d5788790a7e5789ef132fc6b5fef8aeb9e9f6e0"},
|
{file = "httpx-0.18.1-py3-none-any.whl", hash = "sha256:ad2e3db847be736edc4b272c4d5788790a7e5789ef132fc6b5fef8aeb9e9f6e0"},
|
||||||
{file = "httpx-0.18.1.tar.gz", hash = "sha256:0a2651dd2b9d7662c70d12ada5c290abcf57373b9633515fe4baa9f62566086f"},
|
{file = "httpx-0.18.1.tar.gz", hash = "sha256:0a2651dd2b9d7662c70d12ada5c290abcf57373b9633515fe4baa9f62566086f"},
|
||||||
]
|
]
|
||||||
|
humanize = [
|
||||||
|
{file = "humanize-3.7.1-py3-none-any.whl", hash = "sha256:a0dca9eb010dd1fab61819acaea54be344a4c22c77261f72ac4dbee183dd9a59"},
|
||||||
|
{file = "humanize-3.7.1.tar.gz", hash = "sha256:b8e7878f3063174b212bb82b9e5bee3b24bc47931e44df0bd34bcb1d8e0acf2f"},
|
||||||
|
]
|
||||||
idna = [
|
idna = [
|
||||||
{file = "idna-3.2-py3-none-any.whl", hash = "sha256:14475042e284991034cb48e06f6851428fb14c4dc953acd9be9a5e95c7b6dd7a"},
|
{file = "idna-2.10-py2.py3-none-any.whl", hash = "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"},
|
||||||
{file = "idna-3.2.tar.gz", hash = "sha256:467fbad99067910785144ce333826c71fb0e63a425657295239737f7ecd125f3"},
|
{file = "idna-2.10.tar.gz", hash = "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6"},
|
||||||
]
|
]
|
||||||
isort = [
|
isort = [
|
||||||
{file = "isort-5.8.0-py3-none-any.whl", hash = "sha256:2bb1680aad211e3c9944dbce1d4ba09a989f04e238296c87fe2139faa26d655d"},
|
{file = "isort-5.8.0-py3-none-any.whl", hash = "sha256:2bb1680aad211e3c9944dbce1d4ba09a989f04e238296c87fe2139faa26d655d"},
|
||||||
@ -699,6 +970,21 @@ pathspec = [
|
|||||||
{file = "pathspec-0.8.1-py2.py3-none-any.whl", hash = "sha256:aa0cb481c4041bf52ffa7b0d8fa6cd3e88a2ca4879c533c9153882ee2556790d"},
|
{file = "pathspec-0.8.1-py2.py3-none-any.whl", hash = "sha256:aa0cb481c4041bf52ffa7b0d8fa6cd3e88a2ca4879c533c9153882ee2556790d"},
|
||||||
{file = "pathspec-0.8.1.tar.gz", hash = "sha256:86379d6b86d75816baba717e64b1a3a3469deb93bb76d613c9ce79edc5cb68fd"},
|
{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 = [
|
pycodestyle = [
|
||||||
{file = "pycodestyle-2.7.0-py2.py3-none-any.whl", hash = "sha256:514f76d918fcc0b55c6680472f0a37970994e07bbb80725808c17089be302068"},
|
{file = "pycodestyle-2.7.0-py2.py3-none-any.whl", hash = "sha256:514f76d918fcc0b55c6680472f0a37970994e07bbb80725808c17089be302068"},
|
||||||
{file = "pycodestyle-2.7.0.tar.gz", hash = "sha256:c389c1d06bf7904078ca03399a4816f974a1d590090fecea0c63ec26ebaf1cef"},
|
{file = "pycodestyle-2.7.0.tar.gz", hash = "sha256:c389c1d06bf7904078ca03399a4816f974a1d590090fecea0c63ec26ebaf1cef"},
|
||||||
@ -739,6 +1025,16 @@ python-dotenv = [
|
|||||||
{file = "python-dotenv-0.17.1.tar.gz", hash = "sha256:b1ae5e9643d5ed987fc57cc2583021e38db531946518130777734f9589b3141f"},
|
{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"},
|
{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 = [
|
pyyaml = [
|
||||||
{file = "PyYAML-5.4.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:3b2b1824fe7112845700f815ff6a489360226a5609b96ec2190a45e62a9fc922"},
|
{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"},
|
{file = "PyYAML-5.4.1-cp27-cp27m-win32.whl", hash = "sha256:129def1b7c1bf22faffd67b8f3724645203b79d8f4cc81f674654d9902cb4393"},
|
||||||
@ -813,10 +1109,22 @@ regex = [
|
|||||||
{file = "regex-2021.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:97f29f57d5b84e73fbaf99ab3e26134e6687348e95ef6b48cfd2c06807005a07"},
|
{file = "regex-2021.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:97f29f57d5b84e73fbaf99ab3e26134e6687348e95ef6b48cfd2c06807005a07"},
|
||||||
{file = "regex-2021.4.4.tar.gz", hash = "sha256:52ba3d3f9b942c49d7e4bc105bb28551c44065f139a65062ab7912bef10c9afb"},
|
{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 = [
|
rfc3986 = [
|
||||||
{file = "rfc3986-1.5.0-py2.py3-none-any.whl", hash = "sha256:a86d6e1f5b1dc238b218b012df0aa79409667bb209e58da56d0b94704e712a97"},
|
{file = "rfc3986-1.5.0-py2.py3-none-any.whl", hash = "sha256:a86d6e1f5b1dc238b218b012df0aa79409667bb209e58da56d0b94704e712a97"},
|
||||||
{file = "rfc3986-1.5.0.tar.gz", hash = "sha256:270aaf10d87d0d4e095063c65bf3ddbc6ee3d0b226328ce21e036f946e421835"},
|
{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 = [
|
sniffio = [
|
||||||
{file = "sniffio-1.2.0-py3-none-any.whl", hash = "sha256:471b71698eac1c2112a40ce2752bb2f4a4814c22a54a3eed3676bc0f5ca9f663"},
|
{file = "sniffio-1.2.0-py3-none-any.whl", hash = "sha256:471b71698eac1c2112a40ce2752bb2f4a4814c22a54a3eed3676bc0f5ca9f663"},
|
||||||
{file = "sniffio-1.2.0.tar.gz", hash = "sha256:c4666eecec1d3f50960c6bdf61ab7bc350648da6c126e3cf6898d8cd4ddcd3de"},
|
{file = "sniffio-1.2.0.tar.gz", hash = "sha256:c4666eecec1d3f50960c6bdf61ab7bc350648da6c126e3cf6898d8cd4ddcd3de"},
|
||||||
@ -834,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-py3-none-any.whl", hash = "sha256:779383f6086d90c99ae41cf0ff39aac8a7937a9283ce0a414e5dd782f4c94a84"},
|
||||||
{file = "typing_extensions-3.10.0.0.tar.gz", hash = "sha256:50b6f157849174217d0656f99dc82fe932884fb250826c18350e159ec6cdf342"},
|
{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 = [
|
uvicorn = [
|
||||||
{file = "uvicorn-0.14.0-py3-none-any.whl", hash = "sha256:2a76bb359171a504b3d1c853409af3adbfa5cef374a4a59e5881945a97a93eae"},
|
{file = "uvicorn-0.14.0-py3-none-any.whl", hash = "sha256:2a76bb359171a504b3d1c853409af3adbfa5cef374a4a59e5881945a97a93eae"},
|
||||||
{file = "uvicorn-0.14.0.tar.gz", hash = "sha256:45ad7dfaaa7d55cab4cd1e85e03f27e9d60bc067ddc59db52a2b0aeca8870292"},
|
{file = "uvicorn-0.14.0.tar.gz", hash = "sha256:45ad7dfaaa7d55cab4cd1e85e03f27e9d60bc067ddc59db52a2b0aeca8870292"},
|
||||||
|
@ -13,6 +13,12 @@ Jinja2 = "^3.0.1"
|
|||||||
itsdangerous = "^2.0.1"
|
itsdangerous = "^2.0.1"
|
||||||
Authlib = "^0.15.4"
|
Authlib = "^0.15.4"
|
||||||
httpx = "^0.18.1"
|
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]
|
[tool.poetry.dev-dependencies]
|
||||||
black = "^21.6b0"
|
black = "^21.6b0"
|
||||||
|
@ -1 +0,0 @@
|
|||||||
// TODO
|
|
@ -1,8 +0,0 @@
|
|||||||
<html>
|
|
||||||
<head>
|
|
||||||
<title>Home</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<p>Hello, {{ user }}</p>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
@ -1,8 +0,0 @@
|
|||||||
<html>
|
|
||||||
<head>
|
|
||||||
<title>Login</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<p>Please login</p>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
Reference in New Issue
Block a user