|  | 
|  | 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