logout oidc, more settings info, group claim is optional

This commit is contained in:
Markbeep
2025-03-14 22:01:48 +01:00
parent 64f0b5e937
commit 77dbb1c96c
8 changed files with 131 additions and 37 deletions

View File

@@ -77,7 +77,7 @@ OIDC allows you to use an external authentication service (Authentik, Keycloak,
- client id
- client secret
In your auth server settings, make sure you allow for redirecting to `/auth/oidc`. The oidc-login flow will redirect you there after you log in.
In your auth server settings, make sure you allow for redirecting to `/auth/oidc`. The oidc-login flow will redirect you there after you log in. Additionally, the access token expiry time from the authentication server will be used if provided. This might be fairly low by default.
Applying settings does not directly invalidate your current session. To test OIDC-settings, press the "log out" button to invalidate your current session.

View File

@@ -1,3 +1,4 @@
import time
from typing import Annotated, Optional
from argon2 import PasswordHasher
@@ -91,10 +92,12 @@ class ABRAuth:
) -> DetailedUser:
login_type = auth_config.get_login_type(session)
if login_type == LoginTypeEnum.forms or login_type == LoginTypeEnum.oidc:
if login_type == LoginTypeEnum.forms:
user = await self._get_session_auth(request, session)
elif login_type == LoginTypeEnum.none:
user = await self._get_none_auth(session)
elif login_type == LoginTypeEnum.oidc:
user = await self._get_oidc_auth(request, session)
else:
user = await self._get_basic_auth(request, session)
@@ -146,6 +149,16 @@ class ABRAuth:
return user
async def _get_oidc_auth(
self,
request: Request,
session: Session,
) -> User:
if exp := request.session.get("exp"):
if exp < time.time():
raise RequiresLoginException()
return await self._get_session_auth(request, session)
async def _get_none_auth(self, session: Session) -> User:
"""Treats every request as being root by returning the first admin user"""
if self.none_user:

View File

@@ -16,6 +16,7 @@ oidcConfigKey = Literal[
"oidc_token_endpoint",
"oidc_userinfo_endpoint",
"oidc_authorize_endpoint",
"oidc_end_session_endpoint",
]
@@ -41,6 +42,9 @@ class oidcConfig(StringConfigCache[oidcConfigKey]):
)
self.set(session, "oidc_token_endpoint", data["token_endpoint"])
self.set(session, "oidc_userinfo_endpoint", data["userinfo_endpoint"])
self.set(
session, "oidc_end_session_endpoint", data["end_session_endpoint"]
)
async def validate(
self, session: Session, client_session: ClientSession
@@ -72,7 +76,7 @@ class oidcConfig(StringConfigCache[oidcConfigKey]):
return "Username claim is not supported by the provider"
group_claim = self.get(session, "oidc_group_claim")
if not group_claim or group_claim not in provider_claims:
if group_claim and group_claim not in provider_claims:
return "Group claim is not supported by the provider"

View File

@@ -25,6 +25,12 @@ class User(BaseModel, table=True):
sa_column_kwargs={"server_default": "untrusted"},
)
root: bool = False
# TODO: Add last_login
# last_login: datetime = Field(
# default_factory=datetime.now, sa_column_kwargs={"server_default": "now()"}
# )
"""
untrusted: Requests need to be manually reviewed
trusted: Requests are automatically downloaded if possible

View File

@@ -1,5 +1,6 @@
import base64
import secrets
import time
from typing import Annotated, Optional
from urllib.parse import urlencode
@@ -79,10 +80,21 @@ async def login(
@router.post("/logout")
def logout(
request: Request, user: Annotated[DetailedUser, Depends(get_authenticated_user())]
async def logout(
request: Request,
user: Annotated[DetailedUser, Depends(get_authenticated_user())],
session: Annotated[Session, Depends(get_session)],
):
request.session["sub"] = ""
login_type = auth_config.get_login_type(session)
if login_type == LoginTypeEnum.oidc:
end_session_endpoint = oidc_config.get(session, "oidc_end_session_endpoint")
if end_session_endpoint:
return Response(
status_code=status.HTTP_204_NO_CONTENT,
headers={"HX-Redirect": end_session_endpoint},
)
return Response(
status_code=status.HTTP_204_NO_CONTENT, headers={"HX-Redirect": "/login"}
)
@@ -143,8 +155,6 @@ async def login_oidc(
raise InvalidOIDCConfiguration("Missing OIDC client secret")
if not username_claim:
raise InvalidOIDCConfiguration("Missing OIDC username claim")
if not group_claim:
raise InvalidOIDCConfiguration("Missing OIDC group claim")
base_url = str(request.base_url).rstrip("/")
@@ -162,7 +172,7 @@ async def login_oidc(
) as response:
body = await response.json()
access_token = body.get("access_token")
access_token: Optional[str] = body.get("access_token")
if not access_token:
return Response(status_code=status.HTTP_401_UNAUTHORIZED)
@@ -176,9 +186,12 @@ async def login_oidc(
if not username:
raise InvalidOIDCConfiguration("Missing username claim")
groups: list[str] | str = userinfo.get(group_claim, [])
if isinstance(groups, str):
groups = groups.split(" ")
if group_claim:
groups: list[str] | str = userinfo.get(group_claim, [])
if isinstance(groups, str):
groups = groups.split(" ")
else:
groups = []
user = session.exec(select(User).where(User.username == username)).first()
if not user:
@@ -190,22 +203,28 @@ async def login_oidc(
# Don't overwrite the group if the user is root admin
if not user.root:
ensure_group = GroupEnum.untrusted
for group in groups:
if group.lower() == "admin":
ensure_group = GroupEnum.admin
user.group = GroupEnum.admin
break
elif group.lower() == "trusted":
ensure_group = GroupEnum.trusted
user.group = GroupEnum.trusted
break
elif group.lower() == "untrusted":
ensure_group = GroupEnum.untrusted
user.group = GroupEnum.untrusted
break
user.group = ensure_group
session.add(user)
session.commit()
session.add(user)
session.commit()
expires_in: int = body.get(
"expires_in",
auth_config.get_access_token_expiry_minutes(session) * 60,
)
expires = int(time.time() + expires_in)
request.session["sub"] = username
request.session["exp"] = expires
# We can't redirect server side, because that results in an infinite loop.
# The session token is never correctly set causing any other endpoint to

View File

@@ -92,11 +92,12 @@ def read_users(
session: Annotated[Session, Depends(get_session)],
):
users = session.exec(select(User)).all()
is_oidc = auth_config.get_login_type(session) == LoginTypeEnum.oidc
return template_response(
"settings_page/users.html",
request,
admin_user,
{"page": "users", "users": users},
{"page": "users", "users": users, "is_oidc": is_oidc},
)
@@ -721,7 +722,8 @@ async def update_security(
oidc_config.set(session, "oidc_scope", oidc_scope)
if oidc_username_claim:
oidc_config.set(session, "oidc_username_claim", oidc_username_claim)
if oidc_group_claim:
if oidc_group_claim is not None:
oidc_config.set(session, "oidc_group_claim", oidc_group_claim)
error_message = await oidc_config.validate(session, client_session)

View File

@@ -51,7 +51,7 @@
</option>
</select>
<template x-if="loginType === 'forms' || loginType === 'oidc'">
<template x-if="loginType === 'forms'">
<div class="contents">
<label for="expiry-input">Access Token Expiry (minutes)</label>
<input
@@ -80,7 +80,9 @@
<template x-if="loginType === 'oidc'">
<div class="contents">
<label for="oidc-client-id">OIDC Client ID</label>
<label for="oidc-client-id"
>OIDC Client ID <span class="text-error">*</span></label
>
<input
id="oidc-client-id"
required
@@ -91,7 +93,9 @@
value="{{ oidc_client_id}}"
/>
<label for="oidc-client-secret">OIDC Client Secret</label>
<label for="oidc-client-secret"
>OIDC Client Secret <span class="text-error">*</span></label
>
<input
id="oidc-client-secret"
required
@@ -102,12 +106,18 @@
value="{{ oidc_client_secret }}"
/>
<label for="oidc-endpoint">OIDC Configuration Endpoint</label>
<p class="opacity-60">
This has to be the
<span class="font-mono">.well-known/openid-configuration</span>
endpoint
</p>
<div>
<label for="oidc-endpoint"
>OIDC Configuration Endpoint
<span class="text-error">*</span></label
>
<p class="opacity-60 text-xs">
The
<span class="font-mono">.well-known/openid-configuration</span>
endpoint containing all the OIDC information. You should be able to
visit the page and view it yourself.
</p>
</div>
<input
id="oidc-endpoint"
required
@@ -118,7 +128,16 @@
value="{{ oidc_endpoint }}"
/>
<label for="oidc-scope">OIDC Scopes</label>
<div>
<label for="oidc-scope"
>OIDC Scopes <span class="text-error">*</span></label
>
<p class="opacity-60 text-xs">
The scopes that will be requested from the OIDC provider. "openid"
is almost always required. Add the scopes required to fetch the
username and group claims.
</p>
</div>
<input
id="oidc-scope"
required
@@ -130,7 +149,18 @@
value="{{ oidc_scope }}"
/>
<label for="oidc-username-claim">OIDC Username Claim</label>
<div>
<label for="oidc-username-claim"
>OIDC Username Claim <span class="text-error">*</span></label
>
<p class="opacity-60 text-xs">
The claim that will be used for the username. Make sure the
respective scope is passed along above. For example some services
expect the "email" claim to be able to use the email. "sub" is
always avaiable. You can head to the OIDC endpoint to see what
claims are avaiable.
</p>
</div>
<input
id="oidc-username-claim"
required
@@ -142,10 +172,17 @@
value="{{ oidc_username_claim }}"
/>
<label for="oidc-username-claim">OIDC Group Claim</label>
<div>
<label for="oidc-username-claim">OIDC Group Claim</label>
<p class="opacity-60 text-xs">
The claim that contains the group(s) the user is in. For example, if
a user is in the group "trusted" they will be assigned the Trusted
role here. The group claim can be a list of groups or a single one
and is case-insensitive.
</p>
</div>
<input
id="oidc-group-claim"
required
type="text"
autocomplete="off"
placeholder="group"
@@ -154,7 +191,7 @@
value="{{ oidc_group_claim }}"
/>
<p class="text-error">
<p class="text-error text-xs">
Make sure all the settings are correct. In the case of a
miconfiguration, you can log in at
<a
@@ -164,8 +201,8 @@
>
to fix the settings.
<br />
Note: To test your OpenID Connect settings you have to log out to
invalidate your current session first.
<span class="font-semibold">Note:</span> To test your OpenID Connect
settings you have to log out to invalidate your current session first.
</p>
</div>
</template>

View File

@@ -2,6 +2,13 @@
<title>Settings - Users</title>
{% include 'scripts/toast.html' %}
{% endblock %} {% block content %}
{% if is_oidc %}
<p class="text-error font-semibold">
OpenID Connect is enabled. Users will be automatically created when they log in.
If a user has a group assigned on the authentication server, it will override their group here.
</p>
{% endif %}
<form
id="create-user-form"
class="flex flex-col gap-2"
@@ -45,6 +52,12 @@
<div id="user-list" class="pt-4 border-t border-base-200">
<h2 class="text-lg">Users</h2>
<div class="flex flex-col opacity-60 text-sm">
<span class="font-bold">Untrusted:</span> <span>Can search and request files.</span>
<span class="font-bold">Trusted:</span> <span>Same as untrused, but if auto-download is enabled the user can start downloads.</span>
<span class="font-bold">Admin:</span> <span>Can manage users and settings.</span>
</div>
{% block toast_block %}
<div class="hidden" id="toast-block">
{% if error %}
@@ -94,7 +107,7 @@
{% if u.root %}<option value="admin" selected>Root Admin</option>{% endif %}
</select>
</td>
<td {% if u.root %}title="Root user" {% endif %}>
<td {% if u.root %}title="Can't delete the root admin"{% elif u.is_self(user.username) %}title="Can't delete yourself"{% endif %}>
<!--prettier-ignore -->
<button
class="btn btn-square btn-ghost"