Add OIDC SSO login via mozilla-django-oidc
Adds native OpenID Connect login alongside the existing LDAP and forward-auth modes. Two new TA_LOGIN_AUTH_MODE values: oidc (SSO only) and oidc_local (SSO + local break-glass), mirroring ldap/ldap_local. TAOIDCBackend maps OIDC claims onto the Account model (USERNAME_FIELD is name) and promotes staff/superuser from a configurable group claim, like the LDAP backend. The redirect_uri is anchored to TA_HOST so it stays correct behind nginx and a TLS-terminating proxy; PKCE and audience verification are on. The login page gains a "Log in with SSO" button driven by a public /api/user/oidc/ endpoint, hidden in oidc-only mode. API token auth is unaffected. Configured entirely via TA_OIDC_* env vars. Tested against Authentik. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5477ca97f1
commit
c2594b89b9
|
|
@ -103,6 +103,14 @@ class Command(BaseCommand):
|
|||
"TA_LDAP_USER_BASE",
|
||||
"TA_LDAP_USER_FILTER",
|
||||
]
|
||||
oidc_required_env = [
|
||||
"TA_OIDC_CLIENT_ID",
|
||||
"TA_OIDC_CLIENT_SECRET",
|
||||
"TA_OIDC_AUTHORIZATION_ENDPOINT",
|
||||
"TA_OIDC_TOKEN_ENDPOINT",
|
||||
"TA_OIDC_USER_ENDPOINT",
|
||||
"TA_OIDC_JWKS_ENDPOINT",
|
||||
]
|
||||
_login_auth_mode = (
|
||||
os.environ.get("TA_LOGIN_AUTH_MODE") or "single"
|
||||
).casefold()
|
||||
|
|
@ -127,6 +135,14 @@ class Command(BaseCommand):
|
|||
UNEXPECTED_ENV_VARS["TA_ENABLE_AUTH_PROXY"] = (
|
||||
"TA_ENABLE_AUTH_PROXY is not valid with current auth mode"
|
||||
)
|
||||
elif _login_auth_mode in ("oidc", "oidc_local"):
|
||||
EXPECTED_ENV_VARS.extend(oidc_required_env)
|
||||
UNEXPECTED_ENV_VARS["TA_LDAP"] = (
|
||||
"TA_LDAP is not valid with current auth mode"
|
||||
)
|
||||
UNEXPECTED_ENV_VARS["TA_ENABLE_AUTH_PROXY"] = (
|
||||
"TA_ENABLE_AUTH_PROXY is not valid with current auth mode"
|
||||
)
|
||||
else:
|
||||
if bool(os.environ.get("TA_LDAP")):
|
||||
EXPECTED_ENV_VARS.extend(ldap_required_env)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,69 @@
|
|||
"""OIDC settings for mozilla-django-oidc
|
||||
|
||||
Imported into config.settings when TA_LOGIN_AUTH_MODE is `oidc` or
|
||||
`oidc_local`. All values are driven from TA_OIDC_* environment variables so no
|
||||
provider details are baked into the image.
|
||||
"""
|
||||
|
||||
from os import environ
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
def _public_url():
|
||||
"""first TA_HOST entry as a scheme://host[:port] base for redirect_uri"""
|
||||
hosts = (environ.get("TA_HOST") or "").split()
|
||||
if not hosts:
|
||||
return ""
|
||||
|
||||
host = hosts[0].strip()
|
||||
if not host.startswith("http"):
|
||||
host = f"http://{host}"
|
||||
|
||||
parsed = urlparse(host)
|
||||
netloc = parsed.netloc or parsed.path
|
||||
return f"{parsed.scheme}://{netloc}".rstrip("/")
|
||||
|
||||
|
||||
def _env_bool(name, default):
|
||||
"""parse a boolean-ish env var, falling back to default when unset"""
|
||||
value = environ.get(name)
|
||||
if value is None:
|
||||
return default
|
||||
|
||||
return value.strip().lower() in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
# Relying party (this app) credentials
|
||||
OIDC_RP_CLIENT_ID = environ.get("TA_OIDC_CLIENT_ID")
|
||||
OIDC_RP_CLIENT_SECRET = environ.get("TA_OIDC_CLIENT_SECRET")
|
||||
|
||||
# OpenID provider endpoints (Authentik etc.)
|
||||
OIDC_OP_AUTHORIZATION_ENDPOINT = environ.get("TA_OIDC_AUTHORIZATION_ENDPOINT")
|
||||
OIDC_OP_TOKEN_ENDPOINT = environ.get("TA_OIDC_TOKEN_ENDPOINT")
|
||||
OIDC_OP_USER_ENDPOINT = environ.get("TA_OIDC_USER_ENDPOINT")
|
||||
OIDC_OP_JWKS_ENDPOINT = environ.get("TA_OIDC_JWKS_ENDPOINT")
|
||||
|
||||
OIDC_RP_SIGN_ALGO = environ.get("TA_OIDC_SIGN_ALGO") or "RS256"
|
||||
OIDC_RP_SCOPES = environ.get("TA_OIDC_SCOPES") or "openid profile email groups"
|
||||
|
||||
# auto-provision unknown users on first login (mozilla honours this)
|
||||
OIDC_CREATE_USER = _env_bool("TA_OIDC_CREATE_USER", True)
|
||||
# PKCE hardens the auth code in transit; TA sits behind proxies
|
||||
OIDC_USE_PKCE = True
|
||||
OIDC_PKCE_CODE_CHALLENGE_METHOD = "S256"
|
||||
|
||||
# where mozilla-django-oidc sends the browser after login
|
||||
LOGIN_REDIRECT_URL = "/"
|
||||
LOGOUT_REDIRECT_URL = "/login/"
|
||||
|
||||
# public base URL used to build the OIDC redirect_uri (see oidc_views)
|
||||
TA_OIDC_PUBLIC_URL = _public_url()
|
||||
|
||||
# read by user.src.oidc_auth and the public OIDC info endpoint
|
||||
TA_OIDC_USERNAME_CLAIM = (
|
||||
environ.get("TA_OIDC_USERNAME_CLAIM") or "preferred_username"
|
||||
)
|
||||
TA_OIDC_GROUPS_CLAIM = environ.get("TA_OIDC_GROUPS_CLAIM") or "groups"
|
||||
TA_OIDC_ADMIN_GROUP = environ.get("TA_OIDC_ADMIN_GROUP") or ""
|
||||
TA_OIDC_STAFF_GROUP = environ.get("TA_OIDC_STAFF_GROUP") or ""
|
||||
TA_OIDC_BUTTON_LABEL = environ.get("TA_OIDC_BUTTON_LABEL") or "Log in with SSO"
|
||||
|
|
@ -159,6 +159,19 @@ elif _login_auth_mode == "ldap_local":
|
|||
"django.contrib.auth.backends.ModelBackend",
|
||||
)
|
||||
from .ldap_settings import * # noqa: F403 F401
|
||||
elif _login_auth_mode == "oidc":
|
||||
from .oidc_settings import * # noqa: F403 F401
|
||||
|
||||
AUTHENTICATION_BACKENDS = ("user.src.oidc_auth.TAOIDCBackend",)
|
||||
INSTALLED_APPS.append("mozilla_django_oidc")
|
||||
elif _login_auth_mode == "oidc_local":
|
||||
from .oidc_settings import * # noqa: F403 F401
|
||||
|
||||
AUTHENTICATION_BACKENDS = (
|
||||
"user.src.oidc_auth.TAOIDCBackend",
|
||||
"django.contrib.auth.backends.ModelBackend",
|
||||
)
|
||||
INSTALLED_APPS.append("mozilla_django_oidc")
|
||||
else:
|
||||
# If none of these cases match, AUTHENTICATION_BACKENDS is unset, which
|
||||
# means the ModelBackend should be used by default
|
||||
|
|
@ -173,6 +186,10 @@ else:
|
|||
)
|
||||
MIDDLEWARE.append("user.src.remote_user_auth.HttpRemoteUserMiddleware")
|
||||
|
||||
# auth capabilities exposed to the login UI via /api/user/oidc/
|
||||
TA_SSO_ENABLED = _login_auth_mode in ("oidc", "oidc_local")
|
||||
TA_LOCAL_LOGIN_ENABLED = _login_auth_mode != "oidc"
|
||||
|
||||
# Internationalization
|
||||
# https://docs.djangoproject.com/en/3.2/topics/i18n/
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ Including another URLconf
|
|||
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
||||
"""
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib import admin
|
||||
from django.urls import include, path
|
||||
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView
|
||||
|
|
@ -36,3 +37,7 @@ urlpatterns = [
|
|||
),
|
||||
path("admin/", admin.site.urls),
|
||||
]
|
||||
|
||||
# OIDC login/callback routes, only when an SSO auth mode is active
|
||||
if "mozilla_django_oidc" in settings.INSTALLED_APPS:
|
||||
urlpatterns.append(path("api/oidc/", include("user.src.oidc_urls")))
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ django-cors-headers==4.9.0
|
|||
Django==6.0.6
|
||||
djangorestframework==3.17.1
|
||||
drf-spectacular==0.28.0 # rc:ignore
|
||||
mozilla-django-oidc==5.0.2
|
||||
Pillow==12.2.0
|
||||
redis==7.4.0
|
||||
requests==2.34.2
|
||||
|
|
|
|||
|
|
@ -57,3 +57,12 @@ class LoginSerializer(serializers.Serializer):
|
|||
username = serializers.CharField()
|
||||
password = serializers.CharField()
|
||||
remember_me = serializers.ChoiceField(choices=["on", "off"], default="off")
|
||||
|
||||
|
||||
class OidcInfoSerializer(serializers.Serializer):
|
||||
"""serialize public SSO login capability for the login page"""
|
||||
|
||||
enabled = serializers.BooleanField()
|
||||
local_login = serializers.BooleanField()
|
||||
label = serializers.CharField()
|
||||
login_url = serializers.CharField()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,98 @@
|
|||
"""OIDC authentication backend
|
||||
|
||||
Resolves OpenID Connect logins against the custom Account model
|
||||
(USERNAME_FIELD = "name"), which the stock mozilla-django-oidc backend cannot
|
||||
do on its own. Claim-to-value logic lives in user.src.oidc_claims so it stays
|
||||
unit testable without Django.
|
||||
"""
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
from mozilla_django_oidc.auth import OIDCAuthenticationBackend
|
||||
from user.src.oidc_claims import (
|
||||
admin_flags_from_groups,
|
||||
username_from_claims,
|
||||
)
|
||||
|
||||
|
||||
class TAOIDCBackend(OIDCAuthenticationBackend):
|
||||
"""map OIDC claims onto TubeArchivist Account rows"""
|
||||
|
||||
def _username_claim(self):
|
||||
return getattr(
|
||||
settings, "TA_OIDC_USERNAME_CLAIM", "preferred_username"
|
||||
)
|
||||
|
||||
def verify_token(self, token, **kwargs):
|
||||
"""pin the audience to our client id
|
||||
|
||||
mozilla-django-oidc does not enforce `aud` by default; for a flow that
|
||||
can grant superuser, reject tokens minted for a different client.
|
||||
"""
|
||||
payload = super().verify_token(token, **kwargs)
|
||||
audience = payload.get("aud")
|
||||
allowed = audience if isinstance(audience, list) else [audience]
|
||||
if not audience or self.OIDC_RP_CLIENT_ID not in allowed:
|
||||
raise SuspiciousOperation("OIDC token audience mismatch")
|
||||
|
||||
return payload
|
||||
|
||||
def verify_claims(self, claims):
|
||||
"""a resolvable account name is the only hard requirement"""
|
||||
return bool(username_from_claims(claims, self._username_claim()))
|
||||
|
||||
def filter_users_by_claims(self, claims):
|
||||
"""match an existing account by exact name (the unique column)"""
|
||||
name = username_from_claims(claims, self._username_claim())
|
||||
if not name:
|
||||
return self.UserModel.objects.none()
|
||||
|
||||
return self.UserModel.objects.filter(name=name)
|
||||
|
||||
def create_user(self, claims):
|
||||
"""provision a new account with an unusable password
|
||||
|
||||
Instantiates the model directly to bypass AccountManager (which
|
||||
requires a password); set_unusable_password keeps the local
|
||||
ModelBackend from ever authenticating this SSO account.
|
||||
"""
|
||||
name = username_from_claims(claims, self._username_claim())
|
||||
user = self.UserModel(name=name)
|
||||
user.set_unusable_password()
|
||||
self._apply_group_promotion(user, claims)
|
||||
user.save()
|
||||
|
||||
return user
|
||||
|
||||
def update_user(self, user, claims):
|
||||
"""re-apply group based promotion on each login"""
|
||||
if self._apply_group_promotion(user, claims):
|
||||
user.save()
|
||||
|
||||
return user
|
||||
|
||||
def _apply_group_promotion(self, user, claims):
|
||||
"""promote-only staff/superuser from the group claim
|
||||
|
||||
Returns True when a flag changed so callers can avoid a needless save.
|
||||
Mirrors the LDAP backend: never demotes, so a manually granted local
|
||||
admin keeps access.
|
||||
"""
|
||||
groups = claims.get(
|
||||
getattr(settings, "TA_OIDC_GROUPS_CLAIM", "groups"), []
|
||||
)
|
||||
is_staff, is_superuser = admin_flags_from_groups(
|
||||
groups,
|
||||
getattr(settings, "TA_OIDC_ADMIN_GROUP", ""),
|
||||
getattr(settings, "TA_OIDC_STAFF_GROUP", ""),
|
||||
)
|
||||
|
||||
changed = False
|
||||
if is_staff and not user.is_staff:
|
||||
user.is_staff = True
|
||||
changed = True
|
||||
if is_superuser and not user.is_superuser:
|
||||
user.is_superuser = True
|
||||
changed = True
|
||||
|
||||
return changed
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
"""pure helpers for mapping OIDC claims onto TubeArchivist accounts
|
||||
|
||||
Kept import-free (no Django, no mozilla_django_oidc) so the mapping logic can
|
||||
be unit tested without bootstrapping the app. The Django glue lives in
|
||||
user.src.oidc_auth.
|
||||
"""
|
||||
|
||||
|
||||
def username_from_claims(claims, claim_name="preferred_username"):
|
||||
"""resolve the account name from OIDC claims
|
||||
|
||||
Uses the configured claim, falling back only to the stable ``sub`` so an
|
||||
identity never silently resolves to a different, reassignable claim
|
||||
(e.g. email) between logins.
|
||||
"""
|
||||
for key in (claim_name, "sub"):
|
||||
value = claims.get(key)
|
||||
if value:
|
||||
return str(value)
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def admin_flags_from_groups(groups, admin_group, staff_group):
|
||||
"""map OIDC group membership to (is_staff, is_superuser)
|
||||
|
||||
Promote-only, mirroring the LDAP backend: belonging to admin_group grants
|
||||
superuser (which implies staff); staff_group grants staff. Empty group
|
||||
names disable that tier. Never demotes here - callers decide that.
|
||||
"""
|
||||
groups = groups or []
|
||||
is_superuser = bool(admin_group) and admin_group in groups
|
||||
is_staff = is_superuser or (bool(staff_group) and staff_group in groups)
|
||||
|
||||
return is_staff, is_superuser
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
"""OIDC URL routes using TubeArchivist's host-pinned views
|
||||
|
||||
Mirrors mozilla_django_oidc.urls (same view names) but swaps in the views that
|
||||
anchor redirect_uri to TA_HOST.
|
||||
"""
|
||||
|
||||
from django.urls import path
|
||||
from user.src.oidc_views import (
|
||||
TAOIDCAuthenticationCallbackView,
|
||||
TAOIDCAuthenticationRequestView,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
"callback/",
|
||||
TAOIDCAuthenticationCallbackView.as_view(),
|
||||
name="oidc_authentication_callback",
|
||||
),
|
||||
path(
|
||||
"authenticate/",
|
||||
TAOIDCAuthenticationRequestView.as_view(),
|
||||
name="oidc_authentication_init",
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
"""OIDC views that anchor the redirect_uri to the configured public host
|
||||
|
||||
TubeArchivist serves behind its own nginx (which rewrites the Host header to
|
||||
localhost) and usually an external TLS-terminating proxy (which rewrites the
|
||||
scheme). Letting mozilla-django-oidc infer the callback URL from the request
|
||||
therefore produces a wrong redirect_uri such as ``http://localhost/...``. We
|
||||
pin it to TA_HOST, the documented public URL, so the authorize redirect_uri and
|
||||
the token-exchange redirect_uri are both correct and identical.
|
||||
"""
|
||||
|
||||
from django.conf import settings
|
||||
from mozilla_django_oidc.views import (
|
||||
OIDCAuthenticationCallbackView,
|
||||
OIDCAuthenticationRequestView,
|
||||
)
|
||||
|
||||
|
||||
class PublicHostRedirectMixin:
|
||||
"""build root-relative absolute URIs from TA_OIDC_PUBLIC_URL
|
||||
|
||||
mozilla-django-oidc resolves the redirect_uri via
|
||||
``request.build_absolute_uri(reverse(...))`` in both the request view and
|
||||
the auth backend's token exchange. Patching it on the request object covers
|
||||
both, since the backend reuses the same request.
|
||||
"""
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
base = getattr(settings, "TA_OIDC_PUBLIC_URL", "")
|
||||
if base:
|
||||
original = request.build_absolute_uri
|
||||
|
||||
def build_absolute_uri(location=None):
|
||||
if location and location.startswith("/"):
|
||||
return base.rstrip("/") + location
|
||||
return original(location)
|
||||
|
||||
request.build_absolute_uri = build_absolute_uri
|
||||
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
|
||||
class TAOIDCAuthenticationRequestView(
|
||||
PublicHostRedirectMixin, OIDCAuthenticationRequestView
|
||||
):
|
||||
"""authorize redirect with a TA_HOST-anchored redirect_uri"""
|
||||
|
||||
|
||||
class TAOIDCAuthenticationCallbackView(
|
||||
PublicHostRedirectMixin, OIDCAuthenticationCallbackView
|
||||
):
|
||||
"""callback whose token-exchange redirect_uri matches the authorize one"""
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
"""test OIDC claim and group mapping helpers"""
|
||||
|
||||
import pytest
|
||||
from user.src.oidc_claims import admin_flags_from_groups, username_from_claims
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"claims,claim_name,expected",
|
||||
[
|
||||
({"preferred_username": "alice"}, "preferred_username", "alice"),
|
||||
# email is NOT a fallback - only the configured claim then sub
|
||||
({"email": "bob@example.com"}, "preferred_username", ""),
|
||||
({"sub": "abc-123"}, "preferred_username", "abc-123"),
|
||||
({"sub": "s-1", "email": "x@y.z"}, "preferred_username", "s-1"),
|
||||
({"name": "carol"}, "name", "carol"),
|
||||
({}, "preferred_username", ""),
|
||||
({"preferred_username": ""}, "preferred_username", ""),
|
||||
],
|
||||
)
|
||||
def test_username_from_claims(claims, claim_name, expected):
|
||||
"""resolve the account name from claims with sensible fallbacks"""
|
||||
assert username_from_claims(claims, claim_name) == expected
|
||||
|
||||
|
||||
def test_username_prefers_configured_claim():
|
||||
"""the configured claim wins over the default chain"""
|
||||
claims = {"preferred_username": "alice", "email": "alice@example.com"}
|
||||
assert username_from_claims(claims, "email") == "alice@example.com"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"groups,admin_group,staff_group,expected",
|
||||
[
|
||||
(["ta-admins"], "ta-admins", "ta-staff", (True, True)),
|
||||
(["ta-staff"], "ta-admins", "ta-staff", (True, False)),
|
||||
(["other"], "ta-admins", "ta-staff", (False, False)),
|
||||
([], "ta-admins", "ta-staff", (False, False)),
|
||||
(None, "ta-admins", "ta-staff", (False, False)),
|
||||
(["ta-admins"], "", "", (False, False)),
|
||||
(["ta-admins", "ta-staff"], "ta-admins", "ta-staff", (True, True)),
|
||||
],
|
||||
)
|
||||
def test_admin_flags_from_groups(groups, admin_group, staff_group, expected):
|
||||
"""map OIDC groups to (is_staff, is_superuser); admin implies staff"""
|
||||
assert (
|
||||
admin_flags_from_groups(groups, admin_group, staff_group) == expected
|
||||
)
|
||||
|
|
@ -5,6 +5,7 @@ from user import views
|
|||
|
||||
urlpatterns = [
|
||||
path("login/", views.LoginApiView.as_view(), name="api-user-login"),
|
||||
path("oidc/", views.OidcInfoView.as_view(), name="api-user-oidc"),
|
||||
path("logout/", views.LogoutApiView.as_view(), name="api-user-logout"),
|
||||
path("account/", views.UserAccountView.as_view(), name="api-user-account"),
|
||||
path("me/", views.UserConfigView.as_view(), name="api-user-me"),
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
from common.serializers import ErrorResponseSerializer
|
||||
from common.views import ApiBaseView
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import authenticate, login, logout
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
|
|
@ -13,6 +14,7 @@ from user.models import Account
|
|||
from user.serializers import (
|
||||
AccountSerializer,
|
||||
LoginSerializer,
|
||||
OidcInfoSerializer,
|
||||
UserMeConfigSerializer,
|
||||
)
|
||||
from user.src.user_config import UserConfig
|
||||
|
|
@ -112,6 +114,28 @@ class LoginApiView(APIView):
|
|||
return Response(status=204)
|
||||
|
||||
|
||||
class OidcInfoView(APIView):
|
||||
"""resolves to /api/user/oidc/
|
||||
GET: public SSO login capability, consumed by the login page
|
||||
"""
|
||||
|
||||
permission_classes = [AllowAny]
|
||||
authentication_classes = []
|
||||
|
||||
@extend_schema(responses=OidcInfoSerializer())
|
||||
def get(self, request):
|
||||
"""report whether SSO is offered and how to present it"""
|
||||
data = {
|
||||
"enabled": getattr(settings, "TA_SSO_ENABLED", False),
|
||||
"local_login": getattr(settings, "TA_LOCAL_LOGIN_ENABLED", True),
|
||||
"label": getattr(
|
||||
settings, "TA_OIDC_BUTTON_LABEL", "Log in with SSO"
|
||||
),
|
||||
"login_url": "/api/oidc/authenticate/",
|
||||
}
|
||||
return Response(OidcInfoSerializer(data).data)
|
||||
|
||||
|
||||
class LogoutApiView(ApiBaseView):
|
||||
"""resolves to /api/user/logout/
|
||||
POST: handle logout
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
import defaultHeaders from '../../configuration/defaultHeaders';
|
||||
import getApiUrl from '../../configuration/getApiUrl';
|
||||
import getFetchCredentials from '../../configuration/getFetchCredentials';
|
||||
|
||||
export type OidcInfoType = {
|
||||
enabled: boolean;
|
||||
local_login: boolean;
|
||||
label: string;
|
||||
login_url: string;
|
||||
};
|
||||
|
||||
const loadOidcInfo = async (): Promise<OidcInfoType> => {
|
||||
const apiUrl = getApiUrl();
|
||||
|
||||
const response = await fetch(`${apiUrl}/api/user/oidc/`, {
|
||||
headers: { ...defaultHeaders },
|
||||
credentials: getFetchCredentials(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`OIDC info request failed: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export default loadOidcInfo;
|
||||
|
|
@ -5,6 +5,8 @@ import Colours from '../configuration/colours/Colours';
|
|||
import Button from '../components/Button';
|
||||
import signIn from '../api/actions/signIn';
|
||||
import loadAuth from '../api/loader/loadAuth';
|
||||
import loadOidcInfo, { OidcInfoType } from '../api/loader/loadOidcInfo';
|
||||
import getApiUrl from '../configuration/getApiUrl';
|
||||
import LoadingIndicator from '../components/LoadingIndicator';
|
||||
|
||||
const Login = () => {
|
||||
|
|
@ -16,6 +18,8 @@ const Login = () => {
|
|||
const [waitingForBackend, setWaitingForBackend] = useState(false);
|
||||
const [waitedCount, setWaitedCount] = useState(0);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [oidcInfo, setOidcInfo] = useState<OidcInfoType | null>(null);
|
||||
const [oidcLoaded, setOidcLoaded] = useState(false);
|
||||
|
||||
const handleSubmit = async (event: { preventDefault: () => void }) => {
|
||||
event.preventDefault();
|
||||
|
|
@ -39,6 +43,13 @@ const Login = () => {
|
|||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadOidcInfo()
|
||||
.then(setOidcInfo)
|
||||
.catch(() => setOidcInfo(null))
|
||||
.finally(() => setOidcLoaded(true));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let retryCount = 0;
|
||||
|
||||
|
|
@ -72,6 +83,8 @@ const Login = () => {
|
|||
};
|
||||
}, [navigate]);
|
||||
|
||||
const showLocalLogin = oidcInfo?.local_login ?? true;
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>TA | Welcome</title>
|
||||
|
|
@ -89,59 +102,72 @@ const Login = () => {
|
|||
</p>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<input
|
||||
type="text"
|
||||
name="username"
|
||||
id="id_username"
|
||||
placeholder="Username"
|
||||
autoComplete="username"
|
||||
maxLength={150}
|
||||
required={true}
|
||||
value={username}
|
||||
onChange={event => setUsername(event.target.value)}
|
||||
/>
|
||||
|
||||
<br />
|
||||
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
id="id_password"
|
||||
placeholder="Password"
|
||||
autoComplete="current-password"
|
||||
required={true}
|
||||
value={password}
|
||||
onChange={event => setPassword(event.target.value)}
|
||||
/>
|
||||
|
||||
<br />
|
||||
|
||||
<p>
|
||||
Remember me:{' '}
|
||||
{oidcLoaded && showLocalLogin && (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="remember_me"
|
||||
id="id_remember_me"
|
||||
checked={saveLogin}
|
||||
onChange={() => {
|
||||
setSaveLogin(!saveLogin);
|
||||
}}
|
||||
type="text"
|
||||
name="username"
|
||||
id="id_username"
|
||||
placeholder="Username"
|
||||
autoComplete="username"
|
||||
maxLength={150}
|
||||
required={true}
|
||||
value={username}
|
||||
onChange={event => setUsername(event.target.value)}
|
||||
/>
|
||||
</p>
|
||||
|
||||
<input type="hidden" name="next" value={Routes.Home} />
|
||||
<br />
|
||||
|
||||
{waitingForBackend && (
|
||||
<>
|
||||
<p>
|
||||
Waiting for backend <LoadingIndicator />
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
id="id_password"
|
||||
placeholder="Password"
|
||||
autoComplete="current-password"
|
||||
required={true}
|
||||
value={password}
|
||||
onChange={event => setPassword(event.target.value)}
|
||||
/>
|
||||
|
||||
{!waitingForBackend && <Button label="Login" type="submit" />}
|
||||
</form>
|
||||
<br />
|
||||
|
||||
<p>
|
||||
Remember me:{' '}
|
||||
<input
|
||||
type="checkbox"
|
||||
name="remember_me"
|
||||
id="id_remember_me"
|
||||
checked={saveLogin}
|
||||
onChange={() => {
|
||||
setSaveLogin(!saveLogin);
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
|
||||
<input type="hidden" name="next" value={Routes.Home} />
|
||||
|
||||
{waitingForBackend && (
|
||||
<>
|
||||
<p>
|
||||
Waiting for backend <LoadingIndicator />
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!waitingForBackend && <Button label="Login" type="submit" />}
|
||||
</form>
|
||||
)}
|
||||
|
||||
{oidcInfo?.enabled && (
|
||||
<Button
|
||||
label={oidcInfo.label}
|
||||
type="button"
|
||||
className="oidc-login-button"
|
||||
onClick={() => {
|
||||
window.location.assign(`${getApiUrl()}${oidcInfo.login_url}`);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{waitedCount > 10 && (
|
||||
<div className="info-box">
|
||||
|
|
|
|||
|
|
@ -865,6 +865,12 @@ video:-webkit-full-screen {
|
|||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.login-page .oidc-login-button {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.login-links a {
|
||||
text-decoration: underline;
|
||||
margin: 30px 0;
|
||||
|
|
|
|||
Loading…
Reference in New Issue