|
| 1 | += Password Management |
| 2 | + |
| 3 | +Spring Security can offer advice about passwords through its `PasswordAdvisor` API. |
| 4 | +It aims to help applications apply https://github.com/OWASP/ASVS/blob/v5.0.0/5.0/docs_en/OWASP_Application_Security_Verification_Standard_5.0.0_en.csv#L108[the ASVS 5.0 standard for Password Security]. |
| 5 | + |
| 6 | +Consider the following configuration: |
| 7 | + |
| 8 | +.Java |
| 9 | +[source,java,role="primary"] |
| 10 | +---- |
| 11 | +http |
| 12 | + .authorizeHttpRequests((authorize) -> authorize.anyRequest().authenticated()) |
| 13 | + .formLogin(Customizer.withDefaults()) |
| 14 | + .passwordManagement(Customizer.withDefaults()) |
| 15 | +---- |
| 16 | + |
| 17 | +By adding the `passwordManagement` DSL, your application now has the ability to suggest or require a user to change their password if certain criteria are met. |
| 18 | + |
| 19 | +By default, `UserDetailsPasswordAdvisor` is consulted in a `SessionAuthenticationStrategy` after login is complete. |
| 20 | +It calls `UserDetails#getPasswordAdvice` to look up any password advice stored on the user object. |
| 21 | +If the advice on the user object is `PasswordAdvice.MUST_CHANGE`, then Spring Security will redirect the application to `/change-password` for every request in the application. |
| 22 | + |
| 23 | +== Configuring a Password Advisor |
| 24 | + |
| 25 | +There are wo kinds of advisors, `PasswordAdvisor` and `UpdatePasswordAdvisor`. |
| 26 | +The first advisor type is for analyzing the password at authentication time. |
| 27 | +This is useful should your website's password standards change or should the password become compromised and leaked. |
| 28 | +It is also useful when your administrator has marked a certain user as needing to update their password, regardless of any other analysis. |
| 29 | + |
| 30 | +To take advantage of these advisors, you can publish a `PasswordAdvisor` as a bean: |
| 31 | + |
| 32 | +[tabs] |
| 33 | +====== |
| 34 | +Java:: |
| 35 | ++ |
| 36 | +[source,java,role="primary"] |
| 37 | +---- |
| 38 | +@Bean |
| 39 | +PasswordAdvisor passwordAdvisor() { |
| 40 | + return CompositePasswordAdvisor.withDefaults( // <1> |
| 41 | + new CompromisedPasswordAdvisor() // <2> |
| 42 | + ); |
| 43 | +} |
| 44 | +---- |
| 45 | +
|
| 46 | +Kotlin:: |
| 47 | ++ |
| 48 | +[source,kotlin,role="secondary"] |
| 49 | +---- |
| 50 | +@Bean |
| 51 | +fun passwordAdvisor(): PasswordAdvisor { |
| 52 | + return CompositePasswordAdvisor.withDefaults( // <1> |
| 53 | + CompromisedPasswordAdvisor() // <2> |
| 54 | + ) |
| 55 | +} |
| 56 | +---- |
| 57 | +====== |
| 58 | +<1> - `withDefaults` adds an advisor that checks the `UserDetails` object for any admin-set password action and an advisor that checks password length |
| 59 | +<2> - An advisor that checks the HaveIBeenPwned breached password database |
| 60 | + |
| 61 | +== Requiring Password Changes |
| 62 | + |
| 63 | +By default password advisors mark a password as `SHOULD_CHANGE`. |
| 64 | +This allows you to add this to your application passively. |
| 65 | + |
| 66 | +In the event you want to start requiring that users change their password, you can configure each password advisor with a policy. |
| 67 | +For example, you can state that whenever a password is compromised, force the user to update their password at that time by configuring the `CompromisedPasswordAdvisor` policy as `MUST_CHANGE`: |
| 68 | + |
| 69 | +[tabs] |
| 70 | +====== |
| 71 | +Java:: |
| 72 | ++ |
| 73 | +[source,java,role="primary"] |
| 74 | +---- |
| 75 | +@Bean |
| 76 | +PasswordAdvisor passwordAdvisor() { |
| 77 | + CompromisedPasswordAdvisor compromised = new CompromisedPasswordAdvisor(); |
| 78 | + compromised.setAction(PasswordAction.MUST_CHANGE); |
| 79 | + return CompositePasswordAdvisor.withDefaults(compromised); |
| 80 | +} |
| 81 | +---- |
| 82 | +
|
| 83 | +Kotlin:: |
| 84 | ++ |
| 85 | +[source,kotlin,role="secondary"] |
| 86 | +---- |
| 87 | +@Bean |
| 88 | +fun passwordAdvisor(): PasswordAdvisor { |
| 89 | + val compromised = CompromisedPasswordAdvisor() |
| 90 | + compromised.setAction(PasswordAction.MUST_CHANGE) |
| 91 | + return CompositePasswordAdvisor.withDefaults(compromise) |
| 92 | +} |
| 93 | +---- |
| 94 | +====== |
| 95 | + |
| 96 | +[TIP] |
| 97 | +==== |
| 98 | +While optional, it's helpful to include `UserDetailsPasswordAdvisor` in the set of password advisors as this allows admins to update the `passwordAction` value in `UserDetails` out-of-band and thus require password changes en masse. |
| 99 | +This is included by default when calling `withDefaults`. |
| 100 | +==== |
| 101 | + |
| 102 | +== Updating Passwords |
| 103 | + |
| 104 | +When a user updates their password, the following are recommended: |
| 105 | + |
| 106 | +1. You require the user provide their old password |
| 107 | +2. You require the user provide and confirm their new password |
| 108 | +3. You test the password against a set of `UpdatePasswordAdvisor` instances |
| 109 | +4. You update both the password and any remaining password action |
| 110 | +5. You log out the individual so they can re-login with their new password |
| 111 | + |
| 112 | +Here is a sample controller that does this: |
| 113 | + |
| 114 | +[tabs] |
| 115 | +====== |
| 116 | +Java:: |
| 117 | ++ |
| 118 | +[source,java,role="primary"] |
| 119 | +---- |
| 120 | +@Controller |
| 121 | +class ChangePasswordController { |
| 122 | + private final InMemoryUserDetailsManager users; |
| 123 | +
|
| 124 | + private final PasswordEncoder passwordEncoder = |
| 125 | + PasswordEncoderFactories.createDelegatingPasswordEncoder(); |
| 126 | +
|
| 127 | + private final UpdatePasswordAdvisor passwordAdvisor = CompositeUpdatePasswordAdvisor.withDefaults( |
| 128 | + new CompromisedPasswordAdvisor(), |
| 129 | + new LengthPasswordAdvisor(12) // <1> |
| 130 | + ); |
| 131 | +
|
| 132 | + // constructor |
| 133 | +
|
| 134 | + @PostMapping("/change-password") |
| 135 | + String changePassword(Passwords passwords, @AuthenticationPrincipal UserDetails user, |
| 136 | + HttpServletRequest request, HttpServletResponse response) { |
| 137 | +
|
| 138 | + UserDetails latest = this.users.findUserByUsername(user.getUsername()); |
| 139 | + if (!this.passwordEncoder.matches(latest.getPassword(), passwords.current())) { // <2> |
| 140 | + request.setAttribute("error", "The provided current password doesn't match your password on file."); |
| 141 | + return "change-password"; |
| 142 | + } |
| 143 | + if (!passwords.change().equals(passwords.confirm())) { // <3> |
| 144 | + request.setAttribute("error", "The new password doesn't match its confirmation."); |
| 145 | + return "change-password"; |
| 146 | + } |
| 147 | + PasswordAdvice advice = this.passwordAdvisor.advise(latest, latest.getPassword(), passwords.change()); // <4> |
| 148 | + if (PasswordAction.NONE.advisedBy(advice)) { |
| 149 | + UserDetails updated = User.withUserDetails(latest) |
| 150 | + .passwordEncoder(this.passwordEncoder::encode) |
| 151 | + .password(passwords.change()) |
| 152 | + .passwordAction(PasswordAction.NONE).build(); // <5> |
| 153 | + this.users.updateUser(updated); |
| 154 | + return "forward:/logout"; // <6> |
| 155 | + } |
| 156 | + request.setAttribute(error, "Your password was rejected since " + advice); |
| 157 | + return "change-password"; |
| 158 | + } |
| 159 | +} |
| 160 | +---- |
| 161 | +
|
| 162 | +Kotlin:: |
| 163 | ++ |
| 164 | +[source,kotlin,role="secondary"] |
| 165 | +---- |
| 166 | +@Controller |
| 167 | +open class ChangePasswordController { |
| 168 | + private val users: InMemoryUserDetailsManager |
| 169 | +
|
| 170 | + private val passwordEncoder = |
| 171 | + PasswordEncoderFactories.createDelegatingPasswordEncoder() |
| 172 | +
|
| 173 | + private val passwordAdvisor = CompositeUpdatePasswordAdvisor.of( |
| 174 | + CompromisedPasswordAdvisor(), |
| 175 | + LengthPasswordAdvisor(12) // <1> |
| 176 | + ) |
| 177 | +
|
| 178 | + // constructor |
| 179 | +
|
| 180 | + @PostMapping("/change-password") |
| 181 | + fun changePassword(val passwords: Passwords, @AuthenticationPrincipal val user: UserDetails, |
| 182 | + val request: HttpServletRequest): String { |
| 183 | +
|
| 184 | + val latest = this.users.findUserByUsername(user.getUsername()) |
| 185 | + if (!this.passwordEncoder.matches(latest.getPassword(), passwords.current())) { // <2> |
| 186 | + request.setAttribute("error", "The provided current password doesn't match your password on file.") |
| 187 | + return "change-password" |
| 188 | + } |
| 189 | + if (!passwords.change().equals(passwords.confirm())) { // <3> |
| 190 | + request.setAttribute("error", "The new password doesn't match its confirmation.") |
| 191 | + return "change-password" |
| 192 | + } |
| 193 | + val advice = this.passwordAdvisor.advise(latest, latest.getPassword(), passwords.change()) // <4> |
| 194 | + if (PasswordAction.NONE.advisedBy(advice)) { |
| 195 | + val updated = User.withUserDetails(latest) |
| 196 | + .passwordEncoder(this.passwordEncoder::encode) |
| 197 | + .password(passwords.change()) |
| 198 | + .passwordAction(PasswordAction.NONE).build() // <5> |
| 199 | + this.users.updateUser(updated) |
| 200 | + return "forward:/logout" // <6> |
| 201 | + } |
| 202 | + request.setAttribute(error, "Your password was rejected since " + advice) |
| 203 | + return "change-password" |
| 204 | + } |
| 205 | +} |
| 206 | +---- |
| 207 | +====== |
| 208 | +<1> - Override the default `PasswordLengthAdvisor` to require a minimum length of 12 |
| 209 | +<2> - Test that the user's current password matches the provided password; note that since credentials are often erased during login, you'll need to look up the user in order to check their password |
| 210 | +<3> - Test that the new password and the confirmation fields match |
| 211 | +<4> - Test the password against various criteria |
| 212 | +<5> - If all the is met, then update the `UserDetails` object to have the new password and no more password advice |
| 213 | +<6> - Forward to /logout to get the person logged out |
0 commit comments