Add PULSE_AUTH_HIDE_LOCAL_LOGIN option to hide password form
Implements #750 - allows hiding the username/password login form when using OIDC SSO to avoid user confusion, while maintaining security. - Added HideLocalLogin config option (env: PULSE_AUTH_HIDE_LOCAL_LOGIN) - Exposed hideLocalLogin in /api/security/status endpoint - Updated Login.tsx to conditionally hide local login form - Added escape hatch via ?show_local=true URL parameter This approach avoids the security and upgrade issues that led to DISABLE_AUTH being removed (see #707, #678), while solving the UX problem of users being confused by multiple login options.
This commit is contained in:
parent
9caba86389
commit
6b84b9a2bf
3 changed files with 173 additions and 149 deletions
|
|
@ -20,6 +20,7 @@ interface SecurityStatus {
|
||||||
deprecatedDisableAuth?: boolean;
|
deprecatedDisableAuth?: boolean;
|
||||||
message?: string;
|
message?: string;
|
||||||
apiTokenConfigured?: boolean;
|
apiTokenConfigured?: boolean;
|
||||||
|
hideLocalLogin?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Login: Component<LoginProps> = (props) => {
|
export const Login: Component<LoginProps> = (props) => {
|
||||||
|
|
@ -275,6 +276,12 @@ export const Login: Component<LoginProps> = (props) => {
|
||||||
const showFirstRunSetup = () =>
|
const showFirstRunSetup = () =>
|
||||||
authStatus()?.hasAuthentication === false || legacyDisableAuth();
|
authStatus()?.hasAuthentication === false || legacyDisableAuth();
|
||||||
|
|
||||||
|
const shouldShowLocalLogin = () => {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
if (params.get('show_local') === 'true') return true;
|
||||||
|
return !authStatus()?.hideLocalLogin;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Show
|
<Show
|
||||||
when={!loadingAuth()}
|
when={!loadingAuth()}
|
||||||
|
|
@ -306,6 +313,7 @@ export const Login: Component<LoginProps> = (props) => {
|
||||||
oidcLoading,
|
oidcLoading,
|
||||||
oidcError,
|
oidcError,
|
||||||
oidcMessage,
|
oidcMessage,
|
||||||
|
showLocalLogin: shouldShowLocalLogin(),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
|
@ -346,6 +354,7 @@ const LoginForm: Component<{
|
||||||
oidcLoading: () => boolean;
|
oidcLoading: () => boolean;
|
||||||
oidcError: () => string;
|
oidcError: () => string;
|
||||||
oidcMessage: () => string;
|
oidcMessage: () => string;
|
||||||
|
showLocalLogin: boolean;
|
||||||
}> = (props) => {
|
}> = (props) => {
|
||||||
const {
|
const {
|
||||||
username,
|
username,
|
||||||
|
|
@ -362,6 +371,7 @@ const LoginForm: Component<{
|
||||||
oidcLoading,
|
oidcLoading,
|
||||||
oidcError,
|
oidcError,
|
||||||
oidcMessage,
|
oidcMessage,
|
||||||
|
showLocalLogin,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -443,169 +453,171 @@ const LoginForm: Component<{
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<div class="space-y-4">
|
<Show when={showLocalLogin}>
|
||||||
<div class="relative">
|
<div class="space-y-4">
|
||||||
<label for="username" class="sr-only">
|
<div class="relative">
|
||||||
Username
|
<label for="username" class="sr-only">
|
||||||
</label>
|
Username
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
</label>
|
||||||
<svg
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
class="h-5 w-5 text-gray-400"
|
<svg
|
||||||
fill="none"
|
class="h-5 w-5 text-gray-400"
|
||||||
stroke="currentColor"
|
fill="none"
|
||||||
viewBox="0 0 24 24"
|
stroke="currentColor"
|
||||||
>
|
viewBox="0 0 24 24"
|
||||||
<path
|
>
|
||||||
stroke-linecap="round"
|
<path
|
||||||
stroke-linejoin="round"
|
stroke-linecap="round"
|
||||||
stroke-width="2"
|
stroke-linejoin="round"
|
||||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
stroke-width="2"
|
||||||
/>
|
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||||
</svg>
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id="username"
|
||||||
|
name="username"
|
||||||
|
type="text"
|
||||||
|
autocomplete="username"
|
||||||
|
required
|
||||||
|
class="appearance-none relative block w-full pl-10 pr-3 py-3 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all sm:text-sm dark:bg-gray-700 dark:border-gray-600 dark:text-white dark:placeholder-gray-400"
|
||||||
|
placeholder="Username"
|
||||||
|
value={username()}
|
||||||
|
onInput={(e) => setUsername(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<div class="relative">
|
||||||
id="username"
|
<label for="password" class="sr-only">
|
||||||
name="username"
|
Password
|
||||||
type="text"
|
</label>
|
||||||
autocomplete="username"
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
required
|
<svg
|
||||||
class="appearance-none relative block w-full pl-10 pr-3 py-3 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all sm:text-sm dark:bg-gray-700 dark:border-gray-600 dark:text-white dark:placeholder-gray-400"
|
class="h-5 w-5 text-gray-400"
|
||||||
placeholder="Username"
|
fill="none"
|
||||||
value={username()}
|
stroke="currentColor"
|
||||||
onInput={(e) => setUsername(e.currentTarget.value)}
|
viewBox="0 0 24 24"
|
||||||
/>
|
>
|
||||||
</div>
|
<path
|
||||||
<div class="relative">
|
stroke-linecap="round"
|
||||||
<label for="password" class="sr-only">
|
stroke-linejoin="round"
|
||||||
Password
|
stroke-width="2"
|
||||||
</label>
|
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
/>
|
||||||
<svg
|
</svg>
|
||||||
class="h-5 w-5 text-gray-400"
|
</div>
|
||||||
fill="none"
|
<input
|
||||||
stroke="currentColor"
|
id="password"
|
||||||
viewBox="0 0 24 24"
|
name="password"
|
||||||
>
|
type="password"
|
||||||
<path
|
autocomplete="current-password"
|
||||||
stroke-linecap="round"
|
required
|
||||||
stroke-linejoin="round"
|
class="appearance-none relative block w-full pl-10 pr-3 py-3 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all sm:text-sm dark:bg-gray-700 dark:border-gray-600 dark:text-white dark:placeholder-gray-400"
|
||||||
stroke-width="2"
|
placeholder="Password"
|
||||||
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
|
value={password()}
|
||||||
/>
|
onInput={(e) => setPassword(e.currentTarget.value)}
|
||||||
</svg>
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<input
|
||||||
|
id="remember-me"
|
||||||
|
name="remember-me"
|
||||||
|
type="checkbox"
|
||||||
|
checked={rememberMe()}
|
||||||
|
onChange={(e) => setRememberMe(e.currentTarget.checked)}
|
||||||
|
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded cursor-pointer dark:border-gray-600 dark:bg-gray-700"
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
for="remember-me"
|
||||||
|
class="ml-2 block text-sm text-gray-700 dark:text-gray-300 cursor-pointer"
|
||||||
|
>
|
||||||
|
Remember me
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<input
|
|
||||||
id="password"
|
|
||||||
name="password"
|
|
||||||
type="password"
|
|
||||||
autocomplete="current-password"
|
|
||||||
required
|
|
||||||
class="appearance-none relative block w-full pl-10 pr-3 py-3 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all sm:text-sm dark:bg-gray-700 dark:border-gray-600 dark:text-white dark:placeholder-gray-400"
|
|
||||||
placeholder="Password"
|
|
||||||
value={password()}
|
|
||||||
onInput={(e) => setPassword(e.currentTarget.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center">
|
|
||||||
<input
|
|
||||||
id="remember-me"
|
|
||||||
name="remember-me"
|
|
||||||
type="checkbox"
|
|
||||||
checked={rememberMe()}
|
|
||||||
onChange={(e) => setRememberMe(e.currentTarget.checked)}
|
|
||||||
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded cursor-pointer dark:border-gray-600 dark:bg-gray-700"
|
|
||||||
/>
|
|
||||||
<label
|
|
||||||
for="remember-me"
|
|
||||||
class="ml-2 block text-sm text-gray-700 dark:text-gray-300 cursor-pointer"
|
|
||||||
>
|
|
||||||
Remember me
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Show when={error()}>
|
<Show when={error()}>
|
||||||
<div
|
<div
|
||||||
class={`rounded-md p-4 ${error().includes('locked')
|
class={`rounded-md p-4 ${error().includes('locked')
|
||||||
? 'bg-orange-50 dark:bg-orange-900/20'
|
? 'bg-orange-50 dark:bg-orange-900/20'
|
||||||
: 'bg-red-50 dark:bg-red-900/20'
|
: 'bg-red-50 dark:bg-red-900/20'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<div class="flex-shrink-0">
|
<div class="flex-shrink-0">
|
||||||
<Show
|
<Show
|
||||||
when={error().includes('locked')}
|
when={error().includes('locked')}
|
||||||
fallback={
|
fallback={
|
||||||
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
|
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<svg class="h-5 w-5 text-orange-400" viewBox="0 0 20 20" fill="currentColor">
|
||||||
<path
|
<path
|
||||||
fill-rule="evenodd"
|
fill-rule="evenodd"
|
||||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
|
d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z"
|
||||||
clip-rule="evenodd"
|
clip-rule="evenodd"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
}
|
</Show>
|
||||||
>
|
</div>
|
||||||
<svg class="h-5 w-5 text-orange-400" viewBox="0 0 20 20" fill="currentColor">
|
<div class="ml-3">
|
||||||
<path
|
<p
|
||||||
fill-rule="evenodd"
|
class={`text-sm ${error().includes('locked')
|
||||||
d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z"
|
? 'text-orange-800 dark:text-orange-200'
|
||||||
clip-rule="evenodd"
|
: 'text-red-800 dark:text-red-200'
|
||||||
/>
|
}`}
|
||||||
</svg>
|
>
|
||||||
</Show>
|
{error()}
|
||||||
</div>
|
|
||||||
<div class="ml-3">
|
|
||||||
<p
|
|
||||||
class={`text-sm ${error().includes('locked')
|
|
||||||
? 'text-orange-800 dark:text-orange-200'
|
|
||||||
: 'text-red-800 dark:text-red-200'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{error()}
|
|
||||||
</p>
|
|
||||||
<Show when={error().includes('locked') && error().includes('minute')}>
|
|
||||||
<p class="text-xs mt-1 text-orange-700 dark:text-orange-300">
|
|
||||||
Lockouts automatically expire after the specified time. If you need immediate
|
|
||||||
access, contact your administrator.
|
|
||||||
</p>
|
</p>
|
||||||
</Show>
|
<Show when={error().includes('locked') && error().includes('minute')}>
|
||||||
|
<p class="text-xs mt-1 text-orange-700 dark:text-orange-300">
|
||||||
|
Lockouts automatically expire after the specified time. If you need immediate
|
||||||
|
access, contact your administrator.
|
||||||
|
</p>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading()}
|
||||||
|
class="group relative w-full flex justify-center py-3 px-4 border border-transparent text-sm font-medium rounded-lg text-white bg-gradient-to-r from-blue-600 to-cyan-600 hover:from-blue-700 hover:to-cyan-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transform transition hover:scale-105 shadow-lg"
|
||||||
|
>
|
||||||
|
<Show when={loading()}>
|
||||||
|
<svg
|
||||||
|
class="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
class="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="4"
|
||||||
|
></circle>
|
||||||
|
<path
|
||||||
|
class="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
</Show>
|
||||||
|
<Show when={loading()} fallback="Sign in to Pulse">
|
||||||
|
Authenticating...
|
||||||
|
</Show>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<div>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={loading()}
|
|
||||||
class="group relative w-full flex justify-center py-3 px-4 border border-transparent text-sm font-medium rounded-lg text-white bg-gradient-to-r from-blue-600 to-cyan-600 hover:from-blue-700 hover:to-cyan-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transform transition hover:scale-105 shadow-lg"
|
|
||||||
>
|
|
||||||
<Show when={loading()}>
|
|
||||||
<svg
|
|
||||||
class="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<circle
|
|
||||||
class="opacity-25"
|
|
||||||
cx="12"
|
|
||||||
cy="12"
|
|
||||||
r="10"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="4"
|
|
||||||
></circle>
|
|
||||||
<path
|
|
||||||
class="opacity-75"
|
|
||||||
fill="currentColor"
|
|
||||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
</Show>
|
|
||||||
<Show when={loading()} fallback="Sign in to Pulse">
|
|
||||||
Authenticating...
|
|
||||||
</Show>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -486,6 +486,7 @@ func (r *Router) setupRoutes() {
|
||||||
"authUsername": "",
|
"authUsername": "",
|
||||||
"authLastModified": "",
|
"authLastModified": "",
|
||||||
"oidcUsername": oidcUsername,
|
"oidcUsername": oidcUsername,
|
||||||
|
"hideLocalLogin": r.config.HideLocalLogin,
|
||||||
}
|
}
|
||||||
|
|
||||||
if isAuthenticated {
|
if isAuthenticated {
|
||||||
|
|
|
||||||
|
|
@ -129,6 +129,7 @@ type Config struct {
|
||||||
DemoMode bool `envconfig:"DEMO_MODE" default:"false"` // Read-only demo mode
|
DemoMode bool `envconfig:"DEMO_MODE" default:"false"` // Read-only demo mode
|
||||||
AllowedOrigins string `envconfig:"ALLOWED_ORIGINS" default:"*"`
|
AllowedOrigins string `envconfig:"ALLOWED_ORIGINS" default:"*"`
|
||||||
IframeEmbeddingAllow string `envconfig:"IFRAME_EMBEDDING_ALLOW" default:"SAMEORIGIN"`
|
IframeEmbeddingAllow string `envconfig:"IFRAME_EMBEDDING_ALLOW" default:"SAMEORIGIN"`
|
||||||
|
HideLocalLogin bool `envconfig:"PULSE_AUTH_HIDE_LOCAL_LOGIN" default:"false"`
|
||||||
|
|
||||||
// Proxy authentication settings
|
// Proxy authentication settings
|
||||||
ProxyAuthSecret string `envconfig:"PROXY_AUTH_SECRET"`
|
ProxyAuthSecret string `envconfig:"PROXY_AUTH_SECRET"`
|
||||||
|
|
@ -774,6 +775,16 @@ func Load() (*Config, error) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if hideLocalLoginStr := utils.GetenvTrim("PULSE_AUTH_HIDE_LOCAL_LOGIN"); hideLocalLoginStr != "" {
|
||||||
|
if hide, err := strconv.ParseBool(hideLocalLoginStr); err == nil {
|
||||||
|
cfg.HideLocalLogin = hide
|
||||||
|
cfg.EnvOverrides["PULSE_AUTH_HIDE_LOCAL_LOGIN"] = true
|
||||||
|
log.Info().Bool("hide", hide).Msg("Overriding hide local login setting from environment")
|
||||||
|
} else {
|
||||||
|
log.Warn().Str("value", hideLocalLoginStr).Msg("Invalid PULSE_AUTH_HIDE_LOCAL_LOGIN value, ignoring")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if enabledStr := utils.GetenvTrim("ENABLE_BACKUP_POLLING"); enabledStr != "" {
|
if enabledStr := utils.GetenvTrim("ENABLE_BACKUP_POLLING"); enabledStr != "" {
|
||||||
switch strings.ToLower(enabledStr) {
|
switch strings.ToLower(enabledStr) {
|
||||||
case "0", "false", "no", "off":
|
case "0", "false", "no", "off":
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue