diff --git a/application/src/main/java/run/halo/app/core/endpoint/console/UserEndpoint.java b/application/src/main/java/run/halo/app/core/endpoint/console/UserEndpoint.java index 861390cb0..a883b69ca 100644 --- a/application/src/main/java/run/halo/app/core/endpoint/console/UserEndpoint.java +++ b/application/src/main/java/run/halo/app/core/endpoint/console/UserEndpoint.java @@ -24,6 +24,7 @@ import io.github.resilience4j.ratelimiter.RequestNotPermitted; import io.github.resilience4j.reactor.ratelimiter.operator.RateLimiterOperator; import io.swagger.v3.oas.annotations.enums.ParameterIn; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; import java.security.Principal; import java.time.Duration; import java.util.Collection; @@ -58,6 +59,8 @@ import org.springframework.util.Assert; import org.springframework.util.CollectionUtils; import org.springframework.util.MultiValueMap; import org.springframework.util.unit.DataSize; +import org.springframework.validation.BeanPropertyBindingResult; +import org.springframework.validation.Validator; import org.springframework.web.reactive.function.BodyExtractors; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.ServerRequest; @@ -65,7 +68,6 @@ import org.springframework.web.reactive.function.server.ServerResponse; import org.springframework.web.server.ServerWebInputException; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; -import reactor.util.function.Tuples; import reactor.util.retry.Retry; import run.halo.app.core.extension.Role; import run.halo.app.core.extension.User; @@ -84,7 +86,6 @@ import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.router.SortableRequest; import run.halo.app.infra.SystemConfigurableEnvironmentFetcher; import run.halo.app.infra.SystemSetting; -import run.halo.app.infra.ValidationUtils; import run.halo.app.infra.exception.RateLimitExceededException; import run.halo.app.infra.exception.UnsatisfiedAttributeValueException; import run.halo.app.infra.utils.JsonUtils; @@ -104,6 +105,7 @@ public class UserEndpoint implements CustomEndpoint { private final EmailVerificationService emailVerificationService; private final RateLimiterRegistry rateLimiterRegistry; private final SystemConfigurableEnvironmentFetcher environmentFetcher; + private final Validator validator; @Override public RouterFunction endpoint() { @@ -280,7 +282,9 @@ public class UserEndpoint implements CustomEndpoint { .onErrorMap(RequestNotPermitted.class, RateLimitExceededException::new); } - public record EmailVerifyRequest(@Schema(requiredMode = REQUIRED) String email) { + public record EmailVerifyRequest(@Schema(requiredMode = REQUIRED) + @Email + String email) { } public record VerifyCodeRequest( @@ -289,25 +293,23 @@ public class UserEndpoint implements CustomEndpoint { } private Mono sendEmailVerificationCode(ServerRequest request) { - return request.bodyToMono(EmailVerifyRequest.class) + var emailMono = request.bodyToMono(EmailVerifyRequest.class) .switchIfEmpty(Mono.error( () -> new ServerWebInputException("Request body is required.")) ) - .doOnNext(emailRequest -> { - if (!ValidationUtils.isValidEmail(emailRequest.email())) { - throw new ServerWebInputException("Invalid email address."); + .doOnNext(emailReq -> { + var bindingResult = new BeanPropertyBindingResult(emailReq, "form"); + validator.validate(emailReq, bindingResult); + if (bindingResult.hasErrors()) { + // only email field is validated + throw new ServerWebInputException("validation.error.email.pattern"); } }) - .flatMap(emailRequest -> { - var email = emailRequest.email(); - return ReactiveSecurityContextHolder.getContext() - .map(SecurityContext::getAuthentication) - .map(Principal::getName) - .map(username -> Tuples.of(username, email)); - }) + .map(EmailVerifyRequest::email); + return Mono.zip(emailMono, getAuthenticatedUserName()) .flatMap(tuple -> { - var username = tuple.getT1(); - var email = tuple.getT2(); + var email = tuple.getT1(); + var username = tuple.getT2(); return Mono.just(username) .transformDeferred(sendEmailVerificationCodeRateLimiter(username, email)) .flatMap(u -> emailVerificationService.sendVerificationCode(username, email)) @@ -346,9 +348,7 @@ public class UserEndpoint implements CustomEndpoint { if (!SELF_USER.equals(name)) { return client.get(User.class, name); } - return ReactiveSecurityContextHolder.getContext() - .map(SecurityContext::getAuthentication) - .map(Authentication::getName) + return getAuthenticatedUserName() .flatMap(currentUserName -> client.get(User.class, currentUserName)); } @@ -501,9 +501,7 @@ public class UserEndpoint implements CustomEndpoint { } private Mono updateProfile(ServerRequest request) { - return ReactiveSecurityContextHolder.getContext() - .map(SecurityContext::getAuthentication) - .map(Authentication::getName) + return getAuthenticatedUserName() .flatMap(currentUserName -> client.get(User.class, currentUserName)) .flatMap(currentUser -> request.bodyToMono(User.class) .filter(user -> user.getMetadata() != null @@ -538,6 +536,12 @@ public class UserEndpoint implements CustomEndpoint { .flatMap(updatedUser -> ServerResponse.ok().bodyValue(updatedUser)); } + private static Mono getAuthenticatedUserName() { + return ReactiveSecurityContextHolder.getContext() + .map(SecurityContext::getAuthentication) + .map(Authentication::getName); + } + Mono changeAnyonePasswordForAdmin(ServerRequest request) { final var nameInPath = request.pathVariable("name"); return ReactiveSecurityContextHolder.getContext() diff --git a/application/src/main/java/run/halo/app/core/user/service/SignUpData.java b/application/src/main/java/run/halo/app/core/user/service/SignUpData.java index 0b5c3cba8..61e32bed0 100644 --- a/application/src/main/java/run/halo/app/core/user/service/SignUpData.java +++ b/application/src/main/java/run/halo/app/core/user/service/SignUpData.java @@ -6,6 +6,7 @@ import jakarta.validation.ConstraintValidatorContext; import jakarta.validation.Payload; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; @@ -15,6 +16,7 @@ import java.util.Optional; import lombok.Data; import org.springframework.util.MultiValueMap; import org.springframework.util.StringUtils; +import run.halo.app.infra.ValidationUtils; /** * Sign up data. @@ -27,6 +29,8 @@ import org.springframework.util.StringUtils; public class SignUpData { @NotBlank + @Pattern(regexp = ValidationUtils.NAME_REGEX, + message = "{validation.error.username.pattern}") private String username; @NotBlank @@ -38,6 +42,8 @@ public class SignUpData { private String emailCode; @NotBlank + @Pattern(regexp = ValidationUtils.PASSWORD_REGEX, + message = "{validation.error.password.pattern}") private String password; @NotBlank @@ -79,9 +85,9 @@ public class SignUpData { String message() default ""; - Class[] groups() default { }; + Class[] groups() default {}; - Class[] payload() default { }; + Class[] payload() default {}; } diff --git a/application/src/main/java/run/halo/app/core/user/service/UserServiceImpl.java b/application/src/main/java/run/halo/app/core/user/service/UserServiceImpl.java index 982765cfb..fb1b45124 100644 --- a/application/src/main/java/run/halo/app/core/user/service/UserServiceImpl.java +++ b/application/src/main/java/run/halo/app/core/user/service/UserServiceImpl.java @@ -33,8 +33,10 @@ import run.halo.app.extension.exception.ExtensionNotFoundException; import run.halo.app.extension.router.selector.FieldSelector; import run.halo.app.infra.SystemConfigurableEnvironmentFetcher; import run.halo.app.infra.SystemSetting; +import run.halo.app.infra.ValidationUtils; import run.halo.app.infra.exception.DuplicateNameException; import run.halo.app.infra.exception.EmailVerificationFailed; +import run.halo.app.infra.exception.UnsatisfiedAttributeValueException; import run.halo.app.infra.exception.UserNotFoundException; @Service @@ -86,6 +88,10 @@ public class UserServiceImpl implements UserService { @Override public Mono updateWithRawPassword(String username, String rawPassword) { + if (!ValidationUtils.PASSWORD_PATTERN.matcher(rawPassword).matches()) { + return Mono.error( + new UnsatisfiedAttributeValueException("validation.error.password.pattern")); + } return getUser(username) .filter(user -> { if (!StringUtils.hasText(user.getSpec().getPassword())) { diff --git a/application/src/main/java/run/halo/app/infra/ValidationUtils.java b/application/src/main/java/run/halo/app/infra/ValidationUtils.java index 9ce908c36..01f20e567 100644 --- a/application/src/main/java/run/halo/app/infra/ValidationUtils.java +++ b/application/src/main/java/run/halo/app/infra/ValidationUtils.java @@ -2,7 +2,6 @@ package run.halo.app.infra; import java.util.regex.Pattern; import lombok.experimental.UtilityClass; -import org.apache.commons.lang3.StringUtils; @UtilityClass public class ValidationUtils { @@ -11,36 +10,9 @@ public class ValidationUtils { public static final Pattern NAME_PATTERN = Pattern.compile(NAME_REGEX); /** - * No Chinese, no spaces. + * A-Z, a-z, 0-9, !@#$%^&* are allowed. */ - public static final String PASSWORD_REGEX = "^(?!.*[\\u4e00-\\u9fa5])(?=\\S+$).+$"; + public static final String PASSWORD_REGEX = "^[A-Za-z0-9!@#$%^&*]+$"; - public static final String EMAIL_REGEX = - "^[a-zA-Z0-9_+&*-]+(?:\\.[a-zA-Z0-9_+&*-]+)*@(?:[a-zA-Z0-9-]+\\.)+[a-zA-Z]{2,7}$"; - - public static final String NAME_VALIDATION_MESSAGE = """ - Super administrator username must be a valid subdomain name, the name must: - 1. contain no more than 63 characters - 2. contain only lowercase alphanumeric characters, '-' or '.' - 3. start with an alphanumeric character - 4. end with an alphanumeric character - """; - - /** - * Validates the name. - * - * @param name name for validation - * @return true if the name is valid - */ - public static boolean validateName(String name) { - if (StringUtils.isBlank(name)) { - return false; - } - boolean matches = NAME_PATTERN.matcher(name).matches(); - return matches && name.length() <= 63; - } - - public static boolean isValidEmail(String email) { - return StringUtils.isNotBlank(email) && email.matches(EMAIL_REGEX); - } + public static final Pattern PASSWORD_PATTERN = Pattern.compile(PASSWORD_REGEX); } diff --git a/application/src/main/java/run/halo/app/infra/exception/UnsatisfiedAttributeValueException.java b/application/src/main/java/run/halo/app/infra/exception/UnsatisfiedAttributeValueException.java index 4b74dd998..bc960653d 100644 --- a/application/src/main/java/run/halo/app/infra/exception/UnsatisfiedAttributeValueException.java +++ b/application/src/main/java/run/halo/app/infra/exception/UnsatisfiedAttributeValueException.java @@ -13,6 +13,10 @@ import org.springframework.web.server.ServerWebInputException; */ public class UnsatisfiedAttributeValueException extends ServerWebInputException { + public UnsatisfiedAttributeValueException(String reason) { + super(reason); + } + public UnsatisfiedAttributeValueException(String reason, @Nullable String messageDetailCode, @Null Object[] messageDetailArguments) { super(reason, null, null, messageDetailCode, messageDetailArguments); diff --git a/application/src/main/resources/config/i18n/messages.properties b/application/src/main/resources/config/i18n/messages.properties index 1123fcc8f..17b74dc76 100644 --- a/application/src/main/resources/config/i18n/messages.properties +++ b/application/src/main/resources/config/i18n/messages.properties @@ -88,5 +88,6 @@ title.visibility.identification.private=(Private) signup.error.confirm-password-not-match=The confirmation password does not match the password. signup.error.email-code.invalid=Invalid email code. +validation.error.email.pattern=The email format is incorrect validation.error.username.pattern=The username can only be lowercase and can only contain letters, numbers, hyphens, and dots, starting and ending with characters. -validation.error.password.pattern=The password cannot contain Chinese characters and spaces. \ No newline at end of file +validation.error.password.pattern=The password can only use uppercase and lowercase letters (A-Z, a-z), numbers (0-9), and the following special characters: !@#$%^&* diff --git a/application/src/main/resources/config/i18n/messages_zh.properties b/application/src/main/resources/config/i18n/messages_zh.properties index 3b5883143..bf8c70faa 100644 --- a/application/src/main/resources/config/i18n/messages_zh.properties +++ b/application/src/main/resources/config/i18n/messages_zh.properties @@ -61,5 +61,6 @@ title.visibility.identification.private=(私有) signup.error.confirm-password-not-match=确认密码与密码不匹配。 signup.error.email-code.invalid=邮箱验证码无效。 +validation.error.email.pattern=邮箱格式不正确 validation.error.username.pattern=用户名只能小写且只能包含字母、数字、中划线和点,以字符开头和结尾 -validation.error.password.pattern=密码不能包含中文和空格 \ No newline at end of file +validation.error.password.pattern=密码只能使用大小写字母 (A-Z, a-z)、数字 (0-9),以及以下特殊字符: !@#$%^&* \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/core/endpoint/console/EmailVerificationCodeTest.java b/application/src/test/java/run/halo/app/core/endpoint/console/EmailVerificationCodeTest.java index e5691d09c..0421bb37b 100644 --- a/application/src/test/java/run/halo/app/core/endpoint/console/EmailVerificationCodeTest.java +++ b/application/src/test/java/run/halo/app/core/endpoint/console/EmailVerificationCodeTest.java @@ -17,6 +17,7 @@ import org.springframework.http.HttpStatus; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.context.junit.jupiter.SpringExtension; import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.validation.Validator; import reactor.core.publisher.Mono; import run.halo.app.core.extension.User; import run.halo.app.core.user.service.EmailVerificationService; @@ -50,6 +51,9 @@ class EmailVerificationCodeTest { @Mock RateLimiterRegistry rateLimiterRegistry; + @Mock + Validator validator; + @InjectMocks UserEndpoint endpoint; diff --git a/application/src/test/java/run/halo/app/core/user/service/UserServiceImplTest.java b/application/src/test/java/run/halo/app/core/user/service/UserServiceImplTest.java index 93a93da7d..d01bed46d 100644 --- a/application/src/test/java/run/halo/app/core/user/service/UserServiceImplTest.java +++ b/application/src/test/java/run/halo/app/core/user/service/UserServiceImplTest.java @@ -44,6 +44,7 @@ import run.halo.app.extension.exception.ExtensionNotFoundException; import run.halo.app.infra.SystemConfigurableEnvironmentFetcher; import run.halo.app.infra.SystemSetting; import run.halo.app.infra.exception.DuplicateNameException; +import run.halo.app.infra.exception.UnsatisfiedAttributeValueException; import run.halo.app.infra.exception.UserNotFoundException; @ExtendWith(MockitoExtension.class) @@ -116,25 +117,25 @@ class UserServiceImplTest { @Test void shouldUpdatePasswordWithDifferentPassword() { - var oldUser = createUser("fake-password"); - var newUser = createUser("new-password"); + var oldUser = createUser("fake@password"); + var newUser = createUser("new@password"); when(client.get(User.class, "fake-user")).thenReturn( Mono.just(oldUser)); when(client.update(eq(oldUser))).thenReturn(Mono.just(newUser)); - when(passwordEncoder.matches("new-password", "fake-password")).thenReturn(false); - when(passwordEncoder.encode("new-password")).thenReturn("encoded-new-password"); + when(passwordEncoder.matches("new@password", "fake@password")).thenReturn(false); + when(passwordEncoder.encode("new@password")).thenReturn("encoded@new@password"); - StepVerifier.create(userService.updateWithRawPassword("fake-user", "new-password")) + StepVerifier.create(userService.updateWithRawPassword("fake-user", "new@password")) .expectNext(newUser) .verifyComplete(); - verify(passwordEncoder).matches("new-password", "fake-password"); - verify(passwordEncoder).encode("new-password"); + verify(passwordEncoder).matches("new@password", "fake@password"); + verify(passwordEncoder).encode("new@password"); verify(client).get(User.class, "fake-user"); verify(client).update(argThat(extension -> { var user = (User) extension; - return "encoded-new-password".equals(user.getSpec().getPassword()); + return "encoded@new@password".equals(user.getSpec().getPassword()); })); verify(eventPublisher).publishEvent(any(PasswordChangedEvent.class)); } @@ -142,21 +143,21 @@ class UserServiceImplTest { @Test void shouldUpdatePasswordIfNoPasswordBefore() { var oldUser = createUser(null); - var newUser = createUser("new-password"); + var newUser = createUser("new@password"); when(client.get(User.class, "fake-user")).thenReturn(Mono.just(oldUser)); when(client.update(oldUser)).thenReturn(Mono.just(newUser)); - when(passwordEncoder.encode("new-password")).thenReturn("encoded-new-password"); + when(passwordEncoder.encode("new@password")).thenReturn("encoded@new@password"); - StepVerifier.create(userService.updateWithRawPassword("fake-user", "new-password")) + StepVerifier.create(userService.updateWithRawPassword("fake-user", "new@password")) .expectNext(newUser) .verifyComplete(); - verify(passwordEncoder, never()).matches("new-password", null); - verify(passwordEncoder).encode("new-password"); + verify(passwordEncoder, never()).matches("new@password", null); + verify(passwordEncoder).encode("new@password"); verify(client).update(argThat(extension -> { var user = (User) extension; - return "encoded-new-password".equals(user.getSpec().getPassword()); + return "encoded@new@password".equals(user.getSpec().getPassword()); })); verify(client).get(User.class, "fake-user"); verify(eventPublisher).publishEvent(any(PasswordChangedEvent.class)); @@ -166,16 +167,16 @@ class UserServiceImplTest { void shouldDoNothingIfPasswordNotChanged() { userService = spy(userService); - var oldUser = createUser("fake-password"); - var newUser = createUser("new-password"); + var oldUser = createUser("fake@password"); + var newUser = createUser("new@password"); when(client.get(User.class, "fake-user")).thenReturn(Mono.just(oldUser)); - when(passwordEncoder.matches("fake-password", "fake-password")).thenReturn(true); + when(passwordEncoder.matches("fake@password", "fake@password")).thenReturn(true); - StepVerifier.create(userService.updateWithRawPassword("fake-user", "fake-password")) + StepVerifier.create(userService.updateWithRawPassword("fake-user", "fake@password")) .expectNextCount(0) .verifyComplete(); - verify(passwordEncoder, times(1)).matches("fake-password", "fake-password"); + verify(passwordEncoder, times(1)).matches("fake@password", "fake@password"); verify(passwordEncoder, never()).encode(any()); verify(client, never()).update(any()); verify(client).get(User.class, "fake-user"); @@ -188,7 +189,7 @@ class UserServiceImplTest { .thenReturn(Mono.error( new ExtensionNotFoundException(fromExtension(User.class), "fake-user"))); - StepVerifier.create(userService.updateWithRawPassword("fake-user", "new-password")) + StepVerifier.create(userService.updateWithRawPassword("fake-user", "new@password")) .verifyError(UserNotFoundException.class); verify(passwordEncoder, never()).matches(anyString(), anyString()); @@ -197,6 +198,16 @@ class UserServiceImplTest { verify(client).get(User.class, "fake-user"); } + @Test + void shouldThrowWhenPwdContainsInvalidChars() { + StepVerifier.create(userService.updateWithRawPassword("fake-user", "new-password")) + .expectError(UnsatisfiedAttributeValueException.class) + .verify(); + + verify(passwordEncoder, never()).encode(anyString()); + verify(client, never()).update(any()); + } + } User createUser(String password) { diff --git a/application/src/test/java/run/halo/app/infra/ValidationUtilsTest.java b/application/src/test/java/run/halo/app/infra/ValidationUtilsTest.java index 6fa0991c3..1a3520fdc 100644 --- a/application/src/test/java/run/halo/app/infra/ValidationUtilsTest.java +++ b/application/src/test/java/run/halo/app/infra/ValidationUtilsTest.java @@ -2,7 +2,6 @@ package run.halo.app.infra; import static org.assertj.core.api.Assertions.assertThat; -import java.util.HashMap; import org.apache.commons.lang3.StringUtils; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -19,74 +18,50 @@ class ValidationUtilsTest { class NameValidationTest { @Test void nullName() { - assertThat(ValidationUtils.validateName(null)).isFalse(); + assertThat(validateName(null)).isFalse(); } @Test void emptyUsername() { - assertThat(ValidationUtils.validateName("")).isFalse(); + assertThat(validateName("")).isFalse(); } @Test void startWithIllegalCharacter() { - assertThat(ValidationUtils.validateName("-abc")).isFalse(); + assertThat(validateName("-abc")).isFalse(); } @Test void endWithIllegalCharacter() { - assertThat(ValidationUtils.validateName("abc-")).isFalse(); - assertThat(ValidationUtils.validateName("abcD")).isFalse(); + assertThat(validateName("abc-")).isFalse(); + assertThat(validateName("abcD")).isFalse(); } @Test void middleWithIllegalCharacter() { - assertThat(ValidationUtils.validateName("ab?c")).isFalse(); + assertThat(validateName("ab?c")).isFalse(); } @Test void moreThan63Characters() { - assertThat(ValidationUtils.validateName(StringUtils.repeat('a', 64))).isFalse(); + assertThat(validateName(StringUtils.repeat('a', 64))).isFalse(); } @Test void correctUsername() { - assertThat(ValidationUtils.validateName("abc")).isTrue(); - assertThat(ValidationUtils.validateName("ab-c")).isTrue(); - assertThat(ValidationUtils.validateName("1st")).isTrue(); - assertThat(ValidationUtils.validateName("ast1")).isTrue(); - assertThat(ValidationUtils.validateName("ast-1")).isTrue(); + assertThat(validateName("abc")).isTrue(); + assertThat(validateName("ab-c")).isTrue(); + assertThat(validateName("1st")).isTrue(); + assertThat(validateName("ast1")).isTrue(); + assertThat(validateName("ast-1")).isTrue(); + } + + static boolean validateName(String name) { + if (StringUtils.isBlank(name)) { + return false; + } + boolean matches = ValidationUtils.NAME_PATTERN.matcher(name).matches(); + return matches && name.length() <= 63; } } - - @Test - void validateEmailTest() { - var cases = new HashMap(); - // Valid cases - cases.put("simple@example.com", true); - cases.put("very.common@example.com", true); - cases.put("disposable.style.email.with+symbol@example.com", true); - cases.put("other.email-with-hyphen@example.com", true); - cases.put("fully-qualified-domain@example.com", true); - cases.put("user.name+tag+sorting@example.com", true); - cases.put("x@example.com", true); - cases.put("example-indeed@strange-example.com", true); - cases.put("example@s.example", true); - cases.put("john.doe@example.com", true); - cases.put("a.little.lengthy.but.fine@dept.example.com", true); - cases.put("123ada@halo.co", true); - cases.put("23ad@halo.top", true); - - // Invalid cases - cases.put("Abc.example.com", false); - cases.put("admin@mailserver1", false); - cases.put("\" \"@example.org", false); - cases.put("A@b@c@example.com", false); - cases.put("a\"b(c)d,e:f;gi[j\\k]l@example.com", false); - cases.put("just\"not\"right@example.com", false); - cases.put("this is\"not\\allowed@example.com", false); - cases.put("this\\ still\\\"not\\\\allowed@example.com", false); - cases.put("123456789012345678901234567890123456789012345", false); - cases.forEach((email, expected) -> assertThat(ValidationUtils.isValidEmail(email)) - .isEqualTo(expected)); - } -} \ No newline at end of file +}