Compare commits
13 Commits
e50ffa50ee
...
main
Author | SHA1 | Date | |
---|---|---|---|
a4f2149223 | |||
74280d6093 | |||
bae57efaa6 | |||
2835682455 | |||
d0d0a1871d | |||
ce9b07cf50 | |||
98c93ac279 | |||
f4ca0b3f1f | |||
99e968a587 | |||
3f326b74d6 | |||
df35fe181a | |||
53a6abb7a8 | |||
de207e3153 |
@ -5,12 +5,12 @@ steps:
|
|||||||
- name: publish container
|
- name: publish container
|
||||||
image: plugins/docker
|
image: plugins/docker
|
||||||
settings:
|
settings:
|
||||||
username:
|
username: 3wordchant
|
||||||
from_secret: docker_reg_username
|
|
||||||
password:
|
password:
|
||||||
from_secret: docker_reg_passwd
|
from_secret: git_autonomic_zone_token_3wc
|
||||||
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
|
||||||
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,2 +1,3 @@
|
|||||||
*.pyc
|
*.pyc
|
||||||
|
.env
|
||||||
/__pycache__/
|
/__pycache__/
|
||||||
|
36
README.md
36
README.md
@ -27,6 +27,7 @@ No Masters edition of Keycloak.
|
|||||||
- They are valid for 30 days by default (configurable via `INVITE_TIME_LIMIT`)
|
- 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!
|
- 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"
|
- 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
|
- 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.
|
If you want a feature implemented, please open an issue to discuss.
|
||||||
@ -43,13 +44,14 @@ 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.
|
- See the example [.env.sample](.env.sample) for the configuration available, more documentation will follow soon.
|
||||||
|
|
||||||
@ -59,6 +61,32 @@ 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 (if you know
|
It's a [FastAPI](https://fastapi.tiangolo.com/) application (if you know
|
||||||
|
@ -12,8 +12,10 @@ KEYCLOAK_CLIENT_ID = environ.get("KEYCLOAK_CLIENT_ID")
|
|||||||
KEYCLOAK_CLIENT_SECRET = environ.get("KEYCLOAK_CLIENT_SECRET")
|
KEYCLOAK_CLIENT_SECRET = environ.get("KEYCLOAK_CLIENT_SECRET")
|
||||||
KEYCLOAK_DOMAIN = environ.get("KEYCLOAK_DOMAIN")
|
KEYCLOAK_DOMAIN = environ.get("KEYCLOAK_DOMAIN")
|
||||||
KEYCLOAK_REALM = environ.get("KEYCLOAK_REALM")
|
KEYCLOAK_REALM = environ.get("KEYCLOAK_REALM")
|
||||||
KEYCLOAK_SCOPES = environ.get("KEYCLOAK_SCOPES", "openid profile email")
|
KEYCLOAK_SCOPES = environ.get("KEYCLOAK_SCOPES", "openid profile email groups")
|
||||||
KEYCLOAK_BASE_URL = f"https://{KEYCLOAK_DOMAIN}/auth/realms/{KEYCLOAK_REALM}/protocol/openid-connect" # noqa
|
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 connection details, our main storage
|
||||||
REDIS_DB = environ.get("REDIS_DB")
|
REDIS_DB = environ.get("REDIS_DB")
|
||||||
@ -39,5 +41,15 @@ elif LOG_LEVEL == "debug":
|
|||||||
else:
|
else:
|
||||||
APP_LOG_LEVEL = logging.INFO
|
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 folks in or show the default log in page?
|
||||||
AUTOMATICALLY_LOG_IN = environ.get("AUTOMATICALLY_LOG_IN", False)
|
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")
|
||||||
|
@ -12,7 +12,7 @@ def init_keycloak():
|
|||||||
)
|
)
|
||||||
|
|
||||||
client = KeycloakAdmin(
|
client = KeycloakAdmin(
|
||||||
server_url=f"https://{KEYCLOAK_DOMAIN}/auth/",
|
server_url=f"https://{KEYCLOAK_DOMAIN}",
|
||||||
realm_name=KEYCLOAK_REALM,
|
realm_name=KEYCLOAK_REALM,
|
||||||
client_secret_key=KEYCLOAK_CLIENT_SECRET,
|
client_secret_key=KEYCLOAK_CLIENT_SECRET,
|
||||||
verify=True,
|
verify=True,
|
||||||
|
@ -5,6 +5,7 @@ from datetime import datetime as dt
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Form, Request
|
from fastapi import APIRouter, Depends, Form, Request
|
||||||
|
from pydantic import EmailStr, errors
|
||||||
|
|
||||||
from keycloak_collective_portal.dependencies import fresh_token, get_invites
|
from keycloak_collective_portal.dependencies import fresh_token, get_invites
|
||||||
|
|
||||||
@ -44,7 +45,7 @@ async def register_invite(
|
|||||||
"invalid.html", context=context
|
"invalid.html", context=context
|
||||||
)
|
)
|
||||||
|
|
||||||
context = {"request": request, "username": username}
|
context = {"request": request, "invited_by": username}
|
||||||
return request.app.state.templates.TemplateResponse(
|
return request.app.state.templates.TemplateResponse(
|
||||||
"register.html", context=context
|
"register.html", context=context
|
||||||
)
|
)
|
||||||
@ -58,8 +59,31 @@ def form_keycloak_register(
|
|||||||
username: str = Form(...),
|
username: str = Form(...),
|
||||||
email: str = Form(...),
|
email: str = Form(...),
|
||||||
password: str = Form(...),
|
password: str = Form(...),
|
||||||
|
password_again: str = Form(...),
|
||||||
invited_by: 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 = {
|
payload = {
|
||||||
"email": email,
|
"email": email,
|
||||||
@ -76,7 +100,7 @@ def form_keycloak_register(
|
|||||||
"realmRoles": [
|
"realmRoles": [
|
||||||
"user_default",
|
"user_default",
|
||||||
],
|
],
|
||||||
"attributes": {"invited_by": username},
|
"attributes": {"invited_by": invited_by},
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
@ -15,7 +15,26 @@ router = APIRouter()
|
|||||||
async def home(
|
async def home(
|
||||||
request: Request, user=Depends(get_user), invites=Depends(get_invites)
|
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}
|
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(
|
return request.app.state.templates.TemplateResponse(
|
||||||
"admin.html", context=context
|
"admin.html", context=context
|
||||||
)
|
)
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
input {
|
input {
|
||||||
display: block;
|
display: block;
|
||||||
|
margin-top: 5px;
|
||||||
margin-bottom: 15px;
|
margin-bottom: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -10,3 +11,15 @@ th, td {
|
|||||||
table, th, td {
|
table, th, td {
|
||||||
border: 1px solid black;
|
border: 1px solid black;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
border: 2px solid currentcolor;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:invalid {
|
||||||
|
border: 2px dashed red;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:invalid:focus {
|
||||||
|
background-image: linear-gradient(magenta, pink);
|
||||||
|
}
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
input {
|
input {
|
||||||
display: block;
|
display: block;
|
||||||
|
margin-top: 5px;
|
||||||
margin-bottom: 15px;
|
margin-bottom: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -10,3 +11,19 @@ th, td {
|
|||||||
table, th, td {
|
table, th, td {
|
||||||
border: 1px solid black;
|
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;
|
||||||
|
}
|
||||||
|
@ -1,26 +1,33 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<p>
|
<p>
|
||||||
You've been invited by {{ username }} 🎉
|
You've been invited by {{ invited_by }} 🎉
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{% if exception %}
|
||||||
|
<p class="error">Oops, something went wrong: {{ exception }} 😬</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<form method="post" action="{{ url_for('form_keycloak_register') }}">
|
<form method="post" action="{{ url_for('form_keycloak_register') }}">
|
||||||
<label for="first_name">First name:</label>
|
<label for="first_name">First name:</label>
|
||||||
<input type="text" name="first_name" />
|
<input type="text" name="first_name" value="{{ first_name }}" minlength="3" />
|
||||||
|
|
||||||
<label for="last_name">Last name:</label>
|
<label for="last_name">Last name:</label>
|
||||||
<input type="text" name="last_name" />
|
<input type="text" name="last_name" value="{{ last_name }}" minlength="3"/>
|
||||||
|
|
||||||
<label for="username">Username:</label>
|
<label for="username">Username:</label>
|
||||||
<input type="text" name="username" />
|
<input type="text" name="username" value="{{ username }}" minlength="3"/>
|
||||||
|
|
||||||
<label for="email">Email:</label>
|
<label for="email">Email:</label>
|
||||||
<input type="text" name="email" />
|
<input type="text" name="email" value="{{ email }}" minlength="3"/>
|
||||||
|
|
||||||
<label for="password">Password:</label>
|
<label for="password">Password:</label>
|
||||||
<input type="password" name="password" />
|
<input type="password" name="password" minlength="8"/>
|
||||||
|
|
||||||
<input type="hidden" name="invited_by" value="{{ username }}"/>
|
<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" />
|
<input type="submit" value="Register" />
|
||||||
</form>
|
</form>
|
||||||
|
37
poetry.lock
generated
37
poetry.lock
generated
@ -163,6 +163,21 @@ 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]]
|
[[package]]
|
||||||
name = "ecdsa"
|
name = "ecdsa"
|
||||||
version = "0.17.0"
|
version = "0.17.0"
|
||||||
@ -178,6 +193,18 @@ six = ">=1.9.0"
|
|||||||
gmpy = ["gmpy"]
|
gmpy = ["gmpy"]
|
||||||
gmpy2 = ["gmpy2"]
|
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"
|
||||||
@ -642,7 +669,7 @@ 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 = "d8f978355587c9f76a7888c64b7d1409de886670a4b6a51cdfc0eedbd6ba3009"
|
content-hash = "ecf2a823da9679d30575e15c6fcb2eac6f1d9c323870831ba8c2f1fdc47e4fd1"
|
||||||
|
|
||||||
[metadata.files]
|
[metadata.files]
|
||||||
aiofiles = [
|
aiofiles = [
|
||||||
@ -758,10 +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 = [
|
ecdsa = [
|
||||||
{file = "ecdsa-0.17.0-py2.py3-none-any.whl", hash = "sha256:5cf31d5b33743abe0dfc28999036c849a69d548f994b535e527ee3cb7f3ef676"},
|
{file = "ecdsa-0.17.0-py2.py3-none-any.whl", hash = "sha256:5cf31d5b33743abe0dfc28999036c849a69d548f994b535e527ee3cb7f3ef676"},
|
||||||
{file = "ecdsa-0.17.0.tar.gz", hash = "sha256:b9f500bb439e4153d0330610f5d26baaf18d17b8ced1bc54410d189385ea68aa"},
|
{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"},
|
||||||
|
@ -18,6 +18,7 @@ humanize = "^3.7.1"
|
|||||||
python-multipart = "^0.0.5"
|
python-multipart = "^0.0.5"
|
||||||
python-keycloak = "^0.25.0"
|
python-keycloak = "^0.25.0"
|
||||||
aiofiles = "^0.7.0"
|
aiofiles = "^0.7.0"
|
||||||
|
email-validator = "^1.1.3"
|
||||||
|
|
||||||
[tool.poetry.dev-dependencies]
|
[tool.poetry.dev-dependencies]
|
||||||
black = "^21.6b0"
|
black = "^21.6b0"
|
||||||
|
Reference in New Issue
Block a user