diff options
| author | Brynn Crowley <littlehill723@gmail.com> | 2025-03-06 08:24:19 +0000 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-03-06 08:24:19 +0000 |
| commit | f4abcb34b757e40467344ffdd7cec9f77f46a227 (patch) | |
| tree | f3cc73da2ebaa978186f6f470d5bd27b279f6a96 | |
| parent | 5b52a9d4b18b5a07b1edb7403b6dc90b8d5c628d (diff) | |
feat(web): change password (#7676)
Add the ability for users to change their password from their user settings, without requiring them to use the reset password workflow. User's are required to create a elevated session in order to change their password. Users may not change their password to their current password. The user's current password is required for the password change. Users must follow any established password policies. Administrators are able to turn this feature off.
Closes #3548
54 files changed, 1857 insertions, 74 deletions
diff --git a/api/openapi.yml b/api/openapi.yml index eb15d39f3..22d47466d 100644 --- a/api/openapi.yml +++ b/api/openapi.yml @@ -29,6 +29,10 @@ tags: - name: User Information description: User configuration endpoints {{- end }} + {{- if .PasswordChange }} + - name: Password Change + description: Password change endpoint + {{- end }} {{- if (or .TOTP .WebAuthn .Duo) }} - name: Second Factor description: TOTP, WebAuthn and Duo endpoints @@ -562,6 +566,31 @@ paths: $ref: '#/components/schemas/handlers.logoutResponseBody' security: - authelia_auth: [] + {{- if .PasswordChange }} + /api/change-password: + post: + tags: + - Password Change + summary: Password Change + description: > + The password change endpoint validates the user session, validates, and changes the password. + The same session cookie must be used for all steps in this process. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/handlers.PasswordChangeRequestBody' + responses: + "200": + description: Successful Operation + content: + application/json: + schema: + $ref: '#/components/schemas/middlewares.Response.OK' + security: + - authelia_auth: [] + {{- end }} {{- if .PasswordReset }} /api/reset-password/identity/start: post: @@ -2210,6 +2239,12 @@ components: passkey_login: type: boolean description: Value which indicates if Passkey logins are enabled. + password_change_disabled: + type: boolean + description: Value which indicates if users are allowed to change their password from account settings. + password_reset_disabled: + type: boolean + description: Value which indicates if users are allowed to reset their password. handlers.configuration.PasswordPolicyConfigurationBody: type: object properties: @@ -2365,6 +2400,24 @@ components: redirect: type: string example: 'https://home.{{ .Domain | default "example.com" }}' + {{- if .PasswordChange }} + handlers.PasswordChangeRequestBody: + required: + - 'username' + - 'old_password' + - 'new_password' + type: object + properties: + username: + type: string + example: john + old_password: + type: string + example: old_password123 + new_password: + type: string + example: new_password456 + {{- end }} {{- if .PasswordReset }} handlers.PasswordResetStep1RequestBody: required: diff --git a/config.template.yml b/config.template.yml index 4fcc0f509..c705acc0d 100644 --- a/config.template.yml +++ b/config.template.yml @@ -408,7 +408,10 @@ identity_validation: ## ## The available providers are: `file`, `ldap`. You must use only one of these providers. # authentication_backend: - + ## Password Change Options. + # password_change: + ## Disable both the HTML element and the API for password change functionality. + # disable: false ## Password Reset Options. # password_reset: ## Disable both the HTML element and the API for reset password functionality. diff --git a/docs/content/configuration/first-factor/introduction.md b/docs/content/configuration/first-factor/introduction.md index f911531c9..b9dc7730f 100644 --- a/docs/content/configuration/first-factor/introduction.md +++ b/docs/content/configuration/first-factor/introduction.md @@ -34,6 +34,8 @@ authentication_backend: password_reset: disable: false custom_url: '' + password_change: + disable: false ``` ## Options @@ -71,6 +73,15 @@ This setting controls if users can reset their password from the web frontend or The custom password reset URL. This replaces the inbuilt password reset functionality and disables the endpoints if this is configured to anything other than nothing or an empty string. +### password_change + +#### disable + +{{< confkey type="boolean" default="false" required="no" >}} + +This setting controls if users can change their password from the web frontend or not. + + ### file The [file](file.md) authentication provider. diff --git a/docs/content/roadmap/active/dashboard-control-panel-for-users.md b/docs/content/roadmap/active/dashboard-control-panel-for-users.md index d1afe008f..1d6773564 100644 --- a/docs/content/roadmap/active/dashboard-control-panel-for-users.md +++ b/docs/content/roadmap/active/dashboard-control-panel-for-users.md @@ -44,7 +44,7 @@ Users should also be able to view all of their registered devices, and revoke th ### Password Reset -{{< roadmap-status >}} +{{< roadmap-status stage="complete">}} Add a method for users to reset their password given they know their current password. diff --git a/internal/authentication/const.go b/internal/authentication/const.go index 7049e2d92..9cbeb5877 100644 --- a/internal/authentication/const.go +++ b/internal/authentication/const.go @@ -97,6 +97,15 @@ var ( // ErrNoContent is returned when the file is empty. ErrNoContent = errors.New("no file content") + + ErrOperationFailed = errors.New("operation failed") + + // ErrIncorrectPassword is returned when the password provided is incorrect. + ErrIncorrectPassword = errors.New("incorrect password") + + ErrPasswordWeak = errors.New("your supplied password does not meet the password policy requirements") + + ErrAuthenticationFailed = errors.New("authentication failed") ) const fileAuthenticationMode = 0600 diff --git a/internal/authentication/file_user_provider.go b/internal/authentication/file_user_provider.go index 0dd84e085..9be6d8edf 100644 --- a/internal/authentication/file_user_provider.go +++ b/internal/authentication/file_user_provider.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "os" + "strings" "sync" "time" @@ -159,6 +160,58 @@ func (p *FileUserProvider) UpdatePassword(username string, newPassword string) ( return nil } +func (p *FileUserProvider) ChangePassword(username string, oldPassword string, newPassword string) (err error) { + var details FileUserDatabaseUserDetails + + if details, err = p.database.GetUserDetails(username); err != nil { + return fmt.Errorf("%w : %v", ErrUserNotFound, err) + } + + if details.Disabled { + return ErrUserNotFound + } + + if strings.TrimSpace(newPassword) == "" { + return ErrPasswordWeak + } + + if oldPassword == newPassword { + return ErrPasswordWeak + } + + oldPasswordCorrect, err := p.CheckUserPassword(username, oldPassword) + + if err != nil { + return ErrAuthenticationFailed + } + + if !oldPasswordCorrect { + return ErrIncorrectPassword + } + + var digest algorithm.Digest + + if digest, err = p.hash.Hash(newPassword); err != nil { + return fmt.Errorf("%w : %v", ErrOperationFailed, err) + } + + details.Password = schema.NewPasswordDigest(digest) + + p.database.SetUserDetails(details.Username, &details) + + p.mutex.Lock() + + p.setTimeoutReload(time.Now()) + + p.mutex.Unlock() + + if err = p.database.Save(); err != nil { + return fmt.Errorf("%w : %v", ErrOperationFailed, err) + } + + return nil +} + // StartupCheck implements the startup check provider interface. func (p *FileUserProvider) StartupCheck() (err error) { if err = checkDatabase(p.config.Path); err != nil { diff --git a/internal/authentication/ldap_user_provider.go b/internal/authentication/ldap_user_provider.go index 905bf07e8..502dbe502 100644 --- a/internal/authentication/ldap_user_provider.go +++ b/internal/authentication/ldap_user_provider.go @@ -164,7 +164,11 @@ func (p *LDAPUserProvider) GetDetailsExtended(username string) (details *UserDet return nil, err } - defer client.Close() + defer func() { + if err := p.factory.ReleaseClient(client); err != nil { + p.log.WithError(err).Warn("Error occurred releasing the LDAP client") + } + }() if profile, err = p.getUserProfileExtended(client, username); err != nil { return nil, err @@ -299,6 +303,111 @@ func (p *LDAPUserProvider) UpdatePassword(username, password string) (err error) return nil } +// ChangePassword is used to change a user's password but requires their old password to be successfully verified. +// +//nolint:gocyclo +func (p *LDAPUserProvider) ChangePassword(username, oldPassword string, newPassword string) (err error) { + var ( + client ldap.Client + profile *ldapUserProfile + ) + + if client, err = p.factory.GetClient(); err != nil { + return fmt.Errorf("unable to update password for user '%s'. Cause: %w", username, err) + } + + defer func() { + if err := p.factory.ReleaseClient(client); err != nil { + p.log.WithError(err).Warn("Error occurred releasing the LDAP client") + } + }() + + if profile, err = p.getUserProfile(client, username); err != nil { + return fmt.Errorf("unable to update password for user '%s'. Cause: %w", username, err) + } + + var controls []ldap.Control + + switch { + case p.features.ControlTypes.MsftPwdPolHints: + controls = append(controls, &controlMsftServerPolicyHints{ldapOIDControlMsftServerPolicyHints}) + case p.features.ControlTypes.MsftPwdPolHintsDeprecated: + controls = append(controls, &controlMsftServerPolicyHints{ldapOIDControlMsftServerPolicyHintsDeprecated}) + } + + userPasswordOk, err := p.CheckUserPassword(username, oldPassword) + + if err != nil { + errorCode := ldapGetErrorCode(err) + if errorCode == ldap.LDAPResultInvalidCredentials { + return ErrIncorrectPassword + } else { + return err + } + } + + if !userPasswordOk { + return ErrIncorrectPassword + } + + if oldPassword == newPassword { + return ErrPasswordWeak + } + + switch { + case p.features.Extensions.PwdModifyExOp: + pwdModifyRequest := ldap.NewPasswordModifyRequest( + profile.DN, + oldPassword, + newPassword, + ) + + err = p.pwdModify(client, pwdModifyRequest) + case p.config.Implementation == schema.LDAPImplementationActiveDirectory: + modifyRequest := ldap.NewModifyRequest(profile.DN, controls) + // The password needs to be enclosed in quotes + // https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-adts/6e803168-f140-4d23-b2d3-c3a8ab5917d2 + pwdEncoded, err := encodingUTF16LittleEndian.NewEncoder().String(fmt.Sprintf("\"%s\"", newPassword)) + if err != nil { + return fmt.Errorf("failed to encode new password for user '%s'. Cause: %w", username, err) + } + + modifyRequest.Replace(ldapAttributeUnicodePwd, []string{pwdEncoded}) + + //nolint + err = p.modify(client, modifyRequest) + default: + modifyRequest := ldap.NewModifyRequest(profile.DN, controls) + modifyRequest.Replace(ldapAttributeUserPassword, []string{newPassword}) + + err = p.modify(client, modifyRequest) + } + + //TODO: Better inform users regarding password reuse/password history. + if err != nil { + if errorCode := ldapGetErrorCode(err); errorCode != -1 { + switch errorCode { + case ldap.LDAPResultInvalidCredentials, + ldap.LDAPResultInappropriateAuthentication: + return ErrIncorrectPassword + case ldap.LDAPResultConstraintViolation, + ldap.LDAPResultObjectClassViolation, + ldap.ErrorEmptyPassword, + ldap.LDAPResultUnwillingToPerform: + return ErrPasswordWeak + case ldap.LDAPResultInsufficientAccessRights: + return ErrOperationFailed + default: + return ErrOperationFailed + } + } + + return ErrOperationFailed + } + + return nil +} + func (p *LDAPUserProvider) search(client ldap.Client, request *ldap.SearchRequest) (result *ldap.SearchResult, err error) { if result, err = client.Search(request); err != nil { if referral, ok := p.getReferral(err); ok { diff --git a/internal/authentication/ldap_util.go b/internal/authentication/ldap_util.go index 539406372..5793a661b 100644 --- a/internal/authentication/ldap_util.go +++ b/internal/authentication/ldap_util.go @@ -111,6 +111,16 @@ func ldapGetReferral(err error) (referral string, ok bool) { } } +func ldapGetErrorCode(err error) int { + var e *ldap.Error + + if errors.As(err, &e) { + return int(e.ResultCode) + } + + return -1 +} + func getValueFromEntry(entry *ldap.Entry, attribute string) string { if attribute == "" { return "" diff --git a/internal/authentication/user_provider.go b/internal/authentication/user_provider.go index 516345d15..9d56e65cd 100644 --- a/internal/authentication/user_provider.go +++ b/internal/authentication/user_provider.go @@ -4,14 +4,23 @@ import ( "github.com/authelia/authelia/v4/internal/model" ) -// UserProvider is the interface for checking user password and -// gathering user details. +// UserProvider is the interface for interacting with the authentication backends. type UserProvider interface { model.StartupCheck - CheckUserPassword(username, password string) (valid bool, err error) + // CheckUserPassword is used to check if a password matches for a specific user. + CheckUserPassword(username string, password string) (valid bool, err error) + + // GetDetails is used to get a user's information. GetDetails(username string) (details *UserDetails, err error) + GetDetailsExtended(username string) (details *UserDetailsExtended, err error) - UpdatePassword(username, newPassword string) (err error) + + // UpdatePassword is used to change a user's password without verifying their old password. + UpdatePassword(username string, newPassword string) (err error) + + // ChangePassword is used to change a user's password but requires their old password to be successfully verified. + ChangePassword(username string, oldPassword string, newPassword string) (err error) + Shutdown() (err error) } diff --git a/internal/configuration/config.template.yml b/internal/configuration/config.template.yml index 4fcc0f509..c705acc0d 100644 --- a/internal/configuration/config.template.yml +++ b/internal/configuration/config.template.yml @@ -408,7 +408,10 @@ identity_validation: ## ## The available providers are: `file`, `ldap`. You must use only one of these providers. # authentication_backend: - + ## Password Change Options. + # password_change: + ## Disable both the HTML element and the API for password change functionality. + # disable: false ## Password Reset Options. # password_reset: ## Disable both the HTML element and the API for reset password functionality. diff --git a/internal/configuration/schema/authentication.go b/internal/configuration/schema/authentication.go index 4cd6c7055..43305b6b1 100644 --- a/internal/configuration/schema/authentication.go +++ b/internal/configuration/schema/authentication.go @@ -8,7 +8,8 @@ import ( // AuthenticationBackend represents the configuration related to the authentication backend. type AuthenticationBackend struct { - PasswordReset AuthenticationBackendPasswordReset `koanf:"password_reset" json:"password_reset" jsonschema:"title=Password Reset" jsonschema_description:"Allows configuration of the password reset behaviour."` + PasswordReset AuthenticationBackendPasswordReset `koanf:"password_reset" json:"password_reset" jsonschema:"title=Password Reset" jsonschema_description:"Allows configuration of the password reset behaviour."` + PasswordChange AuthenticationBackendPasswordChange `koanf:"password_change" json:"password_change" jsonschema:"title=Password Reset" jsonschema_description:"Allows configuration of the password reset behaviour."` RefreshInterval RefreshIntervalDuration `koanf:"refresh_interval" json:"refresh_interval" jsonschema:"default=5 minutes,title=Refresh Interval" jsonschema_description:"How frequently the user details are refreshed from the backend."` @@ -17,6 +18,11 @@ type AuthenticationBackend struct { LDAP *AuthenticationBackendLDAP `koanf:"ldap" json:"ldap" jsonschema:"title=LDAP Backend" jsonschema_description:"The LDAP authentication backend configuration."` } +// AuthenticationBackendPasswordChange represents the configuration related to password reset functionality. +type AuthenticationBackendPasswordChange struct { + Disable bool `koanf:"disable" json:"disable" jsonschema:"default=false,title=Disable" jsonschema_description:"Disables the Password Change option."` +} + // AuthenticationBackendPasswordReset represents the configuration related to password reset functionality. type AuthenticationBackendPasswordReset struct { Disable bool `koanf:"disable" json:"disable" jsonschema:"default=false,title=Disable" jsonschema_description:"Disables the Password Reset option."` diff --git a/internal/configuration/schema/keys.go b/internal/configuration/schema/keys.go index de65bd4ba..616434dd9 100644 --- a/internal/configuration/schema/keys.go +++ b/internal/configuration/schema/keys.go @@ -113,6 +113,7 @@ var Keys = []string{ "authentication_backend.ldap.users_filter", "authentication_backend.password_reset.custom_url", "authentication_backend.password_reset.disable", + "authentication_backend.password_change.disable", "authentication_backend.refresh_interval", "certificates_directory", "default_2fa_method", diff --git a/internal/handlers/const.go b/internal/handlers/const.go index 20cd8cdee..884095d5f 100644 --- a/internal/handlers/const.go +++ b/internal/handlers/const.go @@ -69,6 +69,9 @@ const ( messageUnableToRegisterSecurityKey = "Unable to register your security key." messageSecurityKeyDuplicateName = "Another one of your security keys is already registered with that display name." messageUnableToResetPassword = "Unable to reset your password." + messageCannotReusePassword = "You cannot reuse your old password." + messageUnableToChangePassword = "Unable to change your password." + messageIncorrectPassword = "Incorrect Password" messageMFAValidationFailed = "Authentication failed, please retry later." messagePasswordWeak = "Your supplied password does not meet the password policy requirements." ) diff --git a/internal/handlers/const_test.go b/internal/handlers/const_test.go index 118cb2ec0..f1ab0d7df 100644 --- a/internal/handlers/const_test.go +++ b/internal/handlers/const_test.go @@ -37,6 +37,7 @@ const ( testRedirectionURLString = "https://www.example.com" testUsername = "john" testDisplayName = "john" + testEmail = "john@example.com" exampleDotCom = "example.com" ) diff --git a/internal/handlers/handler_change_password.go b/internal/handlers/handler_change_password.go new file mode 100644 index 000000000..cbdd25ddc --- /dev/null +++ b/internal/handlers/handler_change_password.go @@ -0,0 +1,105 @@ +package handlers + +import ( + "errors" + "fmt" + "net/http" + + "github.com/authelia/authelia/v4/internal/authentication" + "github.com/authelia/authelia/v4/internal/middlewares" + "github.com/authelia/authelia/v4/internal/session" + "github.com/authelia/authelia/v4/internal/templates" +) + +func ChangePasswordPOST(ctx *middlewares.AutheliaCtx) { + var ( + userSession session.UserSession + err error + ) + + if userSession, err = ctx.GetSession(); err != nil { + ctx.Error(fmt.Errorf("error occurred retrieving session for user: %w", err), messageUnableToChangePassword) + return + } + + username := userSession.Username + + var requestBody changePasswordRequestBody + + if err = ctx.ParseBody(&requestBody); err != nil { + ctx.Error(err, messageUnableToChangePassword) + return + } + + if err = ctx.Providers.PasswordPolicy.Check(requestBody.NewPassword); err != nil { + ctx.Error(err, messagePasswordWeak) + return + } + + if err = ctx.Providers.UserProvider.ChangePassword(username, requestBody.OldPassword, requestBody.NewPassword); err != nil { + ctx.Logger.WithError(err).Debugf("Unable to change password for user '%s'", username) + + switch { + case errors.Is(err, authentication.ErrIncorrectPassword): + ctx.SetJSONError(messageIncorrectPassword) + ctx.SetStatusCode(http.StatusUnauthorized) + case errors.Is(err, authentication.ErrPasswordWeak): + ctx.SetJSONError(messagePasswordWeak) + ctx.SetStatusCode(http.StatusBadRequest) + case errors.Is(err, authentication.ErrAuthenticationFailed): + ctx.SetJSONError(messageOperationFailed) + ctx.SetStatusCode(http.StatusUnauthorized) + default: + ctx.SetJSONError(messageOperationFailed) + ctx.SetStatusCode(http.StatusInternalServerError) + } + + return + } + + ctx.Logger.Debugf("User %s has changed their password", username) + + if err = ctx.SaveSession(userSession); err != nil { + ctx.Error(fmt.Errorf("unable to update password reset state: %w", err), messageOperationFailed) + return + } + + userInfo, err := ctx.Providers.UserProvider.GetDetails(username) + if err != nil { + ctx.Logger.Error(err) + ctx.ReplyOK() + + return + } + + if len(userInfo.Emails) == 0 { + ctx.Logger.Error(fmt.Errorf("user %s has no email address configured", username)) + ctx.ReplyOK() + + return + } + + data := templates.EmailEventValues{ + Title: "Password changed successfully", + DisplayName: userInfo.DisplayName, + RemoteIP: ctx.RemoteIP().String(), + Details: map[string]any{ + "Action": "Password Change", + }, + BodyPrefix: eventEmailActionPasswordModifyPrefix, + BodyEvent: eventEmailActionPasswordChange, + BodySuffix: eventEmailActionPasswordModifySuffix, + } + + addresses := userInfo.Addresses() + + ctx.Logger.Debugf("Sending an email to user %s (%s) to inform that the password has changed.", + username, addresses[0].String()) + + if err = ctx.Providers.Notifier.Send(ctx, addresses[0], "Password changed successfully", ctx.Providers.Templates.GetEventEmailTemplate(), data); err != nil { + ctx.Logger.Error(err) + ctx.ReplyOK() + + return + } +} diff --git a/internal/handlers/handler_change_password_test.go b/internal/handlers/handler_change_password_test.go new file mode 100644 index 000000000..3783fef8e --- /dev/null +++ b/internal/handlers/handler_change_password_test.go @@ -0,0 +1,302 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + "github.com/valyala/fasthttp" + "go.uber.org/mock/gomock" + + "github.com/authelia/authelia/v4/internal/authentication" + + "github.com/authelia/authelia/v4/internal/configuration/schema" + "github.com/authelia/authelia/v4/internal/middlewares" + "github.com/authelia/authelia/v4/internal/mocks" +) + +const ( + testPasswordOld = "old_password123" + testPasswordNew = "new_password456" +) + +type ChangePasswordSuite struct { + suite.Suite + mock *mocks.MockAutheliaCtx +} + +func (s *ChangePasswordSuite) SetupTest() { + s.mock = mocks.NewMockAutheliaCtx(s.T()) + userSession, err := s.mock.Ctx.GetSession() + s.Assert().NoError(err) + + userSession.Username = testUsername + userSession.DisplayName = testUsername + userSession.Emails[0] = testEmail + userSession.AuthenticationMethodRefs.UsernameAndPassword = true + s.Assert().NoError(s.mock.Ctx.SaveSession(userSession)) +} + +func (s *ChangePasswordSuite) TearDownTest() { + s.mock.Close() +} + +func TestChangePasswordPOST_ShouldSucceedWithValidCredentials(t *testing.T) { + mock := mocks.NewMockAutheliaCtx(t) + + userSession, err := mock.Ctx.GetSession() + assert.NoError(t, err) + + userSession.Username = testUsername + + assert.NoError(t, mock.Ctx.SaveSession(userSession)) + + oldPassword := testPasswordOld + newPassword := testPasswordNew + + requestBody := changePasswordRequestBody{ + OldPassword: oldPassword, + NewPassword: newPassword, + } + + bodyBytes, err := json.Marshal(requestBody) + assert.NoError(t, err) + mock.Ctx.Request.SetBody(bodyBytes) + + mock.Ctx.Providers.PasswordPolicy = middlewares.NewPasswordPolicyProvider(schema.PasswordPolicy{}) + + mock.NotifierMock.EXPECT(). + Send(mock.Ctx, gomock.Any(), "Password changed successfully", gomock.Any(), gomock.Any()). + Return(nil) + + mock.UserProviderMock.EXPECT(). + ChangePassword(userSession.Username, oldPassword, newPassword). + Return(nil) + + mock.UserProviderMock.EXPECT(). + GetDetails(testUsername). + Return(&authentication.UserDetails{ + Emails: []string{testEmail}, + }, nil) + + ChangePasswordPOST(mock.Ctx) + + assert.Equal(t, fasthttp.StatusOK, mock.Ctx.Response.StatusCode()) +} + +func TestChangePasswordPOST_ShouldFailWhenPasswordPolicyNotMet(t *testing.T) { + mock := mocks.NewMockAutheliaCtx(t) + + userSession, err := mock.Ctx.GetSession() + assert.NoError(t, err) + + userSession.Username = testUsername + + assert.NoError(t, mock.Ctx.SaveSession(userSession)) + + oldPassword := testPasswordOld + newPassword := "weak" + + requestBody := changePasswordRequestBody{ + OldPassword: oldPassword, + NewPassword: newPassword, + } + + bodyBytes, err := json.Marshal(requestBody) + assert.NoError(t, err) + mock.Ctx.Request.SetBody(bodyBytes) + + passwordPolicy := middlewares.NewPasswordPolicyProvider(schema.PasswordPolicy{ + Standard: schema.PasswordPolicyStandard{ + Enabled: true, + MinLength: 8, + MaxLength: 64, + RequireNumber: true, + RequireSpecial: true, + RequireUppercase: true, + RequireLowercase: true, + }, + }) + + mock.Ctx.Providers.PasswordPolicy = passwordPolicy + + ChangePasswordPOST(mock.Ctx) + + errResponse := mock.GetResponseError(t) + + assert.Equal(t, "KO", errResponse.Status) + assert.Equal(t, "Your supplied password does not meet the password policy requirements.", errResponse.Message) +} + +func TestChangePasswordPOST_ShouldFailWhenRequestBodyIsInvalid(t *testing.T) { + mock := mocks.NewMockAutheliaCtx(t) + + userSession, err := mock.Ctx.GetSession() + assert.NoError(t, err) + + userSession.Username = testUsername + + assert.NoError(t, mock.Ctx.SaveSession(userSession)) + + mock.Ctx.Request.SetBody([]byte(`{invalid json`)) + + ChangePasswordPOST(mock.Ctx) + + errResponse := mock.GetResponseError(t) + assert.Equal(t, "KO", errResponse.Status) + assert.Equal(t, messageUnableToChangePassword, errResponse.Message) +} + +func TestChangePasswordPOST_ShouldFailWhenOldPasswordIsIncorrect(t *testing.T) { + mock := mocks.NewMockAutheliaCtx(t) + + userSession, err := mock.Ctx.GetSession() + assert.NoError(t, err) + + userSession.Username = testUsername + + assert.NoError(t, mock.Ctx.SaveSession(userSession)) + + oldPassword := testPasswordOld + newPassword := testPasswordNew + + requestBody := changePasswordRequestBody{ + OldPassword: oldPassword, + NewPassword: newPassword, + } + + bodyBytes, err := json.Marshal(requestBody) + assert.NoError(t, err) + mock.Ctx.Request.SetBody(bodyBytes) + + mock.Ctx.Providers.PasswordPolicy = middlewares.NewPasswordPolicyProvider(schema.PasswordPolicy{}) + + mock.UserProviderMock.EXPECT(). + ChangePassword(testUsername, oldPassword, newPassword). + Return(authentication.ErrIncorrectPassword) + + ChangePasswordPOST(mock.Ctx) + + errResponse := mock.GetResponseError(t) + assert.Equal(t, "KO", errResponse.Status) + assert.Equal(t, messageIncorrectPassword, errResponse.Message) +} + +func TestChangePasswordPOST_ShouldFailWhenPasswordReuseIsNotAllowed(t *testing.T) { + mock := mocks.NewMockAutheliaCtx(t) + + userSession, err := mock.Ctx.GetSession() + assert.NoError(t, err) + + userSession.Username = testUsername + + assert.NoError(t, mock.Ctx.SaveSession(userSession)) + + oldPassword := testPasswordOld + newPassword := testPasswordOld + + requestBody := changePasswordRequestBody{ + OldPassword: oldPassword, + NewPassword: newPassword, + } + + bodyBytes, err := json.Marshal(requestBody) + assert.NoError(t, err) + mock.Ctx.Request.SetBody(bodyBytes) + + mock.Ctx.Providers.PasswordPolicy = middlewares.NewPasswordPolicyProvider(schema.PasswordPolicy{}) + + mock.UserProviderMock.EXPECT(). + ChangePassword(testUsername, oldPassword, newPassword). + Return(authentication.ErrPasswordWeak) + + ChangePasswordPOST(mock.Ctx) + + errResponse := mock.GetResponseError(t) + assert.Equal(t, "KO", errResponse.Status) + assert.Equal(t, messagePasswordWeak, errResponse.Message) +} + +func TestChangePasswordPOST_ShouldSucceedButLogErrorWhenUserHasNoEmail(t *testing.T) { + mock := mocks.NewMockAutheliaCtx(t) + + userSession, err := mock.Ctx.GetSession() + assert.NoError(t, err) + + userSession.Username = testUsername + + assert.NoError(t, mock.Ctx.SaveSession(userSession)) + + oldPassword := testPasswordOld + newPassword := testPasswordNew + + requestBody := changePasswordRequestBody{ + OldPassword: oldPassword, + NewPassword: newPassword, + } + + bodyBytes, err := json.Marshal(requestBody) + assert.NoError(t, err) + mock.Ctx.Request.SetBody(bodyBytes) + + mock.Ctx.Providers.PasswordPolicy = middlewares.NewPasswordPolicyProvider(schema.PasswordPolicy{}) + + mock.UserProviderMock.EXPECT(). + ChangePassword(testUsername, oldPassword, newPassword). + Return(nil) + + mock.UserProviderMock.EXPECT(). + GetDetails(testUsername). + Return(&authentication.UserDetails{ + Emails: []string{}, + }, nil) + + ChangePasswordPOST(mock.Ctx) + + assert.Equal(t, fasthttp.StatusOK, mock.Ctx.Response.StatusCode()) +} + +func TestChangePasswordPOST_ShouldSucceedButLogErrorWhenNotificationFails(t *testing.T) { + mock := mocks.NewMockAutheliaCtx(t) + + userSession, err := mock.Ctx.GetSession() + assert.NoError(t, err) + + userSession.Username = testUsername + + assert.NoError(t, mock.Ctx.SaveSession(userSession)) + + oldPassword := testPasswordOld + newPassword := testPasswordNew + + requestBody := changePasswordRequestBody{ + OldPassword: oldPassword, + NewPassword: newPassword, + } + + bodyBytes, err := json.Marshal(requestBody) + assert.NoError(t, err) + mock.Ctx.Request.SetBody(bodyBytes) + + mock.Ctx.Providers.PasswordPolicy = middlewares.NewPasswordPolicyProvider(schema.PasswordPolicy{}) + + mock.UserProviderMock.EXPECT(). + ChangePassword(testUsername, testPasswordOld, newPassword). + Return(nil) + + mock.UserProviderMock.EXPECT(). + GetDetails(testUsername). + Return(&authentication.UserDetails{ + Emails: []string{testEmail}, + }, nil) + + mock.NotifierMock.EXPECT(). + Send(mock.Ctx, gomock.Any(), "Password changed successfully", gomock.Any(), gomock.Any()). + Return(fmt.Errorf("failed to send notification")) + + ChangePasswordPOST(mock.Ctx) + + assert.Equal(t, fasthttp.StatusOK, mock.Ctx.Response.StatusCode()) +} diff --git a/internal/handlers/handler_configuration.go b/internal/handlers/handler_configuration.go index fec29c4ad..87b9ce2a7 100644 --- a/internal/handlers/handler_configuration.go +++ b/internal/handlers/handler_configuration.go @@ -7,14 +7,24 @@ import ( // ConfigurationGET get the configuration accessible to authenticated users. func ConfigurationGET(ctx *middlewares.AutheliaCtx) { body := configurationBody{ - AvailableMethods: make(MethodList, 0, 3), + AvailableMethods: make(MethodList, 0, 3), + PasswordChangeDisabled: false, + PasswordResetDisabled: false, } if ctx.Providers.Authorizer.IsSecondFactorEnabled() { body.AvailableMethods = ctx.AvailableSecondFactorMethods() } - ctx.Logger.Tracef("Available methods are %s", body.AvailableMethods) + body.PasswordChangeDisabled = ctx.Configuration.AuthenticationBackend.PasswordChange.Disable + body.PasswordResetDisabled = ctx.Configuration.AuthenticationBackend.PasswordReset.Disable + + ctx.Logger.WithFields( + map[string]any{ + "available_methods": body.AvailableMethods, + "password_change_disabled": body.PasswordChangeDisabled, + "password_reset_disabled": body.PasswordResetDisabled, + }).Trace("Authelia configuration requested") if err := ctx.SetJSONBody(body); err != nil { ctx.Logger.Errorf("Unable to set configuration response in body: %s", err) diff --git a/internal/handlers/handler_configuration_test.go b/internal/handlers/handler_configuration_test.go index 12dec9465..2c8db42cc 100644 --- a/internal/handlers/handler_configuration_test.go +++ b/internal/handlers/handler_configuration_test.go @@ -10,12 +10,12 @@ import ( "github.com/authelia/authelia/v4/internal/mocks" ) -type SecondFactorAvailableMethodsFixture struct { +type ConfigurationHandlerFixture struct { suite.Suite mock *mocks.MockAutheliaCtx } -func (s *SecondFactorAvailableMethodsFixture) SetupTest() { +func (s *ConfigurationHandlerFixture) SetupTest() { s.mock = mocks.NewMockAutheliaCtx(s.T()) s.mock.Ctx.Providers.Authorizer = authorization.NewAuthorizer(&schema.Configuration{ AccessControl: schema.AccessControl{ @@ -24,11 +24,11 @@ func (s *SecondFactorAvailableMethodsFixture) SetupTest() { }}) } -func (s *SecondFactorAvailableMethodsFixture) TearDownTest() { +func (s *ConfigurationHandlerFixture) TearDownTest() { s.mock.Close() } -func (s *SecondFactorAvailableMethodsFixture) TestShouldHaveAllConfiguredMethods() { +func (s *ConfigurationHandlerFixture) TestShouldHaveAllConfiguredMethods() { s.mock.Ctx.Configuration = schema.Configuration{ DuoAPI: schema.DuoAPI{ Disable: false, @@ -58,7 +58,7 @@ func (s *SecondFactorAvailableMethodsFixture) TestShouldHaveAllConfiguredMethods }) } -func (s *SecondFactorAvailableMethodsFixture) TestShouldRemoveTOTPFromAvailableMethodsWhenDisabled() { +func (s *ConfigurationHandlerFixture) TestShouldRemoveTOTPFromAvailableMethodsWhenDisabled() { s.mock.Ctx.Configuration = schema.Configuration{ DuoAPI: schema.DuoAPI{ Disable: false, @@ -88,7 +88,7 @@ func (s *SecondFactorAvailableMethodsFixture) TestShouldRemoveTOTPFromAvailableM }) } -func (s *SecondFactorAvailableMethodsFixture) TestShouldRemoveWebAuthnFromAvailableMethodsWhenDisabled() { +func (s *ConfigurationHandlerFixture) TestShouldRemoveWebAuthnFromAvailableMethodsWhenDisabled() { s.mock.Ctx.Configuration = schema.Configuration{ DuoAPI: schema.DuoAPI{ Disable: false, @@ -118,7 +118,7 @@ func (s *SecondFactorAvailableMethodsFixture) TestShouldRemoveWebAuthnFromAvaila }) } -func (s *SecondFactorAvailableMethodsFixture) TestShouldRemoveDuoFromAvailableMethodsWhenNotConfigured() { +func (s *ConfigurationHandlerFixture) TestShouldRemoveDuoFromAvailableMethodsWhenNotConfigured() { s.mock.Ctx.Configuration = schema.Configuration{ DuoAPI: schema.DuoAPI{ Disable: true, @@ -148,7 +148,7 @@ func (s *SecondFactorAvailableMethodsFixture) TestShouldRemoveDuoFromAvailableMe }) } -func (s *SecondFactorAvailableMethodsFixture) TestShouldRemoveAllMethodsWhenNoTwoFactorACLRulesConfigured() { +func (s *ConfigurationHandlerFixture) TestShouldRemoveAllMethodsWhenNoTwoFactorACLRulesConfigured() { s.mock.Ctx.Configuration = schema.Configuration{ DuoAPI: schema.DuoAPI{ Disable: false, @@ -178,7 +178,7 @@ func (s *SecondFactorAvailableMethodsFixture) TestShouldRemoveAllMethodsWhenNoTw }) } -func (s *SecondFactorAvailableMethodsFixture) TestShouldRemoveAllMethodsWhenAllDisabledOrNotConfigured() { +func (s *ConfigurationHandlerFixture) TestShouldRemoveAllMethodsWhenAllDisabledOrNotConfigured() { s.mock.Ctx.Configuration = schema.Configuration{ DuoAPI: schema.DuoAPI{ Disable: true, @@ -208,7 +208,71 @@ func (s *SecondFactorAvailableMethodsFixture) TestShouldRemoveAllMethodsWhenAllD }) } +func (s *ConfigurationHandlerFixture) TestDisablePasswordResetChangeOptions() { + testCases := []struct { + name string + passwordChangeDisable bool + passwordResetDisable bool + }{ + { + name: "BothEnabled", + passwordChangeDisable: false, + passwordResetDisable: false, + }, + { + name: "PasswordChangeDisabled", + passwordChangeDisable: true, + passwordResetDisable: false, + }, + { + name: "PasswordResetDisabled", + passwordChangeDisable: false, + passwordResetDisable: true, + }, + { + name: "BothDisabled", + passwordChangeDisable: true, + passwordResetDisable: true, + }, + } + + for _, tc := range testCases { + s.T().Run(tc.name, func(t *testing.T) { + mock := mocks.NewMockAutheliaCtx(t) + defer mock.Close() + + mock.Ctx.Configuration = schema.Configuration{ + AuthenticationBackend: schema.AuthenticationBackend{ + PasswordChange: schema.AuthenticationBackendPasswordChange{ + Disable: tc.passwordChangeDisable, + }, + PasswordReset: schema.AuthenticationBackendPasswordReset{ + Disable: tc.passwordResetDisable, + }, + }, + DuoAPI: schema.DuoAPI{ + Disable: true, + }, + TOTP: schema.TOTP{ + Disable: true, + }, + WebAuthn: schema.WebAuthn{ + Disable: true, + }, + } + + ConfigurationGET(mock.Ctx) + + mock.Assert200OK(s.T(), configurationBody{ + AvailableMethods: []string{}, + PasswordChangeDisabled: tc.passwordChangeDisable, + PasswordResetDisabled: tc.passwordResetDisable, + }) + }) + } +} + func TestRunSuite(t *testing.T) { - s := new(SecondFactorAvailableMethodsFixture) + s := new(ConfigurationHandlerFixture) suite.Run(t, s) } diff --git a/internal/handlers/handler_reset_password.go b/internal/handlers/handler_reset_password.go index 2e9b3da3e..598e4bdb9 100644 --- a/internal/handlers/handler_reset_password.go +++ b/internal/handlers/handler_reset_password.go @@ -198,9 +198,9 @@ func ResetPasswordPOST(ctx *middlewares.AutheliaCtx) { Details: map[string]any{ "Action": "Password Reset", }, - BodyPrefix: eventEmailActionPasswordResetPrefix, + BodyPrefix: eventEmailActionPasswordModifyPrefix, BodyEvent: eventEmailActionPasswordReset, - BodySuffix: eventEmailActionPasswordResetSuffix, + BodySuffix: eventEmailActionPasswordModifySuffix, } addresses := userInfo.Addresses() @@ -253,7 +253,7 @@ var ResetPasswordIdentityStart = middlewares.IdentityVerificationStart(middlewar IdentityRetrieverFunc: identityRetrieverFromStorage, }, middlewares.TimingAttackDelay(10, 250, 85, time.Millisecond*500, false)) -func resetPasswordIdentityFinish(ctx *middlewares.AutheliaCtx, username string) { +func resetPasswordIdentityVerificationFinish(ctx *middlewares.AutheliaCtx, username string) { var ( userSession session.UserSession err error @@ -276,4 +276,4 @@ func resetPasswordIdentityFinish(ctx *middlewares.AutheliaCtx, username string) // ResetPasswordIdentityFinish the handler for finishing the identity validation. var ResetPasswordIdentityFinish = middlewares.IdentityVerificationFinish( - middlewares.IdentityVerificationFinishArgs{ActionClaim: ActionResetPassword}, resetPasswordIdentityFinish) + middlewares.IdentityVerificationFinishArgs{ActionClaim: ActionResetPassword}, resetPasswordIdentityVerificationFinish) diff --git a/internal/handlers/handler_user_info.go b/internal/handlers/handler_user_info.go index 64fb67107..5de2c9c7d 100644 --- a/internal/handlers/handler_user_info.go +++ b/internal/handlers/handler_user_info.go @@ -101,6 +101,11 @@ func UserInfoGET(ctx *middlewares.AutheliaCtx) { userInfo.DisplayName = userSession.DisplayName + // it should be noted that UserInfo only contains info from the database and NOT any info from the authn_backend (email/groups). + for _, email := range userSession.Emails { + userInfo.Emails = append(userInfo.Emails, redactEmail(email)) + } + err = ctx.SetJSONBody(userInfo) if err != nil { ctx.Logger.Errorf("Unable to set user info response in body: %+v", err) diff --git a/internal/handlers/types.go b/internal/handlers/types.go index 6fd904c47..7efba826d 100644 --- a/internal/handlers/types.go +++ b/internal/handlers/types.go @@ -20,7 +20,9 @@ type MethodList = []string // configurationBody the content returned by the configuration endpoint. type configurationBody struct { - AvailableMethods MethodList `json:"available_methods"` + AvailableMethods MethodList `json:"available_methods"` + PasswordChangeDisabled bool `json:"password_change_disabled"` + PasswordResetDisabled bool `json:"password_reset_disabled"` } // bodySignTOTPRequest is the model of the request body of TOTP 2FA authentication endpoint. @@ -204,6 +206,13 @@ type bodyRequestPasswordResetDELETE struct { Token string `json:"token"` } +// changePasswordRequestBody model of the change password request body. +type changePasswordRequestBody struct { + Username string `json:"username"` + OldPassword string `json:"old_password"` + NewPassword string `json:"new_password"` +} + // PasswordPolicyBody represents the response sent by the password reset step 2. type PasswordPolicyBody struct { Mode string `json:"mode"` diff --git a/internal/handlers/util.go b/internal/handlers/util.go index 9a137f6a1..92c7e2d28 100644 --- a/internal/handlers/util.go +++ b/internal/handlers/util.go @@ -2,6 +2,7 @@ package handlers import ( "fmt" + "strings" "github.com/authelia/authelia/v4/internal/authentication" "github.com/authelia/authelia/v4/internal/middlewares" @@ -21,9 +22,10 @@ const ( eventEmailAction2FAAddedSuffix = "was added to your account." eventEmailAction2FARemovedSuffix = "was removed from your account." - eventEmailActionPasswordResetPrefix = "your" - eventEmailActionPasswordReset = "Password Reset" - eventEmailActionPasswordResetSuffix = "was successful." + eventEmailActionPasswordModifyPrefix = "your" + eventEmailActionPasswordReset = "Password Reset" + eventEmailActionPasswordChange = "Password Change" + eventEmailActionPasswordModifySuffix = "was successful." eventLogCategoryOneTimePassword = "One-Time Password" eventLogCategoryWebAuthnCredential = "WebAuthn Credential" //nolint:gosec @@ -75,3 +77,23 @@ func ctxLogEvent(ctx *middlewares.AutheliaCtx, username, description string, bod return } } + +func redactEmail(email string) string { + parts := strings.Split(email, "@") + if len(parts) != 2 { + return "" + } + + localPart := parts[0] + domain := parts[1] + + if len(localPart) <= 2 { + return strings.Repeat("*", len(localPart)) + "@" + domain + } + + first := string(localPart[0]) + last := string(localPart[len(localPart)-1]) + middle := strings.Repeat("*", len(localPart)-2) + + return first + middle + last + "@" + domain +} diff --git a/internal/handlers/util_test.go b/internal/handlers/util_test.go new file mode 100644 index 000000000..f5bf7f7ab --- /dev/null +++ b/internal/handlers/util_test.go @@ -0,0 +1,24 @@ +package handlers + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestRedactEmail(t *testing.T) { + testCases := []struct { + testName string + input string + expected string + }{ + {"ShouldRedactEmail", "james.dean@authelia.com", "j********n@authelia.com"}, + {"ShouldRedactShortEmail", "me@authelia.com", "**@authelia.com"}, + {"ShouldRedactInvalidEmail", "invalidEmail.com", ""}, + } + for _, tc := range testCases { + t.Run(tc.testName, func(t *testing.T) { + require.Equal(t, tc.expected, redactEmail(tc.input)) + }) + } +} diff --git a/internal/mocks/user_provider.go b/internal/mocks/user_provider.go index b235d80b7..204940221 100644 --- a/internal/mocks/user_provider.go +++ b/internal/mocks/user_provider.go @@ -40,6 +40,20 @@ func (m *MockUserProvider) EXPECT() *MockUserProviderMockRecorder { return m.recorder } +// ChangePassword mocks base method. +func (m *MockUserProvider) ChangePassword(arg0, arg1, arg2 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ChangePassword", arg0, arg1, arg2) + ret0, _ := ret[0].(error) + return ret0 +} + +// ChangePassword indicates an expected call of ChangePassword. +func (mr *MockUserProviderMockRecorder) ChangePassword(arg0, arg1, arg2 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ChangePassword", reflect.TypeOf((*MockUserProvider)(nil).ChangePassword), arg0, arg1, arg2) +} + // CheckUserPassword mocks base method. func (m *MockUserProvider) CheckUserPassword(username, password string) (bool, error) { m.ctrl.T.Helper() diff --git a/internal/model/user_info.go b/internal/model/user_info.go index 9f897dd33..a226033eb 100644 --- a/internal/model/user_info.go +++ b/internal/model/user_info.go @@ -9,6 +9,9 @@ type UserInfo struct { // The users display name. DisplayName string `db:"-" json:"display_name"` + // The users email address. + Emails []string `db:"-" json:"emails"` + // The preferred 2FA method. Method string `db:"second_factor_method" json:"method" valid:"required"` diff --git a/internal/server/handlers.go b/internal/server/handlers.go index db0a4e20e..d633f503e 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -268,6 +268,10 @@ func handleRouter(config *schema.Configuration, providers middlewares.Providers) r.DELETE("/api/reset-password", middlewareAPI(resetPasswordTokenRL(handlers.ResetPasswordDELETE))) } + if !config.AuthenticationBackend.PasswordChange.Disable { + r.POST("/api/change-password", middlewareElevated1FA(handlers.ChangePasswordPOST)) + } + // Information about the user. r.GET("/api/user/info", middleware1FA(handlers.UserInfoGET)) r.POST("/api/user/info", middleware1FA(handlers.UserInfoPOST)) diff --git a/internal/server/locales/en/settings.json b/internal/server/locales/en/settings.json index 6cd0c70cc..6d9317db8 100644 --- a/internal/server/locales/en/settings.json +++ b/internal/server/locales/en/settings.json @@ -18,6 +18,7 @@ "Backed Up": "Backed Up", "Backup State": "Backup State", "Cancel": "Cancel", + "Caps Lock is on": "Caps Lock is on", "Click to add a {{item}} to your account": "Click to add a {{item}} to your account", "Click to copy the {{value}}": "Click to copy the {{value}}", "Click to Copy": "Click to Copy", @@ -37,6 +38,7 @@ "Digits": "Digits", "Discoverable": "Discoverable", "Display extended information for this WebAuthn Credential": "Display extended information for this WebAuthn Credential", + "Email": "Email", "Display extended information for this One-Time Password": "Display extended information for this One-Time Password", "Edit this {{item}}": "Edit this {{item}}", "Eligible": "Eligible", @@ -51,6 +53,7 @@ "Failed to register your credential, the identity verification process might have timed out": "Failed to register your credential, the identity verification process might have timed out", "global configuration": "global configuration", "Identity Verification": "Identity Verification", + "Incorrect password": "Incorrect password", "In order to perform this action policy enforcement requires additional identity verification and a One-Time Code has been sent to your email": "In order to perform this action policy enforcement requires additional identity verification and a One-Time Code has been sent to your email", "Issuer": "Issuer", "Last Used when": "Last Used {{when, datetime}}", @@ -70,6 +73,9 @@ "Options": "Options", "Overview": "Overview", "Period": "Period", + "Password": "Password", + "Passwords do not match": "Passwords do not match", + "Password changed successfully": "Password changed successfully", "Previous": "Previous", "Public Key": "Public Key", "QR Code": "QR Code", @@ -79,10 +85,12 @@ "Remove {{item}}": "Remove {{item}}", "Remove this {{item}}": "Remove this {{item}}", "Remove": "Remove", + "Repeat New Password": "Repeat New Password", "Seconds": "Seconds", "Secret": "Secret", "Settings": "Settings", "Start": "Start", + "Submit": "Submit", "Successfully {{action}} the {{item}}": "Successfully {{action}} the {{item}}", "The attestation challenge was rejected as malformed or incompatible by your browser": "The attestation challenge was rejected as malformed or incompatible by your browser", "The Description must be more than 1 character and less than 64 characters": "The Description must be more than 1 character and less than 64 characters", @@ -93,10 +101,12 @@ "There are no protected applications that require a second factor method": "There are no protected applications that require a second factor method", "There is an issue with this Credential to find out more click to display extended information for this WebAuthn Credential": "There is an issue with this Credential to find out more click to display extended information for this WebAuthn Credential", "There was a problem {{action}} the {{item}}": "There was a problem {{action}} the {{item}}", + "There was an issue changing the {{item}}": "There was an issue changing the {{item}}", "There was an issue retrieving the {{item}}": "There was an issue retrieving the {{item}}", "There was an issue updating preferred second factor method": "There was an issue updating preferred second factor method", "This dialog handles registration of a {{item}}": "This dialog handles registration of a {{item}}", "This is a legacy WebAuthn Credential if it's not operating normally you may need to delete it and register it again": "This is a legacy WebAuthn Credential if it's not operating normally you may need to delete it and register it again", + "This is disabled by your administrator": "This is disabled by your administrator", "This is the user settings area at the present time it's very minimal but will include new features in the near future": "This is the user settings area at the present time it's very minimal but will include new features in the near future", "To begin select next": "To begin select next", "To view the currently available options select the menu icon at the top left": "To view the currently available options select the menu icon at the top left", @@ -110,6 +120,7 @@ "updating": "updating", "URI": "URI", "Usage Count": "Usage Count", + "Username": "Username", "user preferences": "user preferences", "User Settings": "User Settings", "User Verified": "User Verified", @@ -120,6 +131,7 @@ "WebAuthn Credentials": "WebAuthn Credentials", "Yes": "Yes", "You cancelled the attestation request": "You cancelled the attestation request", + "You cannot reuse your old password": "You cannot reuse your old password", "You have registered this device already": "You have registered this device already", "You must be elevated to {{action}} a {{item}}": "You must be elevated to {{action}} a {{item}}", "You must have a higher authentication level to {{action}} a {{item}}": "You must have a higher authentication level to {{action}} a {{item}}", @@ -127,5 +139,6 @@ "You must use the code from the same device and browser that initiated the process": "You must use the code from the same device and browser that initiated the process", "Your browser does not appear to support the configuration": "Your browser does not appear to support the configuration", "Your browser does not support the WebAuthn protocol": "Your browser does not support the WebAuthn protocol", + "Your supplied password does not meet the password policy requirements": "Your supplied password does not meet the password policy requirements", "Your device does not support user verification or resident keys but this was required": "Your device does not support user verification or resident keys but this was required" } diff --git a/internal/server/template.go b/internal/server/template.go index 175643edf..03accd479 100644 --- a/internal/server/template.go +++ b/internal/server/template.go @@ -251,23 +251,25 @@ func writeHealthCheckEnv(disabled bool, scheme, host, path string, port uint16) // NewTemplatedFileOptions returns a new *TemplatedFileOptions. func NewTemplatedFileOptions(config *schema.Configuration) (opts *TemplatedFileOptions) { opts = &TemplatedFileOptions{ - AssetPath: config.Server.AssetPath, - DuoSelfEnrollment: strFalse, - PasskeyLogin: strconv.FormatBool(config.WebAuthn.EnablePasskeyLogin), - RememberMe: strconv.FormatBool(!config.Session.DisableRememberMe), - ResetPassword: strconv.FormatBool(!config.AuthenticationBackend.PasswordReset.Disable), - ResetPasswordCustomURL: config.AuthenticationBackend.PasswordReset.CustomURL.String(), - PrivacyPolicyURL: "", - PrivacyPolicyAccept: strFalse, - Theme: config.Theme, - - EndpointsPasswordReset: !(config.AuthenticationBackend.PasswordReset.Disable || config.AuthenticationBackend.PasswordReset.CustomURL.String() != ""), - EndpointsWebAuthn: !config.WebAuthn.Disable, - EndpointsPasskeys: !config.WebAuthn.Disable && config.WebAuthn.EnablePasskeyLogin, - EndpointsTOTP: !config.TOTP.Disable, - EndpointsDuo: !config.DuoAPI.Disable, - EndpointsOpenIDConnect: !(config.IdentityProviders.OIDC == nil), - EndpointsAuthz: config.Server.Endpoints.Authz, + AssetPath: config.Server.AssetPath, + DuoSelfEnrollment: strFalse, + PasskeyLogin: strconv.FormatBool(config.WebAuthn.EnablePasskeyLogin), + RememberMe: strconv.FormatBool(!config.Session.DisableRememberMe), + ResetPassword: strconv.FormatBool(!config.AuthenticationBackend.PasswordReset.Disable), + ResetPasswordCustomURL: config.AuthenticationBackend.PasswordReset.CustomURL.String(), + PasswordChange: strconv.FormatBool(!config.AuthenticationBackend.PasswordChange.Disable), + PrivacyPolicyURL: "", + PrivacyPolicyAccept: strFalse, + Session: "", + Theme: config.Theme, + EndpointsPasswordReset: !(config.AuthenticationBackend.PasswordReset.Disable || config.AuthenticationBackend.PasswordReset.CustomURL.String() != ""), + EndpointsPasswordChange: !config.AuthenticationBackend.PasswordChange.Disable, + EndpointsWebAuthn: !config.WebAuthn.Disable, + EndpointsPasskeys: !config.WebAuthn.Disable && config.WebAuthn.EnablePasskeyLogin, + EndpointsTOTP: !config.TOTP.Disable, + EndpointsDuo: !config.DuoAPI.Disable, + EndpointsOpenIDConnect: !(config.IdentityProviders.OIDC == nil), + EndpointsAuthz: config.Server.Endpoints.Authz, } if config.PrivacyPolicy.Enabled { @@ -290,17 +292,19 @@ type TemplatedFileOptions struct { RememberMe string ResetPassword string ResetPasswordCustomURL string + PasswordChange string PrivacyPolicyURL string PrivacyPolicyAccept string Session string Theme string - EndpointsPasswordReset bool - EndpointsWebAuthn bool - EndpointsPasskeys bool - EndpointsTOTP bool - EndpointsDuo bool - EndpointsOpenIDConnect bool + EndpointsPasswordReset bool + EndpointsPasswordChange bool + EndpointsWebAuthn bool + EndpointsPasskeys bool + EndpointsTOTP bool + EndpointsDuo bool + EndpointsOpenIDConnect bool EndpointsAuthz map[string]schema.ServerEndpointsAuthz } @@ -352,13 +356,13 @@ func (options *TemplatedFileOptions) commonDataWithRememberMe(base, baseURL, dom // OpenAPIData returns a TemplatedFileOpenAPIData with the dynamic options. func (options *TemplatedFileOptions) OpenAPIData(base, baseURL, domain, nonce string) TemplatedFileOpenAPIData { return TemplatedFileOpenAPIData{ - Base: base, - BaseURL: baseURL, - Domain: domain, - CSPNonce: nonce, - + Base: base, + BaseURL: baseURL, + Domain: domain, + CSPNonce: nonce, Session: options.Session, PasswordReset: options.EndpointsPasswordReset, + PasswordChange: options.EndpointsPasswordChange, WebAuthn: options.EndpointsWebAuthn, Passkeys: options.EndpointsPasskeys, TOTP: options.EndpointsTOTP, @@ -388,17 +392,18 @@ type TemplatedFileCommonData struct { // TemplatedFileOpenAPIData is a struct which is used for the OpenAPI spec file. type TemplatedFileOpenAPIData struct { - Base string - BaseURL string - Domain string - CSPNonce string - Session string - PasswordReset bool - WebAuthn bool - Passkeys bool - TOTP bool - Duo bool - OpenIDConnect bool + Base string + BaseURL string + Domain string + CSPNonce string + Session string + PasswordReset bool + PasswordChange bool + WebAuthn bool + Passkeys bool + TOTP bool + Duo bool + OpenIDConnect bool EndpointsAuthz map[string]schema.ServerEndpointsAuthz } diff --git a/internal/suites/Caddy/configuration.yml b/internal/suites/Caddy/configuration.yml index 5fa665911..1567e8ec3 100644 --- a/internal/suites/Caddy/configuration.yml +++ b/internal/suites/Caddy/configuration.yml @@ -24,6 +24,14 @@ server: buckets: - period: '2 minutes' requests: 20 + session_elevation_start: + buckets: + - period: '2 minutes' + requests: 20 + session_elevation_finish: + buckets: + - period: '2 minutes' + requests: 20 log: level: 'debug' diff --git a/internal/suites/Envoy/configuration.yml b/internal/suites/Envoy/configuration.yml index c5f1174f3..1d26d8920 100644 --- a/internal/suites/Envoy/configuration.yml +++ b/internal/suites/Envoy/configuration.yml @@ -20,6 +20,14 @@ server: buckets: - period: '2 minutes' requests: 20 + session_elevation_start: + buckets: + - period: '2 minutes' + requests: 20 + session_elevation_finish: + buckets: + - period: '2 minutes' + requests: 20 log: level: 'debug' diff --git a/internal/suites/LDAP/configuration.yml b/internal/suites/LDAP/configuration.yml index dd13ff94b..900ebfd2f 100644 --- a/internal/suites/LDAP/configuration.yml +++ b/internal/suites/LDAP/configuration.yml @@ -17,6 +17,14 @@ server: buckets: - period: '2 minutes' requests: 20 + session_elevation_start: + buckets: + - period: '2 minutes' + requests: 20 + session_elevation_finish: + buckets: + - period: '2 minutes' + requests: 20 log: level: 'debug' diff --git a/internal/suites/PathPrefix/configuration.yml b/internal/suites/PathPrefix/configuration.yml index 30fdf7bc8..b5f38b6c6 100644 --- a/internal/suites/PathPrefix/configuration.yml +++ b/internal/suites/PathPrefix/configuration.yml @@ -16,6 +16,14 @@ server: buckets: - period: '2 minutes' requests: 20 + session_elevation_start: + buckets: + - period: '2 minutes' + requests: 20 + session_elevation_finish: + buckets: + - period: '2 minutes' + requests: 20 log: level: 'debug' diff --git a/internal/suites/action_change_password.go b/internal/suites/action_change_password.go new file mode 100644 index 000000000..37b525b40 --- /dev/null +++ b/internal/suites/action_change_password.go @@ -0,0 +1,82 @@ +package suites + +import ( + "testing" + + "github.com/go-rod/rod" + "github.com/stretchr/testify/require" +) + +func (rs *RodSession) doChangePassword(t *testing.T, page *rod.Page, oldPassword, newPassword1, newPassword2 string) { + t.Helper() + + require.NoError(t, rs.WaitElementLocatedByID(t, page, "change-password-button").Click("left", 1)) + + rs.doMaybeVerifyIdentity(t, page) + + oldPasswordInput := rs.WaitElementLocatedByID(t, page, "old-password") + newPasswordInput := rs.WaitElementLocatedByID(t, page, "new-password") + repeatNewPasswordInput := rs.WaitElementLocatedByID(t, page, "repeat-new-password") + + require.NoError(t, oldPasswordInput.Type(rs.toInputs(oldPassword)...)) + require.NoError(t, newPasswordInput.Type(rs.toInputs(newPassword1)...)) + require.NoError(t, repeatNewPasswordInput.Type(rs.toInputs(newPassword2)...)) + + require.NoError(t, rs.WaitElementLocatedByID(t, page, "password-change-dialog-submit").Click("left", 1)) + rs.verifyNotificationDisplayed(t, page, "Password changed successfully") +} + +func (rs *RodSession) doMustChangePasswordExistingPassword(t *testing.T, page *rod.Page, password string) { + require.NoError(t, rs.WaitElementLocatedByID(t, page, "change-password-button").Click("left", 1)) + + rs.doMaybeVerifyIdentity(t, page) + t.Helper() + + oldPasswordInput := rs.WaitElementLocatedByID(t, page, "old-password") + newPasswordInput := rs.WaitElementLocatedByID(t, page, "new-password") + repeatNewPasswordInput := rs.WaitElementLocatedByID(t, page, "repeat-new-password") + + require.NoError(t, oldPasswordInput.Type(rs.toInputs(password)...)) + require.NoError(t, newPasswordInput.Type(rs.toInputs(password)...)) + require.NoError(t, repeatNewPasswordInput.Type(rs.toInputs(password)...)) + + require.NoError(t, rs.WaitElementLocatedByID(t, page, "password-change-dialog-submit").Click("left", 1)) + rs.verifyNotificationDisplayed(t, page, "Your supplied password does not meet the password policy requirements") +} + +func (rs *RodSession) doMustChangePasswordWrongExistingPassword(t *testing.T, page *rod.Page, oldPassword, newPassword1 string) { + t.Helper() + + require.NoError(t, rs.WaitElementLocatedByID(t, page, "change-password-button").Click("left", 1)) + + rs.doMaybeVerifyIdentity(t, page) + + oldPasswordInput := rs.WaitElementLocatedByID(t, page, "old-password") + newPasswordInput := rs.WaitElementLocatedByID(t, page, "new-password") + repeatNewPasswordInput := rs.WaitElementLocatedByID(t, page, "repeat-new-password") + + require.NoError(t, oldPasswordInput.Type(rs.toInputs(oldPassword)...)) + require.NoError(t, newPasswordInput.Type(rs.toInputs(newPassword1)...)) + require.NoError(t, repeatNewPasswordInput.Type(rs.toInputs(newPassword1)...)) + + require.NoError(t, rs.WaitElementLocatedByID(t, page, "password-change-dialog-submit").Click("left", 1)) + rs.verifyNotificationDisplayed(t, page, "Incorrect password") +} + +func (rs *RodSession) doMustChangePasswordMustMatch(t *testing.T, page *rod.Page, oldPassword, newPassword1, newPassword2 string) { + require.NoError(t, rs.WaitElementLocatedByID(t, page, "change-password-button").Click("left", 1)) + + rs.doMaybeVerifyIdentity(t, page) + t.Helper() + + oldPasswordInput := rs.WaitElementLocatedByID(t, page, "old-password") + newPasswordInput := rs.WaitElementLocatedByID(t, page, "new-password") + repeatNewPasswordInput := rs.WaitElementLocatedByID(t, page, "repeat-new-password") + + require.NoError(t, oldPasswordInput.Type(rs.toInputs(oldPassword)...)) + require.NoError(t, newPasswordInput.Type(rs.toInputs(newPassword1)...)) + require.NoError(t, repeatNewPasswordInput.Type(rs.toInputs(newPassword2)...)) + + require.NoError(t, rs.WaitElementLocatedByID(t, page, "password-change-dialog-submit").Click("left", 1)) + rs.verifyNotificationDisplayed(t, page, "Passwords do not match") +} diff --git a/internal/suites/action_settings.go b/internal/suites/action_settings.go index 827f84c3c..997dbc9f6 100644 --- a/internal/suites/action_settings.go +++ b/internal/suites/action_settings.go @@ -24,6 +24,14 @@ func (rs *RodSession) doOpenSettingsMenu(t *testing.T, page *rod.Page) { require.NoError(t, page.WaitStable(time.Millisecond*10)) } +func (rs *RodSession) doOpenSettingsMenuClickSecurity(t *testing.T, page *rod.Page) { + rs.doOpenSettingsMenu(t, page) + + require.NoError(t, rs.WaitElementLocatedByID(t, page, "settings-menu-security").Click("left", 1)) + + require.NoError(t, page.WaitStable(time.Millisecond*10)) +} + func (rs *RodSession) doOpenSettingsMenuClickTwoFactor(t *testing.T, page *rod.Page) { rs.doOpenSettingsMenu(t, page) diff --git a/internal/suites/action_verify.go b/internal/suites/action_verify.go index 0179434a3..9f40320ff 100644 --- a/internal/suites/action_verify.go +++ b/internal/suites/action_verify.go @@ -9,7 +9,7 @@ import ( ) func (rs *RodSession) isVerifyIdentityShowing(t *testing.T, page *rod.Page) bool { - require.NoError(t, page.WaitStable(time.Millisecond*100)) + require.NoError(t, page.WaitStable(time.Millisecond*200)) has, _, err := page.Has("#dialog-verify-one-time-code") require.NoError(t, err) diff --git a/internal/suites/scenario_change_password_test.go b/internal/suites/scenario_change_password_test.go new file mode 100644 index 000000000..8419a4f5c --- /dev/null +++ b/internal/suites/scenario_change_password_test.go @@ -0,0 +1,160 @@ +package suites + +import ( + "context" + "log" + "testing" + "time" +) + +type ChangePasswordScenario struct { + *RodSuite +} + +func NewChangePasswordScenario() *ChangePasswordScenario { + return &ChangePasswordScenario{RodSuite: NewRodSuite("")} +} + +func (s *ChangePasswordScenario) SetupSuite() { + browser, err := NewRodSession(RodSessionWithCredentials(s)) + if err != nil { + log.Fatal(err) + } + + s.RodSession = browser +} + +func (s *ChangePasswordScenario) TearDownSuite() { + err := s.RodSession.Stop() + + if err != nil { + log.Fatal(err) + } +} + +func (s *ChangePasswordScenario) SetupTest() { + s.Page = s.doCreateTab(s.T(), HomeBaseURL) + s.verifyIsHome(s.T(), s.Page) +} + +func (s *ChangePasswordScenario) TearDownTest() { + s.collectCoverage(s.Page) + s.MustClose() +} + +func (s *ChangePasswordScenario) TestShouldChangePassword() { + testCases := []struct { + name string + username string + oldPassword string + newPassword string + }{ + {"case1", "john", "password", "password1"}, + {"case2", "john", "password1", "password"}, + } + + for _, tc := range testCases { + s.T().Run(tc.name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) + defer func() { + cancel() + s.collectScreenshot(ctx.Err(), s.Page) + }() + s.doLoginOneFactor(s.T(), s.Context(ctx), tc.username, tc.oldPassword, false, BaseDomain, "") + s.doOpenSettings(s.T(), s.Context(ctx)) + s.doOpenSettingsMenuClickSecurity(s.T(), s.Context(ctx)) + + s.doChangePassword(s.T(), s.Context(ctx), tc.oldPassword, tc.newPassword, tc.newPassword) + s.doLogout(s.T(), s.Context(ctx)) + }) + } +} + +func (s *ChangePasswordScenario) TestCannotChangePasswordToExistingPassword() { + testCases := []struct { + testName string + username string + oldPassword string + }{ + {"case1", "john", "password"}, + } + + for _, tc := range testCases { + s.T().Run(tc.testName, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) + defer func() { + cancel() + s.collectScreenshot(ctx.Err(), s.Page) + }() + s.doLoginOneFactor(s.T(), s.Context(ctx), tc.username, tc.oldPassword, false, BaseDomain, "") + s.doOpenSettings(s.T(), s.Context(ctx)) + s.doOpenSettingsMenuClickSecurity(s.T(), s.Context(ctx)) + + s.doMustChangePasswordExistingPassword(s.T(), s.Context(ctx), tc.oldPassword) + + s.doLogout(s.T(), s.Context(ctx)) + }) + } +} + +func (s *ChangePasswordScenario) TestCannotChangePasswordWithIncorrectOldPassword() { + testCases := []struct { + testName string + username string + oldPassword string + wrongOldPassword string + newPassword string + }{ + {"case1", "john", "password", "wrong_password", "new_password"}, + } + + for _, tc := range testCases { + s.T().Run(tc.testName, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) + defer func() { + cancel() + s.collectScreenshot(ctx.Err(), s.Page) + }() + s.doLoginOneFactor(s.T(), s.Context(ctx), tc.username, tc.oldPassword, false, BaseDomain, "") + + s.doOpenSettings(s.T(), s.Context(ctx)) + + s.doOpenSettingsMenuClickSecurity(s.T(), s.Context(ctx)) + + s.doMustChangePasswordWrongExistingPassword(s.T(), s.Context(ctx), tc.wrongOldPassword, tc.newPassword) + + s.doLogout(s.T(), s.Context(ctx)) + }) + } +} + +func (s *ChangePasswordScenario) TestNewPasswordsMustMatch() { + testCases := []struct { + testName string + username string + oldPassword string + newPassword1 string + newPassword2 string + }{ + {"case1", "john", "password", "my_new_password", "new_password"}, + } + + for _, tc := range testCases { + s.T().Run(tc.testName, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) + defer func() { + cancel() + s.collectScreenshot(ctx.Err(), s.Page) + }() + s.doLoginOneFactor(s.T(), s.Context(ctx), tc.username, tc.oldPassword, false, BaseDomain, "") + + s.doOpenSettings(s.T(), s.Context(ctx)) + + s.doOpenSettingsMenuClickSecurity(s.T(), s.Context(ctx)) + + s.doMustChangePasswordMustMatch(s.T(), s.Context(ctx), tc.oldPassword, tc.newPassword1, tc.newPassword2) + + s.doLogout(s.T(), s.Context(ctx)) + }) + } +} diff --git a/internal/suites/suite_caddy_test.go b/internal/suites/suite_caddy_test.go index 89d3d988b..d64e07c71 100644 --- a/internal/suites/suite_caddy_test.go +++ b/internal/suites/suite_caddy_test.go @@ -32,6 +32,10 @@ func (s *CaddySuite) TestResetPasswordScenario() { suite.Run(s.T(), NewResetPasswordScenario()) } +func (s *CaddySuite) TestChangePasswordScenario() { + suite.Run(s.T(), NewChangePasswordScenario()) +} + func TestCaddySuite(t *testing.T) { if testing.Short() { t.Skip("skipping suite test in short mode") diff --git a/internal/suites/suite_envoy_test.go b/internal/suites/suite_envoy_test.go index 19ac4ae31..6952c274a 100644 --- a/internal/suites/suite_envoy_test.go +++ b/internal/suites/suite_envoy_test.go @@ -32,6 +32,10 @@ func (s *EnvoySuite) TestResetPasswordScenario() { suite.Run(s.T(), NewResetPasswordScenario()) } +func (s *EnvoySuite) TestChangePasswordScenario() { + suite.Run(s.T(), NewChangePasswordScenario()) +} + func TestEnvoySuite(t *testing.T) { if testing.Short() { t.Skip("skipping suite test in short mode") diff --git a/internal/suites/suite_ldap_test.go b/internal/suites/suite_ldap_test.go index b49ce9eb8..7d5d123c8 100644 --- a/internal/suites/suite_ldap_test.go +++ b/internal/suites/suite_ldap_test.go @@ -36,6 +36,10 @@ func (s *LDAPSuite) TestSigninEmailScenario() { suite.Run(s.T(), NewSigninEmailScenario()) } +func (s *LDAPSuite) TestChangePasswordScenario() { + suite.Run(s.T(), NewChangePasswordScenario()) +} + func TestLDAPSuite(t *testing.T) { if testing.Short() { t.Skip("skipping suite test in short mode") diff --git a/internal/suites/suite_pathprefix_test.go b/internal/suites/suite_pathprefix_test.go index 04ad1c954..4b3f95c4b 100644 --- a/internal/suites/suite_pathprefix_test.go +++ b/internal/suites/suite_pathprefix_test.go @@ -37,6 +37,9 @@ func (s *PathPrefixSuite) TestCustomHeaders() { func (s *PathPrefixSuite) TestResetPasswordScenario() { suite.Run(s.T(), NewResetPasswordScenario()) } +func (s *PathPrefixSuite) TestChangePasswordScenario() { + suite.Run(s.T(), NewChangePasswordScenario()) +} func (s *PathPrefixSuite) TestShouldRenderFrontendWithTrailingSlash() { ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) diff --git a/internal/suites/suite_standalone_test.go b/internal/suites/suite_standalone_test.go index 4a974e39e..b3cf42045 100644 --- a/internal/suites/suite_standalone_test.go +++ b/internal/suites/suite_standalone_test.go @@ -346,6 +346,10 @@ func (s *StandaloneSuite) TestResetPasswordScenario() { suite.Run(s.T(), NewResetPasswordScenario()) } +func (s *StandaloneSuite) TestChangePasswordScenario() { + suite.Run(s.T(), NewChangePasswordScenario()) +} + func (s *StandaloneSuite) TestRequestMethodScenario() { suite.Run(s.T(), NewRequestMethodScenario()) } diff --git a/web/src/constants/Routes.ts b/web/src/constants/Routes.ts index c63fead7b..8897661e3 100644 --- a/web/src/constants/Routes.ts +++ b/web/src/constants/Routes.ts @@ -15,6 +15,7 @@ export const SettingsRoute: string = "/settings"; export const SettingsTwoFactorAuthenticationSubRoute: string = "/two-factor-authentication"; export const RevokeOneTimeCodeRoute: string = "/revoke/one-time-code"; export const RevokeResetPasswordRoute: string = "/revoke/reset-password"; +export const SecuritySubRoute: string = "/security"; export const ConsentRoute: string = "/consent"; export const ConsentOpenIDSubRoute: string = "/openid"; diff --git a/web/src/hooks/CapsLock.ts b/web/src/hooks/CapsLock.ts new file mode 100644 index 000000000..724d3c3c9 --- /dev/null +++ b/web/src/hooks/CapsLock.ts @@ -0,0 +1,16 @@ +import React, { useCallback } from "react"; + +export const useCheckCapsLock = (setCapsLockNotify: React.Dispatch<React.SetStateAction<boolean>>) => { + return useCallback( + (event: React.KeyboardEvent<HTMLDivElement>) => { + if (event.getModifierState("CapsLock")) { + setCapsLockNotify(true); + } else { + setCapsLockNotify(false); + } + }, + [setCapsLockNotify], + ); +}; + +export default useCheckCapsLock; diff --git a/web/src/layouts/SettingsLayout.tsx b/web/src/layouts/SettingsLayout.tsx index 5f540954b..7e00ce9fa 100644 --- a/web/src/layouts/SettingsLayout.tsx +++ b/web/src/layouts/SettingsLayout.tsx @@ -1,6 +1,6 @@ import React, { ReactNode, SyntheticEvent, useCallback, useEffect, useState } from "react"; -import { Close, Dashboard, Menu, SystemSecurityUpdateGood } from "@mui/icons-material"; +import { Close, Dashboard, Menu, Security, SystemSecurityUpdateGood } from "@mui/icons-material"; import { AppBar, Box, @@ -17,7 +17,12 @@ import { import IconButton from "@mui/material/IconButton"; import { useTranslation } from "react-i18next"; -import { IndexRoute, SettingsRoute, SettingsTwoFactorAuthenticationSubRoute } from "@constants/Routes"; +import { + IndexRoute, + SecuritySubRoute, + SettingsRoute, + SettingsTwoFactorAuthenticationSubRoute, +} from "@constants/Routes"; import { useRouterNavigate } from "@hooks/RouterNavigate"; export interface Props { @@ -127,7 +132,7 @@ const SettingsLayout = function (props: Props) { {drawer} </SwipeableDrawer> </Box> - <Box component="main" sx={{ flexGrow: 1, p: 3 }}> + <Box component="main" sx={{ flexGrow: 1, p: { xs: 0, sm: 3 } }}> <Toolbar /> {props.children} </Box> @@ -145,6 +150,12 @@ interface NavItem { const navItems: NavItem[] = [ { keyname: "overview", text: "Overview", pathname: SettingsRoute, icon: <Dashboard color={"primary"} /> }, { + keyname: "security", + text: "Security", + pathname: `${SettingsRoute}${SecuritySubRoute}`, + icon: <Security color={"primary"} />, + }, + { keyname: "twofactor", text: "Two-Factor Authentication", pathname: `${SettingsRoute}${SettingsTwoFactorAuthenticationSubRoute}`, diff --git a/web/src/models/Configuration.ts b/web/src/models/Configuration.ts index 45551b301..f133c3399 100644 --- a/web/src/models/Configuration.ts +++ b/web/src/models/Configuration.ts @@ -2,4 +2,10 @@ import { SecondFactorMethod } from "@models/Methods"; export interface Configuration { available_methods: Set<SecondFactorMethod>; + password_change_disabled: boolean; + password_reset_disabled: boolean; +} + +export interface SecuritySettingsConfiguration { + disable: boolean; } diff --git a/web/src/models/UserInfo.ts b/web/src/models/UserInfo.ts index eb420b7b6..5c7c155ca 100644 --- a/web/src/models/UserInfo.ts +++ b/web/src/models/UserInfo.ts @@ -2,6 +2,7 @@ import { SecondFactorMethod } from "@models/Methods"; export interface UserInfo { display_name: string; + emails: string[]; method: SecondFactorMethod; has_webauthn: boolean; has_totp: boolean; diff --git a/web/src/services/Api.ts b/web/src/services/Api.ts index a6f818013..e88e4d204 100644 --- a/web/src/services/Api.ts +++ b/web/src/services/Api.ts @@ -29,6 +29,8 @@ export const CompletePasswordSignInPath = basePath + "/api/secondfactor/password export const InitiateResetPasswordPath = basePath + "/api/reset-password/identity/start"; export const CompleteResetPasswordPath = basePath + "/api/reset-password/identity/finish"; +export const ChangePasswordPath = basePath + "/api/change-password"; + // Do the password reset during completion. export const ResetPasswordPath = basePath + "/api/reset-password"; export const ChecksSafeRedirectionPath = basePath + "/api/checks/safe-redirection"; diff --git a/web/src/services/ChangePassword.ts b/web/src/services/ChangePassword.ts new file mode 100644 index 000000000..9d89703a0 --- /dev/null +++ b/web/src/services/ChangePassword.ts @@ -0,0 +1,17 @@ +import { ChangePasswordPath } from "@services/Api"; +import { PostWithOptionalResponse } from "@services/Client"; + +interface PostPasswordChange { + username: string; + old_password: string; + new_password: string; +} + +export async function postPasswordChange(username: string, old_password: string, new_password: string) { + const data: PostPasswordChange = { + username, + old_password, + new_password, + }; + return PostWithOptionalResponse(ChangePasswordPath, data); +} diff --git a/web/src/services/Configuration.ts b/web/src/services/Configuration.ts index 9c494f182..e69d47731 100644 --- a/web/src/services/Configuration.ts +++ b/web/src/services/Configuration.ts @@ -5,9 +5,16 @@ import { Method2FA, toSecondFactorMethod } from "@services/UserInfo"; interface ConfigurationPayload { available_methods: Method2FA[]; + password_change_disabled: boolean; + password_reset_disabled: boolean; } export async function getConfiguration(): Promise<Configuration> { const config = await Get<ConfigurationPayload>(ConfigurationPath); - return { ...config, available_methods: new Set(config.available_methods.map(toSecondFactorMethod)) }; + return { + ...config, + available_methods: new Set(config.available_methods.map(toSecondFactorMethod)), + password_change_disabled: config.password_change_disabled, + password_reset_disabled: config.password_reset_disabled, + }; } diff --git a/web/src/services/UserInfo.ts b/web/src/services/UserInfo.ts index f7fbb5a6c..db5302624 100644 --- a/web/src/services/UserInfo.ts +++ b/web/src/services/UserInfo.ts @@ -7,6 +7,7 @@ export type Method2FA = "webauthn" | "totp" | "mobile_push"; export interface UserInfoPayload { display_name: string; + emails: string[]; method: Method2FA; has_webauthn: boolean; has_totp: boolean; diff --git a/web/src/views/Settings/Common/IdentityVerificationDialog.tsx b/web/src/views/Settings/Common/IdentityVerificationDialog.tsx index b9b12f942..7eaa4f129 100644 --- a/web/src/views/Settings/Common/IdentityVerificationDialog.tsx +++ b/web/src/views/Settings/Common/IdentityVerificationDialog.tsx @@ -131,6 +131,22 @@ const IdentityVerificationDialog = function (props: Props) { } }, [codeInput, handleFailure, handleSuccess]); + const handleSubmitKeyDown = useCallback( + (event: React.KeyboardEvent<HTMLDivElement>) => { + if (event.key === "Enter") { + if (!codeInput.length) { + setCodeError(true); + } else if (codeInput.length) { + handleSubmit(); + } else { + setCodeError(false); + codeRef.current?.focus(); + } + } + }, + [codeInput.length, handleSubmit], + ); + useEffect(() => { if (closing || !props.opening || !props.elevation) { return; @@ -190,6 +206,7 @@ const IdentityVerificationDialog = function (props: Props) { error={codeError} disabled={loading} inputRef={codeRef} + onKeyDown={handleSubmitKeyDown} /> </Box> </DialogContent> diff --git a/web/src/views/Settings/Security/ChangePasswordDialog.tsx b/web/src/views/Settings/Security/ChangePasswordDialog.tsx new file mode 100644 index 000000000..4bed82e67 --- /dev/null +++ b/web/src/views/Settings/Security/ChangePasswordDialog.tsx @@ -0,0 +1,306 @@ +import React, { MutableRefObject, useCallback, useEffect, useRef, useState } from "react"; + +import { + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControl, + Grid2, + TextField, +} from "@mui/material"; +import axios from "axios"; +import { useTranslation } from "react-i18next"; + +import PasswordMeter from "@components/PasswordMeter"; +import useCheckCapsLock from "@hooks/CapsLock"; +import { useNotifications } from "@hooks/NotificationsContext"; +import { PasswordPolicyConfiguration, PasswordPolicyMode } from "@models/PasswordPolicy"; +import { postPasswordChange } from "@services/ChangePassword"; +import { getPasswordPolicyConfiguration } from "@services/PasswordPolicyConfiguration"; + +interface Props { + username: string; + disabled?: boolean; + open: boolean; + setClosed: () => void; +} + +const ChangePasswordDialog = (props: Props) => { + const { t: translate } = useTranslation("settings"); + + const { createSuccessNotification, createErrorNotification } = useNotifications(); + + const [loading, setLoading] = useState(false); + const [oldPassword, setOldPassword] = useState(""); + const [oldPasswordError, setOldPasswordError] = useState(false); + const [newPassword, setNewPassword] = useState(""); + const [newPasswordError, setNewPasswordError] = useState(false); + const [repeatNewPassword, setRepeatNewPassword] = useState(""); + const [repeatNewPasswordError, setRepeatNewPasswordError] = useState(false); + const [isCapsLockOnOldPW, setIsCapsLockOnOldPW] = useState(false); + const [isCapsLockOnNewPW, setIsCapsLockOnNewPW] = useState(false); + const [isCapsLockOnRepeatNewPW, setIsCapsLockOnRepeatNewPW] = useState(false); + + const oldPasswordRef = useRef() as MutableRefObject<HTMLInputElement>; + const newPasswordRef = useRef() as MutableRefObject<HTMLInputElement>; + const repeatNewPasswordRef = useRef() as MutableRefObject<HTMLInputElement>; + + const [pPolicy, setPPolicy] = useState<PasswordPolicyConfiguration>({ + max_length: 0, + min_length: 8, + min_score: 0, + require_lowercase: false, + require_number: false, + require_special: false, + require_uppercase: false, + mode: PasswordPolicyMode.Disabled, + }); + + const resetPasswordErrors = useCallback(() => { + setOldPasswordError(false); + setNewPasswordError(false); + setRepeatNewPasswordError(false); + }, []); + + const resetCapsLockErrors = useCallback(() => { + setIsCapsLockOnOldPW(false); + setIsCapsLockOnNewPW(false); + setIsCapsLockOnRepeatNewPW(false); + }, []); + + const resetStates = useCallback(() => { + setOldPassword(""); + setNewPassword(""); + setRepeatNewPassword(""); + + resetPasswordErrors(); + resetCapsLockErrors(); + + setLoading(false); + }, [resetPasswordErrors, resetCapsLockErrors]); + + const handleClose = useCallback(() => { + props.setClosed(); + resetStates(); + }, [props, resetStates]); + + const asyncProcess = useCallback(async () => { + try { + setLoading(true); + const policy = await getPasswordPolicyConfiguration(); + setPPolicy(policy); + setLoading(false); + } catch { + createErrorNotification( + translate("There was an issue completing the process the verification token might have expired"), + ); + setLoading(true); + } + }, [createErrorNotification, translate]); + + useEffect(() => { + asyncProcess(); + }, [asyncProcess]); + + const handlePasswordChange = useCallback(async () => { + setLoading(true); + if (oldPassword.trim() === "" || newPassword.trim() === "" || repeatNewPassword.trim() === "") { + if (oldPassword.trim() === "") { + setOldPasswordError(true); + } + if (newPassword.trim() === "") { + setNewPasswordError(true); + } + if (repeatNewPassword.trim() === "") { + setRepeatNewPasswordError(true); + } + setLoading(false); + return; + } + if (newPassword !== repeatNewPassword) { + setNewPasswordError(true); + setRepeatNewPasswordError(true); + createErrorNotification(translate("Passwords do not match")); + setLoading(false); + return; + } + + try { + await postPasswordChange(props.username, oldPassword, newPassword); + createSuccessNotification(translate("Password changed successfully")); + handleClose(); + } catch (err) { + resetPasswordErrors(); + setLoading(false); + if (axios.isAxiosError(err) && err.response) { + switch (err.response.status) { + case 400: // Bad Request - Weak Password + setNewPasswordError(true); + setRepeatNewPasswordError(true); + createErrorNotification( + translate("Your supplied password does not meet the password policy requirements"), + ); + break; + + case 401: // Unauthorized - Incorrect Password + setOldPasswordError(true); + createErrorNotification(translate("Incorrect password")); + break; + + case 500: // Internal Server Error + default: + createErrorNotification( + translate("There was an issue changing the {{item}}", { item: translate("password") }), + ); + break; + } + } else { + // Handle non-axios errors + createErrorNotification( + translate("There was an issue changing the {{item}}", { item: translate("password") }), + ); + } + return; + } + }, [ + createErrorNotification, + createSuccessNotification, + resetPasswordErrors, + handleClose, + newPassword, + oldPassword, + repeatNewPassword, + props.username, + translate, + ]); + + const useHandleKeyDown = ( + passwordState: string, + setError: React.Dispatch<React.SetStateAction<boolean>>, + nextRef?: React.MutableRefObject<HTMLInputElement>, + ) => { + return useCallback( + (event: React.KeyboardEvent<HTMLDivElement>) => { + if (event.key === "Enter") { + if (!passwordState.length) { + setError(true); + } else if (!nextRef) { + handlePasswordChange().catch(console.error); + } else { + nextRef?.current.focus(); + } + } + }, + [nextRef, passwordState.length, setError], + ); + }; + + const handleOldPWKeyDown = useHandleKeyDown(oldPassword, setOldPasswordError, newPasswordRef); + const handleNewPWKeyDown = useHandleKeyDown(newPassword, setNewPasswordError, repeatNewPasswordRef); + const handleRepeatNewPWKeyDown = useHandleKeyDown(repeatNewPassword, setRepeatNewPasswordError); + + const disabled = props.disabled || false; + + return ( + <Dialog open={props.open} maxWidth="xs"> + <DialogTitle>{translate("Change {{item}}", { item: translate("Password") })}</DialogTitle> + <DialogContent> + <FormControl id={"change-password-form"} disabled={loading}> + <Grid2 container spacing={1} alignItems={"center"} justifyContent={"center"} textAlign={"center"}> + <Grid2 size={{ xs: 12 }} sx={{ pt: 3 }}> + <TextField + inputRef={oldPasswordRef} + id="old-password" + label={translate("Old Password")} + variant="outlined" + required + value={oldPassword} + error={oldPasswordError} + disabled={disabled} + fullWidth + onChange={(v) => setOldPassword(v.target.value)} + onFocus={() => setOldPasswordError(false)} + type="password" + autoCapitalize="off" + autoComplete="off" + onKeyDown={handleOldPWKeyDown} + onKeyUp={useCheckCapsLock(setIsCapsLockOnOldPW)} + helperText={isCapsLockOnOldPW ? translate("Caps Lock is on") : " "} + color={isCapsLockOnOldPW ? "error" : "primary"} + onBlur={() => setIsCapsLockOnOldPW(false)} + /> + </Grid2> + <Grid2 size={{ xs: 12 }} sx={{ mt: 3 }}> + <TextField + inputRef={newPasswordRef} + id="new-password" + label={translate("New Password")} + variant="outlined" + required + fullWidth + disabled={disabled} + value={newPassword} + error={newPasswordError} + onChange={(v) => setNewPassword(v.target.value)} + onFocus={() => setNewPasswordError(false)} + type="password" + autoCapitalize="off" + autoComplete="off" + onKeyDown={handleNewPWKeyDown} + onKeyUp={useCheckCapsLock(setIsCapsLockOnNewPW)} + helperText={isCapsLockOnNewPW ? translate("Caps Lock is on") : " "} + color={isCapsLockOnNewPW ? "error" : "primary"} + onBlur={() => setIsCapsLockOnNewPW(false)} + /> + {pPolicy.mode === PasswordPolicyMode.Disabled ? null : ( + <PasswordMeter value={newPassword} policy={pPolicy} /> + )} + </Grid2> + <Grid2 size={{ xs: 12 }}> + <TextField + inputRef={repeatNewPasswordRef} + id="repeat-new-password" + label={translate("Repeat New Password")} + variant="outlined" + required + fullWidth + disabled={disabled} + value={repeatNewPassword} + error={repeatNewPasswordError} + onChange={(v) => setRepeatNewPassword(v.target.value)} + onFocus={() => setRepeatNewPasswordError(false)} + type="password" + autoCapitalize="off" + autoComplete="off" + onKeyDown={handleRepeatNewPWKeyDown} + onKeyUp={useCheckCapsLock(setIsCapsLockOnRepeatNewPW)} + helperText={isCapsLockOnRepeatNewPW ? translate("Caps Lock is on") : " "} + color={isCapsLockOnRepeatNewPW ? "error" : "primary"} + onBlur={() => setIsCapsLockOnRepeatNewPW(false)} + /> + </Grid2> + </Grid2> + </FormControl> + </DialogContent> + <DialogActions> + <Button id={"password-change-dialog-cancel"} color={"error"} onClick={handleClose}> + {translate("Cancel")} + </Button> + <Button + id={"password-change-dialog-submit"} + color={"primary"} + onClick={handlePasswordChange} + disabled={!(oldPassword.length && newPassword.length && repeatNewPassword.length) || loading} + startIcon={loading ? <CircularProgress color="inherit" size={20} /> : <></>} + > + {translate("Submit")} + </Button> + </DialogActions> + </Dialog> + ); +}; + +export default ChangePasswordDialog; diff --git a/web/src/views/Settings/Security/SecurityView.tsx b/web/src/views/Settings/Security/SecurityView.tsx new file mode 100644 index 000000000..6761591fa --- /dev/null +++ b/web/src/views/Settings/Security/SecurityView.tsx @@ -0,0 +1,244 @@ +import { Fragment, useCallback, useEffect, useState } from "react"; + +import { Box, Button, Container, List, ListItem, Paper, Stack, Tooltip, Typography, useTheme } from "@mui/material"; +import { useTranslation } from "react-i18next"; + +import { useConfiguration } from "@hooks/Configuration"; +import { useNotifications } from "@hooks/NotificationsContext"; +import { useUserInfoGET } from "@hooks/UserInfo"; +import { UserSessionElevation, getUserSessionElevation } from "@services/UserSessionElevation"; +import IdentityVerificationDialog from "@views/Settings/Common/IdentityVerificationDialog"; +import SecondFactorDialog from "@views/Settings/Common/SecondFactorDialog"; +import ChangePasswordDialog from "@views/Settings/Security/ChangePasswordDialog"; + +const SettingsView = function () { + const { t: translate } = useTranslation("settings"); + const theme = useTheme(); + const { createErrorNotification } = useNotifications(); + + const [userInfo, fetchUserInfo, , fetchUserInfoError] = useUserInfoGET(); + const [elevation, setElevation] = useState<UserSessionElevation>(); + const [dialogSFOpening, setDialogSFOpening] = useState(false); + const [dialogIVOpening, setDialogIVOpening] = useState(false); + const [dialogPWChangeOpen, setDialogPWChangeOpen] = useState(false); + const [dialogPWChangeOpening, setDialogPWChangeOpening] = useState(false); + const [configuration, fetchConfiguration, , fetchConfigurationError] = useConfiguration(); + + const handleResetStateOpening = () => { + setDialogSFOpening(false); + setDialogIVOpening(false); + setDialogPWChangeOpening(false); + }; + + const handleResetState = useCallback(() => { + handleResetStateOpening(); + + setElevation(undefined); + setDialogPWChangeOpen(false); + }, []); + + const handleOpenChangePWDialog = useCallback(() => { + handleResetStateOpening(); + setDialogPWChangeOpen(true); + }, []); + + const handleSFDialogClosed = (ok: boolean, changed: boolean) => { + if (!ok) { + console.warn("Second Factor dialog close callback failed, it was likely cancelled by the user."); + + handleResetState(); + + return; + } + + if (changed) { + handleElevationRefresh() + .catch(console.error) + .then(() => { + setDialogIVOpening(true); + }); + } else { + setDialogIVOpening(true); + } + }; + + const handleSFDialogOpened = () => { + setDialogSFOpening(false); + }; + + const handleIVDialogClosed = useCallback( + (ok: boolean) => { + if (!ok) { + console.warn( + "Identity Verification dialog close callback failed, it was likely cancelled by the user.", + ); + + handleResetState(); + + return; + } + + setElevation(undefined); + if (dialogPWChangeOpening) { + handleOpenChangePWDialog(); + } + }, + [dialogPWChangeOpening, handleOpenChangePWDialog, handleResetState], + ); + + const handleIVDialogOpened = () => { + setDialogIVOpening(false); + }; + + const handleElevationRefresh = async () => { + try { + const result = await getUserSessionElevation(); + setElevation(result); + } catch { + createErrorNotification(translate("Failed to get session elevation status")); + } + }; + + const handleElevation = () => { + handleElevationRefresh().catch(console.error); + + setDialogSFOpening(true); + }; + + const handleChangePassword = () => { + setDialogPWChangeOpening(true); + + handleElevation(); + }; + + useEffect(() => { + if (fetchUserInfoError) { + createErrorNotification(translate("There was an issue retrieving user preferences")); + } + if (fetchConfigurationError) { + createErrorNotification(translate("There was an issue retrieving configuration")); + } + }, [fetchUserInfoError, fetchConfigurationError, createErrorNotification, translate]); + + useEffect(() => { + fetchUserInfo(); + fetchConfiguration(); + }, [fetchUserInfo, fetchConfiguration]); + + const PasswordChangeButton = () => { + const buttonContent = ( + <Button + id="change-password-button" + variant="contained" + sx={{ p: 1, width: "100%" }} + onClick={handleChangePassword} + disabled={configuration?.password_change_disabled || false} + > + {translate("Change Password")} + </Button> + ); + + return configuration?.password_change_disabled ? ( + <Tooltip title={translate("This is disabled by your administrator.")}> + <span>{buttonContent}</span> + </Tooltip> + ) : ( + buttonContent + ); + }; + + return ( + <Fragment> + <SecondFactorDialog + info={userInfo} + elevation={elevation} + opening={dialogSFOpening} + handleClosed={handleSFDialogClosed} + handleOpened={handleSFDialogOpened} + /> + <IdentityVerificationDialog + opening={dialogIVOpening} + elevation={elevation} + handleClosed={handleIVDialogClosed} + handleOpened={handleIVDialogOpened} + /> + <ChangePasswordDialog + username={userInfo?.display_name || ""} + open={dialogPWChangeOpen} + setClosed={() => { + handleResetState(); + }} + /> + + <Container + sx={{ + display: "flex", + justifyContent: "center", + alignItems: "flex-start", + height: "100vh", + pt: 8, + }} + > + <Paper + variant="outlined" + sx={{ + display: "flex", + justifyContent: "center", + alignItems: "center", + height: "auto", + }} + > + <Stack spacing={2} sx={{ m: 2, width: "100%" }}> + <Box sx={{ p: { xs: 1, md: 3 } }}> + <Box + sx={{ + width: "100%", + p: 1.25, + mb: 1, + border: `1px solid ${theme.palette.grey[600]}`, + borderRadius: 1, + }} + > + <Box display="flex" alignItems="center"> + <Typography sx={{ mr: 1 }}>{translate("Email")}:</Typography> + <Typography>{userInfo?.emails?.[0] || ""}</Typography> + </Box> + {userInfo?.emails && userInfo.emails.length > 1 && ( + <List sx={{ width: "100%", padding: 0, pl: 4 }}> + {" "} + {userInfo.emails.slice(1).map((email: string, index: number) => ( + <ListItem key={index} sx={{ paddingTop: 0, paddingBottom: 0 }}> + <Typography>{email}</Typography> + </ListItem> + ))} + </List> + )} + </Box> + <Box + sx={{ + width: "100%", + p: 1.25, + mb: 1, + border: `1px solid ${theme.palette.grey[600]}`, + borderRadius: 1, + }} + > + <Typography> + {translate("Username")}: {userInfo?.display_name || ""} + </Typography> + </Box> + <Box + sx={{ p: 1.25, mb: 1, border: `1px solid ${theme.palette.grey[600]}`, borderRadius: 1 }} + > + <Typography>{translate("Password")}: ●●●●●●●●</Typography> + </Box> + <PasswordChangeButton /> + </Box> + </Stack> + </Paper> + </Container> + </Fragment> + ); +}; + +export default SettingsView; diff --git a/web/src/views/Settings/SettingsRouter.tsx b/web/src/views/Settings/SettingsRouter.tsx index ba8fdf469..601d08a8e 100644 --- a/web/src/views/Settings/SettingsRouter.tsx +++ b/web/src/views/Settings/SettingsRouter.tsx @@ -2,11 +2,12 @@ import React, { useEffect } from "react"; import { Route, Routes } from "react-router-dom"; -import { IndexRoute, SettingsTwoFactorAuthenticationSubRoute } from "@constants/Routes"; +import { IndexRoute, SecuritySubRoute, SettingsTwoFactorAuthenticationSubRoute } from "@constants/Routes"; import { useRouterNavigate } from "@hooks/RouterNavigate"; import { useAutheliaState } from "@hooks/State"; import SettingsLayout from "@layouts/SettingsLayout"; import { AuthenticationLevel } from "@services/State"; +import SecurityView from "@views/Settings/Security/SecurityView"; import SettingsView from "@views/Settings/SettingsView"; import TwoFactorAuthenticationView from "@views/Settings/TwoFactorAuthentication/TwoFactorAuthenticationView"; @@ -30,6 +31,7 @@ const SettingsRouter = function (props: Props) { <SettingsLayout> <Routes> <Route path={IndexRoute} element={<SettingsView />} /> + <Route path={SecuritySubRoute} element={<SecurityView />} /> <Route path={SettingsTwoFactorAuthenticationSubRoute} element={<TwoFactorAuthenticationView />} /> </Routes> </SettingsLayout> |
