Allow setting password policies from admin UI.

This commit is contained in:
Sebastian Jeltsch
2025-05-09 14:56:55 +02:00
parent c561e8eb99
commit c170a91983
5 changed files with 243 additions and 44 deletions

View File

@@ -318,10 +318,26 @@ export interface AuthConfig {
refreshTokenTtlSec?:
| number
| undefined;
/** / Disables password-based sign-up. */
/** / Disables password-based sign-up. Does not affect already registered users. */
disablePasswordAuth?:
| boolean
| undefined;
/** / Minimal password length. Defaults to 8. */
passwordMinimalLength?:
| number
| undefined;
/** / Password must contain lower and upper-case letters. */
passwordMustContainUpperAndLowerCase?:
| boolean
| undefined;
/** / Password must contain digits in addition to alphabetic characters.. */
passwordMustContainDigits?:
| boolean
| undefined;
/** / Password must contain special, non-alphanumeric, characters. */
passwordMustContainSpecialCharacters?:
| boolean
| undefined;
/** / Map of configured OAuth providers. */
oauthProviders: { [key: string]: OAuthProviderConfig };
}
@@ -940,6 +956,24 @@ export const AuthConfig: MessageFns<AuthConfig> = {
if (message.disablePasswordAuth !== undefined && message.disablePasswordAuth !== false) {
writer.uint32(24).bool(message.disablePasswordAuth);
}
if (message.passwordMinimalLength !== undefined && message.passwordMinimalLength !== 0) {
writer.uint32(32).uint32(message.passwordMinimalLength);
}
if (
message.passwordMustContainUpperAndLowerCase !== undefined &&
message.passwordMustContainUpperAndLowerCase !== false
) {
writer.uint32(40).bool(message.passwordMustContainUpperAndLowerCase);
}
if (message.passwordMustContainDigits !== undefined && message.passwordMustContainDigits !== false) {
writer.uint32(48).bool(message.passwordMustContainDigits);
}
if (
message.passwordMustContainSpecialCharacters !== undefined &&
message.passwordMustContainSpecialCharacters !== false
) {
writer.uint32(56).bool(message.passwordMustContainSpecialCharacters);
}
Object.entries(message.oauthProviders).forEach(([key, value]) => {
AuthConfig_OauthProvidersEntry.encode({ key: key as any, value }, writer.uint32(90).fork()).join();
});
@@ -977,6 +1011,38 @@ export const AuthConfig: MessageFns<AuthConfig> = {
message.disablePasswordAuth = reader.bool();
continue;
}
case 4: {
if (tag !== 32) {
break;
}
message.passwordMinimalLength = reader.uint32();
continue;
}
case 5: {
if (tag !== 40) {
break;
}
message.passwordMustContainUpperAndLowerCase = reader.bool();
continue;
}
case 6: {
if (tag !== 48) {
break;
}
message.passwordMustContainDigits = reader.bool();
continue;
}
case 7: {
if (tag !== 56) {
break;
}
message.passwordMustContainSpecialCharacters = reader.bool();
continue;
}
case 11: {
if (tag !== 90) {
break;
@@ -1004,6 +1070,18 @@ export const AuthConfig: MessageFns<AuthConfig> = {
disablePasswordAuth: isSet(object.disablePasswordAuth)
? globalThis.Boolean(object.disablePasswordAuth)
: undefined,
passwordMinimalLength: isSet(object.passwordMinimalLength)
? globalThis.Number(object.passwordMinimalLength)
: undefined,
passwordMustContainUpperAndLowerCase: isSet(object.passwordMustContainUpperAndLowerCase)
? globalThis.Boolean(object.passwordMustContainUpperAndLowerCase)
: undefined,
passwordMustContainDigits: isSet(object.passwordMustContainDigits)
? globalThis.Boolean(object.passwordMustContainDigits)
: undefined,
passwordMustContainSpecialCharacters: isSet(object.passwordMustContainSpecialCharacters)
? globalThis.Boolean(object.passwordMustContainSpecialCharacters)
: undefined,
oauthProviders: isObject(object.oauthProviders)
? Object.entries(object.oauthProviders).reduce<{ [key: string]: OAuthProviderConfig }>((acc, [key, value]) => {
acc[key] = OAuthProviderConfig.fromJSON(value);
@@ -1024,6 +1102,24 @@ export const AuthConfig: MessageFns<AuthConfig> = {
if (message.disablePasswordAuth !== undefined && message.disablePasswordAuth !== false) {
obj.disablePasswordAuth = message.disablePasswordAuth;
}
if (message.passwordMinimalLength !== undefined && message.passwordMinimalLength !== 0) {
obj.passwordMinimalLength = Math.round(message.passwordMinimalLength);
}
if (
message.passwordMustContainUpperAndLowerCase !== undefined &&
message.passwordMustContainUpperAndLowerCase !== false
) {
obj.passwordMustContainUpperAndLowerCase = message.passwordMustContainUpperAndLowerCase;
}
if (message.passwordMustContainDigits !== undefined && message.passwordMustContainDigits !== false) {
obj.passwordMustContainDigits = message.passwordMustContainDigits;
}
if (
message.passwordMustContainSpecialCharacters !== undefined &&
message.passwordMustContainSpecialCharacters !== false
) {
obj.passwordMustContainSpecialCharacters = message.passwordMustContainSpecialCharacters;
}
if (message.oauthProviders) {
const entries = Object.entries(message.oauthProviders);
if (entries.length > 0) {
@@ -1044,6 +1140,10 @@ export const AuthConfig: MessageFns<AuthConfig> = {
message.authTokenTtlSec = object.authTokenTtlSec ?? 0;
message.refreshTokenTtlSec = object.refreshTokenTtlSec ?? 0;
message.disablePasswordAuth = object.disablePasswordAuth ?? false;
message.passwordMinimalLength = object.passwordMinimalLength ?? 0;
message.passwordMustContainUpperAndLowerCase = object.passwordMustContainUpperAndLowerCase ?? false;
message.passwordMustContainDigits = object.passwordMustContainDigits ?? false;
message.passwordMustContainSpecialCharacters = object.passwordMustContainSpecialCharacters ?? false;
message.oauthProviders = Object.entries(object.oauthProviders ?? {}).reduce<{ [key: string]: OAuthProviderConfig }>(
(acc, [key, value]) => {
if (value !== undefined) {

View File

@@ -323,7 +323,7 @@ function AuthSettingsForm(props: {
<div class="flex flex-col gap-4">
<Card>
<CardHeader>
<h2>Auth Settings</h2>
<h2>Token Settings</h2>
</CardHeader>
<CardContent>
@@ -353,11 +353,21 @@ function AuthSettingsForm(props: {
),
})}
</form.Field>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<h2>Password Settings</h2>
</CardHeader>
<CardContent>
<div class="flex flex-col gap-4">
<form.Field name="disablePasswordAuth">
{buildOptionalBoolFormField({
label: () => (
<div class={labelWidth}>Disable Password Auth</div>
<div class={labelWidth}>Disable Password Registration</div>
),
info: (
<p>
@@ -368,10 +378,104 @@ function AuthSettingsForm(props: {
),
})}
</form.Field>
<form.Field name="passwordMinimalLength">
{buildOptionalNumberFormField({
label: () => <div class={labelWidth}>Min Length</div>,
info: (
<p>
Minimal length for new passwords [Default 8]. Does not
affect existing registrations unless users choose to
change their password.
</p>
),
})}
</form.Field>
<form.Field name="passwordMustContainUpperAndLowerCase">
{buildOptionalBoolFormField({
label: () => (
<div class={labelWidth}>
Must Contain Upper {"&"} Lower Case
</div>
),
info: (
<p>
Passwords must contain both, upper and lower case
characters. Does not affect existing registrations unless
users choose to change their password.
</p>
),
})}
</form.Field>
<form.Field name="passwordMustContainDigits">
{buildOptionalBoolFormField({
label: () => (
<div class={labelWidth}>Must Contain Digits</div>
),
info: (
<p>
Passwords must additionally contain digits. Does not
affect existing registrations unless users choose to
change their password.
</p>
),
})}
</form.Field>
<form.Field name="passwordMustContainSpecialCharacters">
{buildOptionalBoolFormField({
label: () => (
<div class={labelWidth}>Must Contain Special</div>
),
info: (
<p>
Passwords must additionally contain special, i.e.,
non-alphanumeric characters.. Does not affect existing
registrations unless users choose to change their
password.
</p>
),
})}
</form.Field>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<h2>OAuth Providers</h2>
</CardHeader>
<CardContent>
<form.Field name="namedOauthProviders">
{(_field) => {
return (
<Accordion multiple={false} collapsible class="w-full">
<For each={values().namedOauthProviders}>
{(provider, index) => {
// Skip OIDC provider for now until we expand the form to render the extra fields.
if (provider.provider.id === OAuthProviderId.OIDC0) {
return null;
}
return (
<ProviderSettingsSubForm
form={form}
index={index()}
provider={provider.provider}
/>
);
}}
</For>
</Accordion>
);
}}
</form.Field>
</CardContent>
</Card>
<Card>
<CardHeader>
<h2>Public Key</h2>
@@ -420,39 +524,6 @@ function AuthSettingsForm(props: {
</CardContent>
</Card>
<Card>
<CardHeader>
<h2>OAuth Providers</h2>
</CardHeader>
<CardContent>
<form.Field name="namedOauthProviders">
{(_field) => {
return (
<Accordion multiple={false} collapsible class="w-full">
<For each={values().namedOauthProviders}>
{(provider, index) => {
// Skip OIDC provider for now until we expand the form to render the extra fields.
if (provider.provider.id === OAuthProviderId.OIDC0) {
return null;
}
return (
<ProviderSettingsSubForm
form={form}
index={index()}
provider={provider.provider}
/>
);
}}
</For>
</Accordion>
);
}}
</form.Field>
</CardContent>
</Card>
<div class="flex justify-end">
<form.Subscribe
selector={(state) => ({

View File

@@ -60,9 +60,21 @@ message AuthConfig {
/// Time-to-live in seconds for refresh tokens. Default: 30 days.
optional int64 refresh_token_ttl_sec = 2;
/// Disables password-based sign-up.
/// Disables password-based sign-up. Does not affect already registered users.
optional bool disable_password_auth = 3;
/// Minimal password length. Defaults to 8.
optional uint32 password_minimal_length = 4;
/// Password must contain lower and upper-case letters.
optional bool password_must_contain_upper_and_lower_case = 5;
/// Password must contain digits in addition to alphabetic characters..
optional bool password_must_contain_digits = 6;
/// Password must contain special, non-alphanumeric, characters.
optional bool password_must_contain_special_characters = 7;
/// Map of configured OAuth providers.
map<string, OAuthProviderConfig> oauth_providers = 11;
}

View File

@@ -14,7 +14,17 @@ pub struct AuthOptions {
impl AuthOptions {
pub fn from_config(config: AuthConfig) -> Self {
return Self {
password_options: PasswordOptions::default(),
password_options: PasswordOptions {
min_length: config.password_minimal_length.unwrap_or(8) as usize,
max_length: 128,
must_contain_upper_and_lower_case: config
.password_must_contain_upper_and_lower_case
.unwrap_or(false),
must_contain_digits: config.password_must_contain_digits.unwrap_or(false),
must_contain_special_characters: config
.password_must_contain_special_characters
.unwrap_or(false),
},
oauth_providers: build_oauth_providers_from_config(config).unwrap_or_else(|err| {
error!("Failed to derive configured OAuth providers from config: {err}");
return Default::default();

View File

@@ -9,7 +9,7 @@ pub struct PasswordOptions {
pub min_length: usize,
pub max_length: usize,
pub must_contain_lower_and_upper_case: bool,
pub must_contain_upper_and_lower_case: bool,
pub must_contain_digits: bool,
pub must_contain_special_characters: bool,
}
@@ -19,7 +19,7 @@ impl Default for PasswordOptions {
return PasswordOptions {
min_length: 8,
max_length: 128,
must_contain_lower_and_upper_case: false,
must_contain_upper_and_lower_case: false,
must_contain_digits: false,
must_contain_special_characters: false,
};
@@ -43,11 +43,16 @@ pub fn validate_password_policy(
return Err(AuthError::BadRequest("Password too long"));
}
if opts.must_contain_digits && !password.chars().any(|x| x.is_numeric()) {
return Err(AuthError::BadRequest("Must contain numeric"));
if opts.must_contain_digits {
if !password.chars().any(|x| x.is_numeric()) {
return Err(AuthError::BadRequest("Must contain digits"));
}
if password.chars().all(|x| x.is_numeric()) {
return Err(AuthError::BadRequest("Must contain non-digits"));
}
}
if opts.must_contain_lower_and_upper_case
if opts.must_contain_upper_and_lower_case
&& !(password.chars().any(|x| x.is_lowercase()) && password.chars().any(|x| x.is_uppercase()))
{
return Err(AuthError::BadRequest("Must contain lower and upper case"));
@@ -170,7 +175,7 @@ mod tests {
// lower-upper
let options = PasswordOptions {
min_length: 2,
must_contain_lower_and_upper_case: true,
must_contain_upper_and_lower_case: true,
..Default::default()
};
@@ -189,6 +194,7 @@ mod tests {
assert!(test("aa", &options).is_err());
assert!(test("2a", &options).is_ok());
assert!(test("22", &options).is_err());
}
{