Compare commits

...

13 Commits

Author SHA1 Message Date
a4f2149223 update docs for latest keycloak
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-21 15:47:47 +01:00
74280d6093 Merge pull request 'feat: admins only feature flag' (#10) from ff-admins-only into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #10
2023-04-18 09:00:20 +00:00
bae57efaa6 feat: admins only feature flag 2023-04-17 18:27:23 +02:00
3wc
2835682455 Fix registry URL
All checks were successful
continuous-integration/drone/push Build is passing
2023-04-07 21:04:48 -04:00
3wc
d0d0a1871d Switch to publishing on git.autonomic.zone
Some checks failed
continuous-integration/drone/push Build is failing
2023-04-07 21:03:55 -04:00
3wc
ce9b07cf50 Update docs
[ci skip]
2023-04-07 16:24:44 -04:00
3wc
98c93ac279 Drop more /auth/
All checks were successful
continuous-integration/drone/push Build is passing
2023-04-06 11:53:10 -04:00
3wc
f4ca0b3f1f Just drop /auth/..
All checks were successful
continuous-integration/drone/push Build is passing
Ruangrupa isn't using this, but if they were, they would probably also
need without-/auth/
2023-04-06 11:48:07 -04:00
3wc
99e968a587 Configurable KEYCLOAK_BASE_URL
All checks were successful
continuous-integration/drone/push Build is passing
2023-04-06 11:27:30 -04:00
3f326b74d6 feat: minlength validation
All checks were successful
continuous-integration/drone/push Build is passing
2022-01-10 10:33:07 +01:00
df35fe181a fix: show message as string 2022-01-10 10:32:54 +01:00
53a6abb7a8 feat: email validation 2022-01-10 10:27:42 +01:00
de207e3153 feat: password confirmation 2022-01-10 10:17:35 +01:00
12 changed files with 179 additions and 22 deletions

View File

@ -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
View File

@ -1,2 +1,3 @@
*.pyc *.pyc
.env
/__pycache__/ /__pycache__/

View File

@ -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

View File

@ -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")

View File

@ -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,

View File

@ -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:

View File

@ -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
) )

View File

@ -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);
}

View File

@ -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;
}

View File

@ -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
View File

@ -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"},

View File

@ -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"