Refactor password reset for extensibility (#6803)

#### What type of PR is this?

/kind improvement
/area core
/milestone 2.20.x

#### What this PR does / why we need it:

This PR refactors password reset for extensibility. If we want to add another password reset method, first thing we need to do is adding a new password reset method into `halo.security.password-reset-methods[]` and then defining PasswordResetAvailabilityProvider bean.

#### Does this PR introduce a user-facing change?

```release-note
None
```
pull/6806/head
John Niang 2024-10-09 17:04:57 +08:00 committed by GitHub
parent 0e4a19d182
commit ec75564f37
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 233 additions and 33 deletions

View File

@ -115,7 +115,7 @@ public class EmailPasswordRecoveryServiceImpl implements EmailPasswordRecoverySe
var tokenHash = hashToken(token);
var expiresAt = clock.instant().plus(RESET_TOKEN_LIFE_TIME);
var uri = UriComponentsBuilder.fromUriString("/")
.pathSegment("password-reset", token)
.pathSegment("password-reset", "email", token)
.build(true)
.toUri();
var resetToken = new ResetToken(tokenHash, username, expiresAt);

View File

@ -2,7 +2,10 @@ package run.halo.app.infra.properties;
import static org.springframework.security.web.server.header.ReferrerPolicyServerHttpHeadersWriter.ReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN;
import java.net.URI;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import lombok.Data;
import org.springframework.security.web.server.header.ReferrerPolicyServerHttpHeadersWriter.ReferrerPolicy;
import org.springframework.security.web.server.header.XFrameOptionsServerHttpHeadersWriter.Mode;
@ -20,6 +23,8 @@ public class SecurityProperties {
private final BasicAuthOptions basicAuth = new BasicAuthOptions();
private final List<PasswordResetMethod> passwordResetMethods = new ArrayList<>();
@Data
public static class BasicAuthOptions {
/**
@ -57,4 +62,15 @@ public class SecurityProperties {
public static class RememberMeOptions {
private Duration tokenValidity = Duration.ofDays(14);
}
@Data
public static class PasswordResetMethod {
private String name;
private URI href;
private URI icon;
}
}

View File

@ -0,0 +1,42 @@
package run.halo.app.security.preauth;
import java.util.List;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import run.halo.app.infra.properties.HaloProperties;
import run.halo.app.infra.properties.SecurityProperties;
import run.halo.app.infra.properties.SecurityProperties.PasswordResetMethod;
/**
* Default password reset availability providers.
*
* @author johnniang
* @since 2.20.0
*/
@Component
public class DefaultPasswordResetAvailabilityProviders
implements PasswordResetAvailabilityProviders {
private final SecurityProperties securityProperties;
private final List<PasswordResetAvailabilityProvider> providers;
public DefaultPasswordResetAvailabilityProviders(HaloProperties haloProperties,
ObjectProvider<PasswordResetAvailabilityProvider> providers) {
this.securityProperties = haloProperties.getSecurity();
this.providers = providers.orderedStream().toList();
}
@Override
public Flux<PasswordResetMethod> getAvailableMethods() {
return Flux.fromIterable(securityProperties.getPasswordResetMethods())
.filterWhen(method -> providers.stream()
.filter(provider -> provider.support(method.getName()))
.findFirst()
.map(provider -> provider.isAvailable(method))
.orElseGet(() -> Mono.just(false))
);
}
}

View File

@ -0,0 +1,26 @@
package run.halo.app.security.preauth;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
import run.halo.app.infra.properties.SecurityProperties;
/**
* Email password reset availability provider.
*
* @author johnniang
* @since 2.20.0
*/
@Component
public class EmailPasswordResetAvailabilityProvider implements PasswordResetAvailabilityProvider {
@Override
public Mono<Boolean> isAvailable(SecurityProperties.PasswordResetMethod method) {
// TODO Check the email notifier is available in the future
return Mono.just(true);
}
@Override
public boolean support(String name) {
return "email".equals(name);
}
}

View File

@ -0,0 +1,30 @@
package run.halo.app.security.preauth;
import reactor.core.publisher.Mono;
import run.halo.app.infra.properties.SecurityProperties;
/**
* Password reset availability provider.
*
* @author johnniang
* @since 2.20.0
*/
public interface PasswordResetAvailabilityProvider {
/**
* Check if the password reset method is available.
*
* @param method password reset method
* @return true if available, false otherwise
*/
Mono<Boolean> isAvailable(SecurityProperties.PasswordResetMethod method);
/**
* Check if the provider supports the name.
*
* @param name password reset method name
* @return true if supports, false otherwise
*/
boolean support(String name);
}

View File

@ -0,0 +1,31 @@
package run.halo.app.security.preauth;
import reactor.core.publisher.Flux;
import run.halo.app.infra.properties.SecurityProperties.PasswordResetMethod;
/**
* Password reset availability providers.
*
* @author johnniang
* @since 2.20.0
*/
public interface PasswordResetAvailabilityProviders {
/**
* Get available password reset methods.
*
* @return available password reset methods
*/
Flux<PasswordResetMethod> getAvailableMethods();
/**
* Get other available password reset methods.
*
* @param methodName method name
* @return other available password reset methods
*/
default Flux<PasswordResetMethod> getOtherAvailableMethods(String methodName) {
return getAvailableMethods().filter(method -> !method.getName().equals(methodName));
}
}

View File

@ -33,7 +33,10 @@ import run.halo.app.infra.utils.IpAddressUtils;
* @since 2.20.0
*/
@Component
class PreAuthPasswordResetEndpoint {
class PreAuthEmailPasswordResetEndpoint {
private static final String SEND_TEMPLATE = "password-reset/email/send";
private static final String RESET_TEMPLATE = "password-reset/email/reset";
private final EmailPasswordRecoveryService emailPasswordRecoveryService;
@ -41,32 +44,44 @@ class PreAuthPasswordResetEndpoint {
private final RateLimiterRegistry rateLimiterRegistry;
public PreAuthPasswordResetEndpoint(EmailPasswordRecoveryService emailPasswordRecoveryService,
private final PasswordResetAvailabilityProviders passwordResetAvailabilityProviders;
public PreAuthEmailPasswordResetEndpoint(
EmailPasswordRecoveryService emailPasswordRecoveryService,
MessageSource messageSource,
RateLimiterRegistry rateLimiterRegistry
RateLimiterRegistry rateLimiterRegistry,
PasswordResetAvailabilityProviders passwordResetAvailabilityProviders
) {
this.emailPasswordRecoveryService = emailPasswordRecoveryService;
this.messageSource = messageSource;
this.rateLimiterRegistry = rateLimiterRegistry;
this.passwordResetAvailabilityProviders = passwordResetAvailabilityProviders;
}
@Bean
RouterFunction<ServerResponse> preAuthPasswordResetEndpoints() {
return RouterFunctions.nest(path("/password-reset"), RouterFunctions.route()
.GET("", request -> ServerResponse.ok().render("password-reset"))
return RouterFunctions.nest(path("/password-reset/email"), RouterFunctions.route()
.GET("", request -> {
return ServerResponse.ok().render(SEND_TEMPLATE, Map.of(
"otherMethods",
passwordResetAvailabilityProviders.getOtherAvailableMethods("email")
));
})
.GET("/{resetToken}",
request -> {
var token = request.pathVariable("resetToken");
return emailPasswordRecoveryService.getValidResetToken(token)
.flatMap(resetToken -> {
// TODO Check the 2FA of the user
return ServerResponse.ok().render("password-reset-link", Map.of(
return ServerResponse.ok().render(RESET_TEMPLATE, Map.of(
"username", resetToken.username()
));
})
.onErrorResume(InvalidResetTokenException.class,
e -> ServerResponse.status(HttpStatus.FOUND)
.location(URI.create("/password-reset"))
.location(
URI.create("/password-reset/email?error=invalid_reset_token")
)
.build()
.transformDeferred(rateLimiterForPasswordResetVerification(
request.exchange().getRequest()
@ -94,7 +109,7 @@ class PreAuthPasswordResetEndpoint {
"Password can't be blank",
locale
);
return ServerResponse.ok().render("password-reset-link", Map.of(
return ServerResponse.ok().render(RESET_TEMPLATE, Map.of(
"error", error
));
}
@ -105,13 +120,13 @@ class PreAuthPasswordResetEndpoint {
"Password and confirm password mismatch",
locale
);
return ServerResponse.ok().render("password-reset-link", Map.of(
return ServerResponse.ok().render(RESET_TEMPLATE, Map.of(
"error", error
));
}
return emailPasswordRecoveryService.changePassword(password, token)
.then(ServerResponse.status(HttpStatus.FOUND)
.location(URI.create("/login?passwordReset"))
.location(URI.create("/login?password_reset"))
.build()
)
.onErrorResume(InvalidResetTokenException.class, e -> {
@ -121,7 +136,7 @@ class PreAuthPasswordResetEndpoint {
"Invalid reset token",
locale
);
return ServerResponse.ok().render("password-reset-link", Map.of(
return ServerResponse.ok().render(RESET_TEMPLATE, Map.of(
"error", error
)).transformDeferred(rateLimiterForPasswordResetVerification(
request.exchange().getRequest()
@ -148,12 +163,12 @@ class PreAuthPasswordResetEndpoint {
"Email can't be blank",
locale
);
return ServerResponse.ok().render("password-reset", Map.of(
return ServerResponse.ok().render(SEND_TEMPLATE, Map.of(
"error", error
));
}
return emailPasswordRecoveryService.sendPasswordResetEmail(email)
.then(ServerResponse.ok().render("password-reset", Map.of(
.then(ServerResponse.ok().render(SEND_TEMPLATE, Map.of(
"sent", true
)))
.transformDeferred(rateLimiterForSendPasswordResetEmail(

View File

@ -42,6 +42,11 @@ halo:
- pathPattern: /upload/**
locations:
- migrate-from-1.x
security:
password-reset-methods:
- name: email
href: /password-reset/email
icon: /images/password-reset-methods/email.svg
springdoc:
api-docs:

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="rgba(75,85,99,1)"><path d="M3 3H21C21.5523 3 22 3.44772 22 4V20C22 20.5523 21.5523 21 21 21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3ZM20 7.23792L12.0718 14.338L4 7.21594V19H20V7.23792ZM4.51146 5L12.0619 11.662L19.501 5H4.51146Z"></path></svg>

After

Width:  |  Height:  |  Size: 321 B

View File

@ -283,7 +283,7 @@
background: #f9fafb;
}
.auth-provider-items {
.pill-items {
all: unset;
gap: var(--spacing-sm);
margin: 0;
@ -292,7 +292,7 @@
justify-content: center;
}
.auth-provider-items li {
.pill-items li {
all: unset;
border-radius: var(--rounded-lg);
overflow: hidden;
@ -302,7 +302,7 @@
transition-duration: 0.15s;
}
.auth-provider-items li a {
.pill-items li a {
gap: var(--spacing-sm);
font-size: var(--text-sm);
padding: 0.7em 1em;
@ -312,21 +312,21 @@
text-decoration: none;
}
.auth-provider-items li img {
.pill-items li img {
width: 1.5em;
height: 1.5em;
}
.auth-provider-items li:hover {
.pill-items li:hover {
border-color: var(--color-primary);
background: #f3f4f6;
}
.auth-provider-items li:hover a {
.pill-items li:hover a {
color: #111827;
}
.auth-provider-items li:focus-within {
.pill-items li:focus-within {
border-color: var(--color-primary);
}
@ -415,4 +415,4 @@
::-ms-reveal {
display: none;
}
}

View File

@ -109,7 +109,7 @@
<th:block th:text="#{socialLogin.label}"></th:block>
<hr />
</div>
<ul class="auth-provider-items">
<ul class="pill-items">
<li th:each="provider : ${socialAuthProviders}">
<a th:href="${provider.spec.authenticationUrl}">
<img th:src="${provider.spec.logo}" />
@ -120,6 +120,26 @@
</th:block>
</div>
<div th:remove="tag" th:fragment="passwordResetMethods">
<th:block th:unless="${#lists.isEmpty(otherMethods)}">
<div class="divider-wrapper">
<hr />
<th:block th:text="#{passwordResetMethods.label}"></th:block>
<hr />
</div>
<ul class="pill-items">
<li th:each="method : ${otherMethods}">
<a th:href="${method.href}">
<img th:src="${method.icon}" />
<span
th:text="${#messages.msgOrNull('passwordResetMethods.' + method.name + '.displayName') ?: method.name}"
></span>
</a>
</li>
</ul>
</th:block>
</div>
<div th:remove="tag" th:fragment="signupNoticeContent">
<style>
.signup-notice-content {

View File

@ -6,4 +6,7 @@ signupNotice.description=没有账号?
signupNotice.link=立即注册
loginNotice.description=已有账号,
loginNotice.link=立即登录
returnToSite=返回网站
returnToSite=返回网站
passwordResetMethods.label=其他重置方式
passwordResetMethods.email.displayName=通过邮件重置

View File

@ -6,4 +6,7 @@ signupNotice.description=Don't have an account?
signupNotice.link=Sign up
loginNotice.description=Already have an account,
loginNotice.link=Login now
returnToSite=Return to site
returnToSite=Return to site
passwordResetMethods.label=Other Reset Methods
passwordResetMethods.email.displayName=Reset via Email

View File

@ -6,4 +6,7 @@ signupNotice.description=¿No tienes una cuenta?
signupNotice.link=Regístrate ahora
loginNotice.description=Ya tienes una cuenta,
loginNotice.link=Inicia sesión ahora
returnToSite=Volver al sitio
returnToSite=Volver al sitio
passwordResetMethods.label=Otros Métodos de Restablecimiento
passwordResetMethods.email.displayName=Restablecer por Correo Electrónico

View File

@ -6,4 +6,7 @@ signupNotice.description=沒有帳號?
signupNotice.link=立即註冊
loginNotice.description=已有帳號,
loginNotice.link=立即登入
returnToSite=返回網站
returnToSite=返回網站
passwordResetMethods.label=其他重置方式
passwordResetMethods.email.displayName=通過郵件重置

View File

@ -245,7 +245,7 @@
<form
th:fragment="passwordResetLink"
class="halo-form"
th:action="@{/password-reset/{resetToken}(resetToken=${resetToken})}"
th:action="@{/password-reset/email/{resetToken}(resetToken=${resetToken})}"
method="post"
>
<div class="alert alert-error" role="alert" th:if="${error}">
@ -280,7 +280,7 @@
<button type="submit" th:text="#{form.passwordReset.sent.submit}"></button>
</div>
</form>
<form th:unless="${sent}" class="halo-form" th:action="@{/password-reset}" method="post">
<form th:unless="${sent}" class="halo-form" th:action="@{/password-reset/email}" method="post">
<div class="alert alert-error" th:if="${error}">
<strong th:text="${error}"></strong>
</div>

View File

@ -6,7 +6,7 @@
<th:block th:text="#{otherLogin.label}"></th:block>
<hr />
</div>
<ul class="auth-provider-items">
<ul class="pill-items">
<li th:each="provider : ${formAuthProviders}">
<a th:href="'/login?method=' + ${provider.metadata.name}">
<img th:src="${provider.spec.logo}" />
@ -17,4 +17,4 @@
</li>
</ul>
</th:block>
</div>
</div>

View File

@ -45,7 +45,7 @@
<a
class="form-item-extra-link"
tabindex="-1"
th:href="@{/password-reset}"
th:href="@{/password-reset/email}"
th:text="#{form.password.forgot}"
>
</a>

View File

@ -9,9 +9,11 @@
<div class="halo-form-wrapper">
<h1 class="form-title" th:text="${sent} ? #{sent.title} : #{title}"></h1>
<form th:replace="~{gateway_modules/form_fragments::passwordReset}"></form>
<div th:replace="~{gateway_modules/common_fragments::passwordResetMethods}"></div>
</div>
<div th:replace="~{gateway_modules/common_fragments::languageSwitcher}"></div>
</div>
</th:block>
</html>
</html>