Skip to content

Commit

Permalink
feat: add privacy settings (#2072)
Browse files Browse the repository at this point in the history
Add two privacy settings, one that disables account enumeration and one that only returns actual configured login methods of the user.
  • Loading branch information
FreddyDevelop authored Mar 11, 2025
1 parent 7e1ac28 commit 26063d3
Show file tree
Hide file tree
Showing 14 changed files with 88 additions and 31 deletions.
2 changes: 2 additions & 0 deletions backend/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ type Config struct {
Webauthn WebauthnSettings `yaml:"webauthn" json:"webauthn,omitempty" koanf:"webauthn" jsonschema:"title=webauthn"`
// `webhooks` configures HTTP-based callbacks for specific events occurring in the system.
Webhooks WebhookSettings `yaml:"webhooks" json:"webhooks,omitempty" koanf:"webhooks" jsonschema:"title=webhooks"`
// `privacy` configures privacy settings
Privacy Privacy `yaml:"privacy" json:"privacy" koanf:"privacy" jsonschema:"title=privacy"`
}

var (
Expand Down
4 changes: 4 additions & 0 deletions backend/config/config_default.go
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,10 @@ func DefaultConfig() *Config {
Enabled: true,
},
},
Privacy: Privacy{
ShowAccountExistenceHints: false,
OnlyShowActualLoginMethods: false,
},
Debug: false,
}
}
10 changes: 10 additions & 0 deletions backend/config/config_privacy.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package config

type Privacy struct {
// `show_account_existence_hints` determines whether the user should get a user-friendly response rather than a privacy protecting one. E.g. on sign-up, when enabled the user will get "user already exists" response.
// It only has an effect when emails are enabled.
ShowAccountExistenceHints bool `yaml:"show_account_existence_hints" json:"show_account_existence_hints,omitempty" koanf:"show_account_existence_hints" split_words:"true" jsonschema:"default=false"`
// `only_show_actual_login_methods` determines whether the user will only be prompted with his configured login methods.
// It only has an effect when emails are enabled, can be used for authentication and passwords are enabled.
OnlyShowActualLoginMethods bool `yaml:"only_show_actual_login_methods" json:"only_show_actual_login_methods,omitempty" koanf:"only_show_actual_login_methods" split_words:"true" jsonschema:"default=false"`
}
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,25 @@ func (a ContinueWithLoginIdentifier) Execute(c flowpilot.ExecutionContext) error
return err
}

// When privacy setting is off return an error when email address does not exist
if userModel == nil && deps.Cfg.Privacy.ShowAccountExistenceHints {
flowInputError := shared.ErrorUnknownEmail
err = deps.AuditLogger.CreateWithConnection(
deps.Tx,
deps.HttpContext,
models.AuditLogLoginFailure,
nil,
flowInputError,
auditlog.Detail("flow_id", c.GetFlowID()))

if err != nil {
return fmt.Errorf("could not create audit log: %w", err)
}

c.Input().SetError(identifierInputName, flowInputError)
return c.Error(flowpilot.ErrorFormDataInvalid)
}

if err = c.Stash().Set(shared.StashPathEmail, identifierInputValue); err != nil {
return fmt.Errorf("failed to set email to stash: %w", err)
}
Expand Down Expand Up @@ -177,43 +196,37 @@ func (a ContinueWithLoginIdentifier) Execute(c flowpilot.ExecutionContext) error
return c.Error(flowpilot.ErrorFlowDiscontinuity.Wrap(errors.New("user has no email address and passwords are disabled")))
}

if deps.Cfg.Email.UseForAuthentication && deps.Cfg.Password.Enabled {
// Both passcode and password authentication are enabled.
if treatIdentifierAsEmail || (!treatIdentifierAsEmail && userModel != nil && userModel.Emails.GetPrimary() != nil) {
// The user has entered either an email address, or a username for an existing user who has an email address.
if deps.Cfg.Privacy.OnlyShowActualLoginMethods {
switch {
case deps.Cfg.Email.UseForAuthentication && userModel != nil && userModel.Emails.GetPrimary() != nil && deps.Cfg.Password.Enabled && userModel.PasswordCredential != nil:
return c.Continue(shared.StateLoginMethodChooser)
case deps.Cfg.Email.UseForAuthentication && userModel != nil && userModel.Emails.GetPrimary() != nil:
return a.continueToPasscodeConfirmation(c)
case deps.Cfg.Password.Enabled && userModel != nil && userModel.PasswordCredential != nil:
return c.Continue(shared.StateLoginPassword)
}
} else {
if deps.Cfg.Email.UseForAuthentication && deps.Cfg.Password.Enabled {
// Both passcode and password authentication are enabled.
if treatIdentifierAsEmail || (!treatIdentifierAsEmail && userModel != nil && userModel.Emails.GetPrimary() != nil) {
// The user has entered either an email address, or a username for an existing user who has an email address.
return c.Continue(shared.StateLoginMethodChooser)
}

// Either no email was entered or the username does not correspond to an email, passwords are enabled.
return c.Continue(shared.StateLoginPassword)
}

if deps.Cfg.Email.UseForAuthentication {
// Only passcode authentication is enabled; the user must use a passcode.

// Set the login method for audit logging purposes.
if err := c.Stash().Set(shared.StashPathLoginMethod, "passcode"); err != nil {
return fmt.Errorf("failed to set login_method to stash: %w", err)
// Either no email was entered or the username does not correspond to an email, passwords are enabled.
return c.Continue(shared.StateLoginPassword)
}

if c.Stash().Get(shared.StashPathUserID).Exists() {
if err := c.Stash().Set(shared.StashPathPasscodeTemplate, "login"); err != nil {
return fmt.Errorf("failed to set passcode_template to the stash: %w", err)
}
} else {
if err := c.Stash().Set(shared.StashPathPasscodeTemplate, "email_login_attempted"); err != nil {
return fmt.Errorf("failed to set passcode_template to the stash: %w", err)
}
if deps.Cfg.Email.UseForAuthentication {
// Only passcode authentication is enabled; the user must use a passcode.
return a.continueToPasscodeConfirmation(c)
}

return c.Continue(shared.StatePasscodeConfirmation)
}

if deps.Cfg.Password.Enabled {
// Only password authentication is enabled; the user must use a password.
return c.Continue(shared.StateLoginPassword)
if deps.Cfg.Password.Enabled {
// Only password authentication is enabled; the user must use a password.
return c.Continue(shared.StateLoginPassword)
}
}

return c.Error(flowpilot.ErrorFlowDiscontinuity.Wrap(errors.New("no authentication method enabled")))
}

Expand Down Expand Up @@ -251,3 +264,22 @@ func (a ContinueWithLoginIdentifier) analyzeIdentifierInputs(c flowpilot.Executi

return name, value, treatAsEmail
}

func (a ContinueWithLoginIdentifier) continueToPasscodeConfirmation(c flowpilot.ExecutionContext) error {
// Set the login method for audit logging purposes.
if err := c.Stash().Set(shared.StashPathLoginMethod, "passcode"); err != nil {
return fmt.Errorf("failed to set login_method to stash: %w", err)
}

if c.Stash().Get(shared.StashPathUserID).Exists() {
if err := c.Stash().Set(shared.StashPathPasscodeTemplate, "login"); err != nil {
return fmt.Errorf("failed to set passcode_template to the stash: %w", err)
}
} else {
if err := c.Stash().Set(shared.StashPathPasscodeTemplate, "email_login_attempted"); err != nil {
return fmt.Errorf("failed to set passcode_template to the stash: %w", err)
}
}

return c.Continue(shared.StatePasscodeConfirmation)
}
Original file line number Diff line number Diff line change
Expand Up @@ -117,10 +117,10 @@ func (a RegisterLoginIdentifier) Execute(c flowpilot.ExecutionContext) error {
if err != nil {
return err
}
// Do not return an error when only identifier is email and email verification is on (account enumeration protection)
// Do not return an error when only identifier is email and email verification is on (account enumeration protection) and privacy setting is off
if emailModel != nil {
// E-mail address already exists
if !deps.Cfg.Email.RequireVerification {
if !deps.Cfg.Email.RequireVerification || deps.Cfg.Privacy.ShowAccountExistenceHints {
c.Input().SetError("email", shared.ErrorEmailAlreadyExists)
return c.Error(flowpilot.ErrorFormDataInvalid)
} else {
Expand Down
1 change: 1 addition & 0 deletions backend/flow_api/flow/shared/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,6 @@ var (
ErrorEmailAlreadyExists = flowpilot.NewInputError("email_already_exists", "The email address already exists.")
ErrorUsernameAlreadyExists = flowpilot.NewInputError("username_already_exists", "The username already exists.")
ErrorUnknownUsername = flowpilot.NewInputError("unknown_username_error", "The username is unknown.")
ErrorUnknownEmail = flowpilot.NewInputError("unknown_email_error", "The email address is unknown.")
ErrorInvalidUsername = flowpilot.NewInputError("invalid_username_error", "The username is invalid.")
)
1 change: 1 addition & 0 deletions frontend/elements/src/i18n/bn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ export const bn: Translation = {
rate_limit_exceeded:
"অনেকগুলি অনুরোধ করা হয়েছে। অনুরোধকৃত অপারেশন পুনরায় প্রয়াত করতে অপেক্ষা করুন।",
unknown_username_error: "ব্যবহারকারীর নাম অজানা।",
unknown_email_error: "ইমেইল ঠিকানাটি অজানা।",
username_already_exists: "ব্যবহারকারীর নাম ইতিমধ্যে নেওয়া হয়েছে।",
invalid_username_error:
"ব্যবহারকারীর নাম শুধুমাত্র অক্ষর, সংখ্যা এবং আন্ডারস্কোর থাকতে পারে।",
Expand Down
1 change: 1 addition & 0 deletions frontend/elements/src/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,7 @@ export const de: Translation = {
rate_limit_exceeded:
"Zu viele Anfragen wurden gestellt. Bitte warten Sie, um die angeforderte Operation zu wiederholen.",
unknown_username_error: "Der Benutzername ist unbekannt.",
unknown_email_error: "Die Email Adresse ist unbekannt.",
username_already_exists: "Der Benutzername ist bereits vergeben.",
invalid_username_error:
"Der Benutzername darf nur Buchstaben, Zahlen und Unterstriche enthalten.",
Expand Down
1 change: 1 addition & 0 deletions frontend/elements/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ export const en: Translation = {
rate_limit_exceeded:
"Too many requests have been made. Please wait to repeat the requested operation.",
unknown_username_error: "The username is unknown.",
unknown_email_error: "The email address is unknown.",
username_already_exists: "The username is already taken.",
invalid_username_error:
"The username must contain only letters, numbers, and underscores.",
Expand Down
1 change: 1 addition & 0 deletions frontend/elements/src/i18n/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ export const fr: Translation = {
rate_limit_exceeded:
"Trop de demandes ont été effectuées. Veuillez patienter pour répéter l'opération demandée.",
unknown_username_error: "Le nom d'utilisateur est inconnu.",
unknown_email_error: "L'adresse e-mail est inconnue.",
username_already_exists: "Le nom d'utilisateur est déjà pris.",
invalid_username_error:
"Le nom d'utilisateur ne doit contenir que des lettres, des chiffres et des traits de soulignement.",
Expand Down
1 change: 1 addition & 0 deletions frontend/elements/src/i18n/it.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@ export const it: Translation = {
rate_limit_exceeded:
"Troppe richieste sono state effettuate. Si prega di attendere per ripetere l'operazione richiesta.",
unknown_username_error: "Il nome utente è sconosciuto.",
unknown_email_error: "L'indirizzo email è sconosciuto.",
username_already_exists: "Il nome utente è già in uso.",
invalid_username_error:
"Il nome utente deve contenere solo lettere, numeri e trattini bassi.",
Expand Down
1 change: 1 addition & 0 deletions frontend/elements/src/i18n/pt-BR.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,7 @@ export const ptBR: Translation = {
rate_limit_exceeded:
"Foram feitas muitas solicitações. Por favor, aguarde para repetir a operação solicitada.",
unknown_username_error: "O nome de usuário é desconhecido.",
unknown_email_error: "O endereço de e-mail é desconhecido.",
username_already_exists: "O nome de usuário já está em uso.",
invalid_username_error:
"O nome de usuário deve conter apenas letras, números e sublinhados.",
Expand Down
1 change: 1 addition & 0 deletions frontend/elements/src/i18n/translations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ export interface Translation {
passcode_max_attempts_reached: string;
rate_limit_exceeded: string;
unknown_username_error: string;
unknown_email_error: string;
username_already_exists: string;
invalid_username_error: string;
email_already_exists: string;
Expand Down
1 change: 1 addition & 0 deletions frontend/elements/src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ export const zh: Translation = {
"密码输入错误次数太多。请请求一个新的验证码。",
rate_limit_exceeded: "请求过多。请等待重复所请求的操作。",
unknown_username_error: "用户名未知。",
unknown_email_error: "电子邮件地址未知。",
username_already_exists: "用户名已被使用。",
invalid_username_error: "用户名只能包含字母、数字和下划线。",
email_already_exists: "电子邮件已被使用。",
Expand Down

0 comments on commit 26063d3

Please sign in to comment.