diff --git a/cmd/config.go b/cmd/config.go index 8a04a219..788bd6ba 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -4,9 +4,12 @@ import ( "encoding/json" "github.com/abhinavxd/libredesk/internal/envelope" + oidcmodels "github.com/abhinavxd/libredesk/internal/oidc/models" "github.com/zerodha/fastglue" ) +const localLoginEnabledKey = "auth.local_login_enabled" + // handleGetConfig returns the public configuration needed for app initialization, this includes minimal app settings and enabled SSO providers (without secrets). func handleGetConfig(r *fastglue.Request) error { var app = r.Context.(*App) @@ -58,6 +61,23 @@ func handleGetConfig(r *fastglue.Request) error { // Add SSO providers to the response publicSettings["app.sso_providers"] = enabledProviders + publicSettings["auth.local_login_enabled"] = isLocalLoginEnabled(oidcProviders) return r.SendEnvelope(publicSettings) } + +func localLoginDisabledInConfig() bool { + return ko.Exists(localLoginEnabledKey) && !ko.Bool(localLoginEnabledKey) +} + +func isLocalLoginEnabled(providers []oidcmodels.OIDC) bool { + if !localLoginDisabledInConfig() { + return true + } + for _, provider := range providers { + if provider.Enabled { + return false + } + } + return true +} diff --git a/cmd/config_test.go b/cmd/config_test.go new file mode 100644 index 00000000..fcca43fa --- /dev/null +++ b/cmd/config_test.go @@ -0,0 +1,41 @@ +package main + +import ( + "testing" + + oidcmodels "github.com/abhinavxd/libredesk/internal/oidc/models" +) + +func TestIsLocalLoginEnabled(t *testing.T) { + tests := []struct { + name string + configured *bool + providers []oidcmodels.OIDC + want bool + }{ + {name: "defaults to enabled", want: true}, + {name: "remains enabled when configured", configured: boolPointer(true), want: true}, + {name: "remains enabled without an enabled OIDC provider", configured: boolPointer(false), providers: []oidcmodels.OIDC{{Enabled: false}}, want: true}, + {name: "is disabled with an enabled OIDC provider", configured: boolPointer(false), providers: []oidcmodels.OIDC{{Enabled: true}}, want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ko.Delete(localLoginEnabledKey) + if tt.configured != nil { + if err := ko.Set(localLoginEnabledKey, *tt.configured); err != nil { + t.Fatalf("set config: %v", err) + } + } + t.Cleanup(func() { ko.Delete(localLoginEnabledKey) }) + + if got := isLocalLoginEnabled(tt.providers); got != tt.want { + t.Fatalf("isLocalLoginEnabled() = %v, want %v", got, tt.want) + } + }) + } +} + +func boolPointer(value bool) *bool { + return &value +} diff --git a/cmd/login.go b/cmd/login.go index 2cf3968e..250cb943 100644 --- a/cmd/login.go +++ b/cmd/login.go @@ -21,6 +21,17 @@ func handleLogin(r *fastglue.Request) error { loginReq loginRequest ) + if localLoginDisabledInConfig() { + oidcProviders, err := app.oidc.GetAll() + if err != nil { + return sendErrorEnvelope(r, err) + } + if !isLocalLoginEnabled(oidcProviders) { + return r.SendErrorEnvelope(fasthttp.StatusForbidden, app.i18n.T("auth.localLoginDisabled"), nil, envelope.PermissionError) + } + app.lo.Warn("local login is disabled in config but no OIDC provider is enabled, allowing password login") + } + // Decode JSON request. if err := r.Decode(&loginReq, "json"); err != nil { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("errors.parsingRequest"), nil, envelope.InputError) diff --git a/config.sample.toml b/config.sample.toml index 50d5bcc7..f09ec80c 100644 --- a/config.sample.toml +++ b/config.sample.toml @@ -34,6 +34,13 @@ read_buffer_size = 65536 # Keepalive settings. keepalive_timeout = "10s" +[auth] +# Allow users to sign in with their libredesk email and password. +# Disable this only after configuring and enabling at least one OIDC provider. +# libredesk automatically keeps local login available when no OIDC provider is enabled to prevent lockout. +# If an enabled provider is unavailable or misconfigured, set auth.local_login_enabled = true to restore password login. +local_login_enabled = true + # File upload provider to use, either `fs` or `s3`. [upload] provider = "fs" diff --git a/frontend/apps/main/src/views/auth/UserLoginView.vue b/frontend/apps/main/src/views/auth/UserLoginView.vue index 5823750a..fa4f2f00 100644 --- a/frontend/apps/main/src/views/auth/UserLoginView.vue +++ b/frontend/apps/main/src/views/auth/UserLoginView.vue @@ -32,7 +32,7 @@ {{ oidcProvider.name }} -
+
@@ -42,7 +42,7 @@
-
+
{ return oidcProviders.value.filter((provider) => !provider.disabled) }) +const localLoginEnabled = computed( + () => appSettingsStore.public_config?.['auth.local_login_enabled'] !== false +) + const emailHasError = computed(() => { if (!submitted.value) return false const email = loginForm.value.email diff --git a/i18n/en-US.json b/i18n/en-US.json index 680ca10f..9428f883 100644 --- a/i18n/en-US.json +++ b/i18n/en-US.json @@ -608,6 +608,7 @@ "auth.invalidOrExpiredSessionClearCookie": "Invalid or expired session. Please clear your cookies and try again.", "auth.invalidResetLink": "Invalid reset link. Please request a new password reset link.", "auth.loggingIn": "Logging in...", + "auth.localLoginDisabled": "Password login is disabled. Sign in with your SSO provider.", "auth.newPassword": "New password", "auth.oidcAccessDenied": "The sign-in was cancelled or denied at the identity provider. Try signing in again.", "auth.oidcInvalidClient": "Sign-in is unavailable due to a configuration problem. Ask your administrator to check the SSO client ID and secret.",