From ec75564f378a45e6aacf65bc0f436f00a09532b3 Mon Sep 17 00:00:00 2001 From: John Niang Date: Wed, 9 Oct 2024 17:04:57 +0800 Subject: [PATCH] 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 ``` --- .../EmailPasswordRecoveryServiceImpl.java | 2 +- .../infra/properties/SecurityProperties.java | 16 +++++++ ...ultPasswordResetAvailabilityProviders.java | 42 +++++++++++++++++++ ...mailPasswordResetAvailabilityProvider.java | 26 ++++++++++++ .../PasswordResetAvailabilityProvider.java | 30 +++++++++++++ .../PasswordResetAvailabilityProviders.java | 31 ++++++++++++++ ...=> PreAuthEmailPasswordResetEndpoint.java} | 41 ++++++++++++------ .../src/main/resources/application.yaml | 5 +++ .../images/password-reset-methods/email.svg | 1 + .../src/main/resources/static/styles/main.css | 16 +++---- .../gateway_modules/common_fragments.html | 22 +++++++++- .../common_fragments.properties | 5 ++- .../common_fragments_en.properties | 5 ++- .../common_fragments_es.properties | 5 ++- .../common_fragments_zh_TW.properties | 5 ++- .../gateway_modules/form_fragments.html | 4 +- .../gateway_modules/login_fragments.html | 4 +- .../main/resources/templates/login_local.html | 2 +- .../email/reset.html} | 0 .../email/reset.properties} | 0 .../email/reset_en.properties} | 0 .../email/reset_es.properties} | 0 .../email/reset_zh_TW.properties} | 0 .../email/send.html} | 4 +- .../email/send.properties} | 0 .../email/send_en.properties} | 0 .../email/send_es.properties} | 0 .../email/send_zh_TW.properties} | 0 28 files changed, 233 insertions(+), 33 deletions(-) create mode 100644 application/src/main/java/run/halo/app/security/preauth/DefaultPasswordResetAvailabilityProviders.java create mode 100644 application/src/main/java/run/halo/app/security/preauth/EmailPasswordResetAvailabilityProvider.java create mode 100644 application/src/main/java/run/halo/app/security/preauth/PasswordResetAvailabilityProvider.java create mode 100644 application/src/main/java/run/halo/app/security/preauth/PasswordResetAvailabilityProviders.java rename application/src/main/java/run/halo/app/security/preauth/{PreAuthPasswordResetEndpoint.java => PreAuthEmailPasswordResetEndpoint.java} (84%) create mode 100644 application/src/main/resources/static/images/password-reset-methods/email.svg rename application/src/main/resources/templates/{password-reset-link.html => password-reset/email/reset.html} (100%) rename application/src/main/resources/templates/{password-reset-link.properties => password-reset/email/reset.properties} (100%) rename application/src/main/resources/templates/{password-reset-link_en.properties => password-reset/email/reset_en.properties} (100%) rename application/src/main/resources/templates/{password-reset-link_es.properties => password-reset/email/reset_es.properties} (100%) rename application/src/main/resources/templates/{password-reset-link_zh_TW.properties => password-reset/email/reset_zh_TW.properties} (100%) rename application/src/main/resources/templates/{password-reset.html => password-reset/email/send.html} (86%) rename application/src/main/resources/templates/{password-reset.properties => password-reset/email/send.properties} (100%) rename application/src/main/resources/templates/{password-reset_en.properties => password-reset/email/send_en.properties} (100%) rename application/src/main/resources/templates/{password-reset_es.properties => password-reset/email/send_es.properties} (100%) rename application/src/main/resources/templates/{password-reset_zh_TW.properties => password-reset/email/send_zh_TW.properties} (100%) diff --git a/application/src/main/java/run/halo/app/core/user/service/impl/EmailPasswordRecoveryServiceImpl.java b/application/src/main/java/run/halo/app/core/user/service/impl/EmailPasswordRecoveryServiceImpl.java index 290acb085..b75ac59f6 100644 --- a/application/src/main/java/run/halo/app/core/user/service/impl/EmailPasswordRecoveryServiceImpl.java +++ b/application/src/main/java/run/halo/app/core/user/service/impl/EmailPasswordRecoveryServiceImpl.java @@ -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); diff --git a/application/src/main/java/run/halo/app/infra/properties/SecurityProperties.java b/application/src/main/java/run/halo/app/infra/properties/SecurityProperties.java index e10d43385..5911ac284 100644 --- a/application/src/main/java/run/halo/app/infra/properties/SecurityProperties.java +++ b/application/src/main/java/run/halo/app/infra/properties/SecurityProperties.java @@ -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 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; + + } } diff --git a/application/src/main/java/run/halo/app/security/preauth/DefaultPasswordResetAvailabilityProviders.java b/application/src/main/java/run/halo/app/security/preauth/DefaultPasswordResetAvailabilityProviders.java new file mode 100644 index 000000000..e311eddbe --- /dev/null +++ b/application/src/main/java/run/halo/app/security/preauth/DefaultPasswordResetAvailabilityProviders.java @@ -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 providers; + + public DefaultPasswordResetAvailabilityProviders(HaloProperties haloProperties, + ObjectProvider providers) { + this.securityProperties = haloProperties.getSecurity(); + this.providers = providers.orderedStream().toList(); + } + + @Override + public Flux 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)) + ); + } +} diff --git a/application/src/main/java/run/halo/app/security/preauth/EmailPasswordResetAvailabilityProvider.java b/application/src/main/java/run/halo/app/security/preauth/EmailPasswordResetAvailabilityProvider.java new file mode 100644 index 000000000..7509ac793 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/preauth/EmailPasswordResetAvailabilityProvider.java @@ -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 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); + } +} diff --git a/application/src/main/java/run/halo/app/security/preauth/PasswordResetAvailabilityProvider.java b/application/src/main/java/run/halo/app/security/preauth/PasswordResetAvailabilityProvider.java new file mode 100644 index 000000000..5f097149b --- /dev/null +++ b/application/src/main/java/run/halo/app/security/preauth/PasswordResetAvailabilityProvider.java @@ -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 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); + +} diff --git a/application/src/main/java/run/halo/app/security/preauth/PasswordResetAvailabilityProviders.java b/application/src/main/java/run/halo/app/security/preauth/PasswordResetAvailabilityProviders.java new file mode 100644 index 000000000..a88f976a4 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/preauth/PasswordResetAvailabilityProviders.java @@ -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 getAvailableMethods(); + + /** + * Get other available password reset methods. + * + * @param methodName method name + * @return other available password reset methods + */ + default Flux getOtherAvailableMethods(String methodName) { + return getAvailableMethods().filter(method -> !method.getName().equals(methodName)); + } + +} diff --git a/application/src/main/java/run/halo/app/security/preauth/PreAuthPasswordResetEndpoint.java b/application/src/main/java/run/halo/app/security/preauth/PreAuthEmailPasswordResetEndpoint.java similarity index 84% rename from application/src/main/java/run/halo/app/security/preauth/PreAuthPasswordResetEndpoint.java rename to application/src/main/java/run/halo/app/security/preauth/PreAuthEmailPasswordResetEndpoint.java index dc49b05c9..3bdee33b0 100644 --- a/application/src/main/java/run/halo/app/security/preauth/PreAuthPasswordResetEndpoint.java +++ b/application/src/main/java/run/halo/app/security/preauth/PreAuthEmailPasswordResetEndpoint.java @@ -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 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( diff --git a/application/src/main/resources/application.yaml b/application/src/main/resources/application.yaml index 810ed858e..29683cea5 100644 --- a/application/src/main/resources/application.yaml +++ b/application/src/main/resources/application.yaml @@ -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: diff --git a/application/src/main/resources/static/images/password-reset-methods/email.svg b/application/src/main/resources/static/images/password-reset-methods/email.svg new file mode 100644 index 000000000..d658b36a1 --- /dev/null +++ b/application/src/main/resources/static/images/password-reset-methods/email.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/application/src/main/resources/static/styles/main.css b/application/src/main/resources/static/styles/main.css index 0f325cd5d..c7ac03b8e 100644 --- a/application/src/main/resources/static/styles/main.css +++ b/application/src/main/resources/static/styles/main.css @@ -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; -} +} \ No newline at end of file diff --git a/application/src/main/resources/templates/gateway_modules/common_fragments.html b/application/src/main/resources/templates/gateway_modules/common_fragments.html index cb0bd48b5..ba19048ce 100644 --- a/application/src/main/resources/templates/gateway_modules/common_fragments.html +++ b/application/src/main/resources/templates/gateway_modules/common_fragments.html @@ -109,7 +109,7 @@
-
    +
    • @@ -120,6 +120,26 @@ + +