mirror of https://github.com/halo-dev/halo
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
parent
0e4a19d182
commit
ec75564f37
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
|
||||
}
|
|
@ -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));
|
||||
}
|
||||
|
||||
}
|
|
@ -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(
|
|
@ -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:
|
||||
|
|
|
@ -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 |
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -6,4 +6,7 @@ signupNotice.description=没有账号?
|
|||
signupNotice.link=立即注册
|
||||
loginNotice.description=已有账号,
|
||||
loginNotice.link=立即登录
|
||||
returnToSite=返回网站
|
||||
returnToSite=返回网站
|
||||
|
||||
passwordResetMethods.label=其他重置方式
|
||||
passwordResetMethods.email.displayName=通过邮件重置
|
|
@ -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
|
|
@ -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
|
|
@ -6,4 +6,7 @@ signupNotice.description=沒有帳號?
|
|||
signupNotice.link=立即註冊
|
||||
loginNotice.description=已有帳號,
|
||||
loginNotice.link=立即登入
|
||||
returnToSite=返回網站
|
||||
returnToSite=返回網站
|
||||
|
||||
passwordResetMethods.label=其他重置方式
|
||||
passwordResetMethods.email.displayName=通過郵件重置
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
Loading…
Reference in New Issue