diff --git a/trailbase-assets/js/admin/proto/config.ts b/trailbase-assets/js/admin/proto/config.ts index dfb56c81..96bb2ad5 100644 --- a/trailbase-assets/js/admin/proto/config.ts +++ b/trailbase-assets/js/admin/proto/config.ts @@ -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 = { 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 = { 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 = { 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 = { 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 = { 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) { diff --git a/trailbase-assets/js/admin/src/components/settings/AuthSettings.tsx b/trailbase-assets/js/admin/src/components/settings/AuthSettings.tsx index 029ef096..da3cd7af 100644 --- a/trailbase-assets/js/admin/src/components/settings/AuthSettings.tsx +++ b/trailbase-assets/js/admin/src/components/settings/AuthSettings.tsx @@ -323,7 +323,7 @@ function AuthSettingsForm(props: {
-

Auth Settings

+

Token Settings

@@ -353,11 +353,21 @@ function AuthSettingsForm(props: { ), })} +
+ + + + +

Password Settings

+
+ + +
{buildOptionalBoolFormField({ label: () => ( -
Disable Password Auth
+
Disable Password Registration
), info: (

@@ -368,10 +378,104 @@ function AuthSettingsForm(props: { ), })} + + + {buildOptionalNumberFormField({ + label: () =>

Min Length
, + info: ( +

+ Minimal length for new passwords [Default 8]. Does not + affect existing registrations unless users choose to + change their password. +

+ ), + })} +
+ + + {buildOptionalBoolFormField({ + label: () => ( +
+ Must Contain Upper {"&"} Lower Case +
+ ), + info: ( +

+ Passwords must contain both, upper and lower case + characters. Does not affect existing registrations unless + users choose to change their password. +

+ ), + })} +
+ + + {buildOptionalBoolFormField({ + label: () => ( +
Must Contain Digits
+ ), + info: ( +

+ Passwords must additionally contain digits. Does not + affect existing registrations unless users choose to + change their password. +

+ ), + })} +
+ + + {buildOptionalBoolFormField({ + label: () => ( +
Must Contain Special
+ ), + info: ( +

+ Passwords must additionally contain special, i.e., + non-alphanumeric characters.. Does not affect existing + registrations unless users choose to change their + password. +

+ ), + })} +
+ + +

OAuth Providers

+
+ + + + {(_field) => { + return ( + + + {(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 ( + + ); + }} + + + ); + }} + + +
+

Public Key

@@ -420,39 +524,6 @@ function AuthSettingsForm(props: {
- - -

OAuth Providers

-
- - - - {(_field) => { - return ( - - - {(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 ( - - ); - }} - - - ); - }} - - -
-
({ diff --git a/trailbase-core/proto/config.proto b/trailbase-core/proto/config.proto index fb268b22..b282161d 100644 --- a/trailbase-core/proto/config.proto +++ b/trailbase-core/proto/config.proto @@ -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 oauth_providers = 11; } diff --git a/trailbase-core/src/auth/options.rs b/trailbase-core/src/auth/options.rs index 2fe9376d..183b7d9e 100644 --- a/trailbase-core/src/auth/options.rs +++ b/trailbase-core/src/auth/options.rs @@ -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(); diff --git a/trailbase-core/src/auth/password.rs b/trailbase-core/src/auth/password.rs index a5ac3816..3dbf322c 100644 --- a/trailbase-core/src/auth/password.rs +++ b/trailbase-core/src/auth/password.rs @@ -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()); } {