mirror of
https://github.com/markbeep/AudioBookRequest.git
synced 2026-01-04 04:29:41 -06:00
logout oidc, more settings info, group claim is optional
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user