mirror of https://github.com/halo-dev/halo
Merge pull request #6804 from guqing/refactor/user-validation
refactor: unified validation for username and password formatpull/6806/head
commit
158c3e8a9e
|
@ -24,6 +24,7 @@ import io.github.resilience4j.ratelimiter.RequestNotPermitted;
|
||||||
import io.github.resilience4j.reactor.ratelimiter.operator.RateLimiterOperator;
|
import io.github.resilience4j.reactor.ratelimiter.operator.RateLimiterOperator;
|
||||||
import io.swagger.v3.oas.annotations.enums.ParameterIn;
|
import io.swagger.v3.oas.annotations.enums.ParameterIn;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import jakarta.validation.constraints.Email;
|
||||||
import java.security.Principal;
|
import java.security.Principal;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
|
@ -58,6 +59,8 @@ import org.springframework.util.Assert;
|
||||||
import org.springframework.util.CollectionUtils;
|
import org.springframework.util.CollectionUtils;
|
||||||
import org.springframework.util.MultiValueMap;
|
import org.springframework.util.MultiValueMap;
|
||||||
import org.springframework.util.unit.DataSize;
|
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.BodyExtractors;
|
||||||
import org.springframework.web.reactive.function.server.RouterFunction;
|
import org.springframework.web.reactive.function.server.RouterFunction;
|
||||||
import org.springframework.web.reactive.function.server.ServerRequest;
|
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 org.springframework.web.server.ServerWebInputException;
|
||||||
import reactor.core.publisher.Flux;
|
import reactor.core.publisher.Flux;
|
||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
import reactor.util.function.Tuples;
|
|
||||||
import reactor.util.retry.Retry;
|
import reactor.util.retry.Retry;
|
||||||
import run.halo.app.core.extension.Role;
|
import run.halo.app.core.extension.Role;
|
||||||
import run.halo.app.core.extension.User;
|
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.extension.router.SortableRequest;
|
||||||
import run.halo.app.infra.SystemConfigurableEnvironmentFetcher;
|
import run.halo.app.infra.SystemConfigurableEnvironmentFetcher;
|
||||||
import run.halo.app.infra.SystemSetting;
|
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.RateLimitExceededException;
|
||||||
import run.halo.app.infra.exception.UnsatisfiedAttributeValueException;
|
import run.halo.app.infra.exception.UnsatisfiedAttributeValueException;
|
||||||
import run.halo.app.infra.utils.JsonUtils;
|
import run.halo.app.infra.utils.JsonUtils;
|
||||||
|
@ -104,6 +105,7 @@ public class UserEndpoint implements CustomEndpoint {
|
||||||
private final EmailVerificationService emailVerificationService;
|
private final EmailVerificationService emailVerificationService;
|
||||||
private final RateLimiterRegistry rateLimiterRegistry;
|
private final RateLimiterRegistry rateLimiterRegistry;
|
||||||
private final SystemConfigurableEnvironmentFetcher environmentFetcher;
|
private final SystemConfigurableEnvironmentFetcher environmentFetcher;
|
||||||
|
private final Validator validator;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public RouterFunction<ServerResponse> endpoint() {
|
public RouterFunction<ServerResponse> endpoint() {
|
||||||
|
@ -280,7 +282,9 @@ public class UserEndpoint implements CustomEndpoint {
|
||||||
.onErrorMap(RequestNotPermitted.class, RateLimitExceededException::new);
|
.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(
|
public record VerifyCodeRequest(
|
||||||
|
@ -289,25 +293,23 @@ public class UserEndpoint implements CustomEndpoint {
|
||||||
}
|
}
|
||||||
|
|
||||||
private Mono<ServerResponse> sendEmailVerificationCode(ServerRequest request) {
|
private Mono<ServerResponse> sendEmailVerificationCode(ServerRequest request) {
|
||||||
return request.bodyToMono(EmailVerifyRequest.class)
|
var emailMono = request.bodyToMono(EmailVerifyRequest.class)
|
||||||
.switchIfEmpty(Mono.error(
|
.switchIfEmpty(Mono.error(
|
||||||
() -> new ServerWebInputException("Request body is required."))
|
() -> new ServerWebInputException("Request body is required."))
|
||||||
)
|
)
|
||||||
.doOnNext(emailRequest -> {
|
.doOnNext(emailReq -> {
|
||||||
if (!ValidationUtils.isValidEmail(emailRequest.email())) {
|
var bindingResult = new BeanPropertyBindingResult(emailReq, "form");
|
||||||
throw new ServerWebInputException("Invalid email address.");
|
validator.validate(emailReq, bindingResult);
|
||||||
|
if (bindingResult.hasErrors()) {
|
||||||
|
// only email field is validated
|
||||||
|
throw new ServerWebInputException("validation.error.email.pattern");
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.flatMap(emailRequest -> {
|
.map(EmailVerifyRequest::email);
|
||||||
var email = emailRequest.email();
|
return Mono.zip(emailMono, getAuthenticatedUserName())
|
||||||
return ReactiveSecurityContextHolder.getContext()
|
|
||||||
.map(SecurityContext::getAuthentication)
|
|
||||||
.map(Principal::getName)
|
|
||||||
.map(username -> Tuples.of(username, email));
|
|
||||||
})
|
|
||||||
.flatMap(tuple -> {
|
.flatMap(tuple -> {
|
||||||
var username = tuple.getT1();
|
var email = tuple.getT1();
|
||||||
var email = tuple.getT2();
|
var username = tuple.getT2();
|
||||||
return Mono.just(username)
|
return Mono.just(username)
|
||||||
.transformDeferred(sendEmailVerificationCodeRateLimiter(username, email))
|
.transformDeferred(sendEmailVerificationCodeRateLimiter(username, email))
|
||||||
.flatMap(u -> emailVerificationService.sendVerificationCode(username, email))
|
.flatMap(u -> emailVerificationService.sendVerificationCode(username, email))
|
||||||
|
@ -346,9 +348,7 @@ public class UserEndpoint implements CustomEndpoint {
|
||||||
if (!SELF_USER.equals(name)) {
|
if (!SELF_USER.equals(name)) {
|
||||||
return client.get(User.class, name);
|
return client.get(User.class, name);
|
||||||
}
|
}
|
||||||
return ReactiveSecurityContextHolder.getContext()
|
return getAuthenticatedUserName()
|
||||||
.map(SecurityContext::getAuthentication)
|
|
||||||
.map(Authentication::getName)
|
|
||||||
.flatMap(currentUserName -> client.get(User.class, currentUserName));
|
.flatMap(currentUserName -> client.get(User.class, currentUserName));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -501,9 +501,7 @@ public class UserEndpoint implements CustomEndpoint {
|
||||||
}
|
}
|
||||||
|
|
||||||
private Mono<ServerResponse> updateProfile(ServerRequest request) {
|
private Mono<ServerResponse> updateProfile(ServerRequest request) {
|
||||||
return ReactiveSecurityContextHolder.getContext()
|
return getAuthenticatedUserName()
|
||||||
.map(SecurityContext::getAuthentication)
|
|
||||||
.map(Authentication::getName)
|
|
||||||
.flatMap(currentUserName -> client.get(User.class, currentUserName))
|
.flatMap(currentUserName -> client.get(User.class, currentUserName))
|
||||||
.flatMap(currentUser -> request.bodyToMono(User.class)
|
.flatMap(currentUser -> request.bodyToMono(User.class)
|
||||||
.filter(user -> user.getMetadata() != null
|
.filter(user -> user.getMetadata() != null
|
||||||
|
@ -538,6 +536,12 @@ public class UserEndpoint implements CustomEndpoint {
|
||||||
.flatMap(updatedUser -> ServerResponse.ok().bodyValue(updatedUser));
|
.flatMap(updatedUser -> ServerResponse.ok().bodyValue(updatedUser));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static Mono<String> getAuthenticatedUserName() {
|
||||||
|
return ReactiveSecurityContextHolder.getContext()
|
||||||
|
.map(SecurityContext::getAuthentication)
|
||||||
|
.map(Authentication::getName);
|
||||||
|
}
|
||||||
|
|
||||||
Mono<ServerResponse> changeAnyonePasswordForAdmin(ServerRequest request) {
|
Mono<ServerResponse> changeAnyonePasswordForAdmin(ServerRequest request) {
|
||||||
final var nameInPath = request.pathVariable("name");
|
final var nameInPath = request.pathVariable("name");
|
||||||
return ReactiveSecurityContextHolder.getContext()
|
return ReactiveSecurityContextHolder.getContext()
|
||||||
|
|
|
@ -6,6 +6,7 @@ import jakarta.validation.ConstraintValidatorContext;
|
||||||
import jakarta.validation.Payload;
|
import jakarta.validation.Payload;
|
||||||
import jakarta.validation.constraints.Email;
|
import jakarta.validation.constraints.Email;
|
||||||
import jakarta.validation.constraints.NotBlank;
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.Pattern;
|
||||||
import java.lang.annotation.ElementType;
|
import java.lang.annotation.ElementType;
|
||||||
import java.lang.annotation.Retention;
|
import java.lang.annotation.Retention;
|
||||||
import java.lang.annotation.RetentionPolicy;
|
import java.lang.annotation.RetentionPolicy;
|
||||||
|
@ -15,6 +16,7 @@ import java.util.Optional;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import org.springframework.util.MultiValueMap;
|
import org.springframework.util.MultiValueMap;
|
||||||
import org.springframework.util.StringUtils;
|
import org.springframework.util.StringUtils;
|
||||||
|
import run.halo.app.infra.ValidationUtils;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sign up data.
|
* Sign up data.
|
||||||
|
@ -27,6 +29,8 @@ import org.springframework.util.StringUtils;
|
||||||
public class SignUpData {
|
public class SignUpData {
|
||||||
|
|
||||||
@NotBlank
|
@NotBlank
|
||||||
|
@Pattern(regexp = ValidationUtils.NAME_REGEX,
|
||||||
|
message = "{validation.error.username.pattern}")
|
||||||
private String username;
|
private String username;
|
||||||
|
|
||||||
@NotBlank
|
@NotBlank
|
||||||
|
@ -38,6 +42,8 @@ public class SignUpData {
|
||||||
private String emailCode;
|
private String emailCode;
|
||||||
|
|
||||||
@NotBlank
|
@NotBlank
|
||||||
|
@Pattern(regexp = ValidationUtils.PASSWORD_REGEX,
|
||||||
|
message = "{validation.error.password.pattern}")
|
||||||
private String password;
|
private String password;
|
||||||
|
|
||||||
@NotBlank
|
@NotBlank
|
||||||
|
@ -79,9 +85,9 @@ public class SignUpData {
|
||||||
|
|
||||||
String message() default "";
|
String message() default "";
|
||||||
|
|
||||||
Class<?>[] groups() default { };
|
Class<?>[] groups() default {};
|
||||||
|
|
||||||
Class<? extends Payload>[] payload() default { };
|
Class<? extends Payload>[] payload() default {};
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -33,8 +33,10 @@ import run.halo.app.extension.exception.ExtensionNotFoundException;
|
||||||
import run.halo.app.extension.router.selector.FieldSelector;
|
import run.halo.app.extension.router.selector.FieldSelector;
|
||||||
import run.halo.app.infra.SystemConfigurableEnvironmentFetcher;
|
import run.halo.app.infra.SystemConfigurableEnvironmentFetcher;
|
||||||
import run.halo.app.infra.SystemSetting;
|
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.DuplicateNameException;
|
||||||
import run.halo.app.infra.exception.EmailVerificationFailed;
|
import run.halo.app.infra.exception.EmailVerificationFailed;
|
||||||
|
import run.halo.app.infra.exception.UnsatisfiedAttributeValueException;
|
||||||
import run.halo.app.infra.exception.UserNotFoundException;
|
import run.halo.app.infra.exception.UserNotFoundException;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
|
@ -86,6 +88,10 @@ public class UserServiceImpl implements UserService {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Mono<User> updateWithRawPassword(String username, String rawPassword) {
|
public Mono<User> updateWithRawPassword(String username, String rawPassword) {
|
||||||
|
if (!ValidationUtils.PASSWORD_PATTERN.matcher(rawPassword).matches()) {
|
||||||
|
return Mono.error(
|
||||||
|
new UnsatisfiedAttributeValueException("validation.error.password.pattern"));
|
||||||
|
}
|
||||||
return getUser(username)
|
return getUser(username)
|
||||||
.filter(user -> {
|
.filter(user -> {
|
||||||
if (!StringUtils.hasText(user.getSpec().getPassword())) {
|
if (!StringUtils.hasText(user.getSpec().getPassword())) {
|
||||||
|
|
|
@ -2,7 +2,6 @@ package run.halo.app.infra;
|
||||||
|
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
import lombok.experimental.UtilityClass;
|
import lombok.experimental.UtilityClass;
|
||||||
import org.apache.commons.lang3.StringUtils;
|
|
||||||
|
|
||||||
@UtilityClass
|
@UtilityClass
|
||||||
public class ValidationUtils {
|
public class ValidationUtils {
|
||||||
|
@ -11,36 +10,9 @@ public class ValidationUtils {
|
||||||
public static final Pattern NAME_PATTERN = Pattern.compile(NAME_REGEX);
|
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 =
|
public static final Pattern PASSWORD_PATTERN = Pattern.compile(PASSWORD_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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,6 +13,10 @@ import org.springframework.web.server.ServerWebInputException;
|
||||||
*/
|
*/
|
||||||
public class UnsatisfiedAttributeValueException extends ServerWebInputException {
|
public class UnsatisfiedAttributeValueException extends ServerWebInputException {
|
||||||
|
|
||||||
|
public UnsatisfiedAttributeValueException(String reason) {
|
||||||
|
super(reason);
|
||||||
|
}
|
||||||
|
|
||||||
public UnsatisfiedAttributeValueException(String reason, @Nullable String messageDetailCode,
|
public UnsatisfiedAttributeValueException(String reason, @Nullable String messageDetailCode,
|
||||||
@Null Object[] messageDetailArguments) {
|
@Null Object[] messageDetailArguments) {
|
||||||
super(reason, null, null, messageDetailCode, messageDetailArguments);
|
super(reason, null, null, messageDetailCode, messageDetailArguments);
|
||||||
|
|
|
@ -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.confirm-password-not-match=The confirmation password does not match the password.
|
||||||
signup.error.email-code.invalid=Invalid email code.
|
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.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.
|
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: !@#$%^&*
|
||||||
|
|
|
@ -61,5 +61,6 @@ title.visibility.identification.private=(私有)
|
||||||
signup.error.confirm-password-not-match=确认密码与密码不匹配。
|
signup.error.confirm-password-not-match=确认密码与密码不匹配。
|
||||||
signup.error.email-code.invalid=邮箱验证码无效。
|
signup.error.email-code.invalid=邮箱验证码无效。
|
||||||
|
|
||||||
|
validation.error.email.pattern=邮箱格式不正确
|
||||||
validation.error.username.pattern=用户名只能小写且只能包含字母、数字、中划线和点,以字符开头和结尾
|
validation.error.username.pattern=用户名只能小写且只能包含字母、数字、中划线和点,以字符开头和结尾
|
||||||
validation.error.password.pattern=密码不能包含中文和空格
|
validation.error.password.pattern=密码只能使用大小写字母 (A-Z, a-z)、数字 (0-9),以及以下特殊字符: !@#$%^&*
|
|
@ -17,6 +17,7 @@ import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.security.test.context.support.WithMockUser;
|
import org.springframework.security.test.context.support.WithMockUser;
|
||||||
import org.springframework.test.context.junit.jupiter.SpringExtension;
|
import org.springframework.test.context.junit.jupiter.SpringExtension;
|
||||||
import org.springframework.test.web.reactive.server.WebTestClient;
|
import org.springframework.test.web.reactive.server.WebTestClient;
|
||||||
|
import org.springframework.validation.Validator;
|
||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
import run.halo.app.core.extension.User;
|
import run.halo.app.core.extension.User;
|
||||||
import run.halo.app.core.user.service.EmailVerificationService;
|
import run.halo.app.core.user.service.EmailVerificationService;
|
||||||
|
@ -50,6 +51,9 @@ class EmailVerificationCodeTest {
|
||||||
@Mock
|
@Mock
|
||||||
RateLimiterRegistry rateLimiterRegistry;
|
RateLimiterRegistry rateLimiterRegistry;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
Validator validator;
|
||||||
|
|
||||||
@InjectMocks
|
@InjectMocks
|
||||||
UserEndpoint endpoint;
|
UserEndpoint endpoint;
|
||||||
|
|
||||||
|
|
|
@ -44,6 +44,7 @@ import run.halo.app.extension.exception.ExtensionNotFoundException;
|
||||||
import run.halo.app.infra.SystemConfigurableEnvironmentFetcher;
|
import run.halo.app.infra.SystemConfigurableEnvironmentFetcher;
|
||||||
import run.halo.app.infra.SystemSetting;
|
import run.halo.app.infra.SystemSetting;
|
||||||
import run.halo.app.infra.exception.DuplicateNameException;
|
import run.halo.app.infra.exception.DuplicateNameException;
|
||||||
|
import run.halo.app.infra.exception.UnsatisfiedAttributeValueException;
|
||||||
import run.halo.app.infra.exception.UserNotFoundException;
|
import run.halo.app.infra.exception.UserNotFoundException;
|
||||||
|
|
||||||
@ExtendWith(MockitoExtension.class)
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
@ -116,25 +117,25 @@ class UserServiceImplTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void shouldUpdatePasswordWithDifferentPassword() {
|
void shouldUpdatePasswordWithDifferentPassword() {
|
||||||
var oldUser = createUser("fake-password");
|
var oldUser = createUser("fake@password");
|
||||||
var newUser = createUser("new-password");
|
var newUser = createUser("new@password");
|
||||||
|
|
||||||
when(client.get(User.class, "fake-user")).thenReturn(
|
when(client.get(User.class, "fake-user")).thenReturn(
|
||||||
Mono.just(oldUser));
|
Mono.just(oldUser));
|
||||||
when(client.update(eq(oldUser))).thenReturn(Mono.just(newUser));
|
when(client.update(eq(oldUser))).thenReturn(Mono.just(newUser));
|
||||||
when(passwordEncoder.matches("new-password", "fake-password")).thenReturn(false);
|
when(passwordEncoder.matches("new@password", "fake@password")).thenReturn(false);
|
||||||
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)
|
.expectNext(newUser)
|
||||||
.verifyComplete();
|
.verifyComplete();
|
||||||
|
|
||||||
verify(passwordEncoder).matches("new-password", "fake-password");
|
verify(passwordEncoder).matches("new@password", "fake@password");
|
||||||
verify(passwordEncoder).encode("new-password");
|
verify(passwordEncoder).encode("new@password");
|
||||||
verify(client).get(User.class, "fake-user");
|
verify(client).get(User.class, "fake-user");
|
||||||
verify(client).update(argThat(extension -> {
|
verify(client).update(argThat(extension -> {
|
||||||
var user = (User) 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));
|
verify(eventPublisher).publishEvent(any(PasswordChangedEvent.class));
|
||||||
}
|
}
|
||||||
|
@ -142,21 +143,21 @@ class UserServiceImplTest {
|
||||||
@Test
|
@Test
|
||||||
void shouldUpdatePasswordIfNoPasswordBefore() {
|
void shouldUpdatePasswordIfNoPasswordBefore() {
|
||||||
var oldUser = createUser(null);
|
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.get(User.class, "fake-user")).thenReturn(Mono.just(oldUser));
|
||||||
when(client.update(oldUser)).thenReturn(Mono.just(newUser));
|
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)
|
.expectNext(newUser)
|
||||||
.verifyComplete();
|
.verifyComplete();
|
||||||
|
|
||||||
verify(passwordEncoder, never()).matches("new-password", null);
|
verify(passwordEncoder, never()).matches("new@password", null);
|
||||||
verify(passwordEncoder).encode("new-password");
|
verify(passwordEncoder).encode("new@password");
|
||||||
verify(client).update(argThat(extension -> {
|
verify(client).update(argThat(extension -> {
|
||||||
var user = (User) 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(client).get(User.class, "fake-user");
|
||||||
verify(eventPublisher).publishEvent(any(PasswordChangedEvent.class));
|
verify(eventPublisher).publishEvent(any(PasswordChangedEvent.class));
|
||||||
|
@ -166,16 +167,16 @@ class UserServiceImplTest {
|
||||||
void shouldDoNothingIfPasswordNotChanged() {
|
void shouldDoNothingIfPasswordNotChanged() {
|
||||||
userService = spy(userService);
|
userService = spy(userService);
|
||||||
|
|
||||||
var oldUser = createUser("fake-password");
|
var oldUser = createUser("fake@password");
|
||||||
var newUser = createUser("new-password");
|
var newUser = createUser("new@password");
|
||||||
when(client.get(User.class, "fake-user")).thenReturn(Mono.just(oldUser));
|
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)
|
.expectNextCount(0)
|
||||||
.verifyComplete();
|
.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(passwordEncoder, never()).encode(any());
|
||||||
verify(client, never()).update(any());
|
verify(client, never()).update(any());
|
||||||
verify(client).get(User.class, "fake-user");
|
verify(client).get(User.class, "fake-user");
|
||||||
|
@ -188,7 +189,7 @@ class UserServiceImplTest {
|
||||||
.thenReturn(Mono.error(
|
.thenReturn(Mono.error(
|
||||||
new ExtensionNotFoundException(fromExtension(User.class), "fake-user")));
|
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);
|
.verifyError(UserNotFoundException.class);
|
||||||
|
|
||||||
verify(passwordEncoder, never()).matches(anyString(), anyString());
|
verify(passwordEncoder, never()).matches(anyString(), anyString());
|
||||||
|
@ -197,6 +198,16 @@ class UserServiceImplTest {
|
||||||
verify(client).get(User.class, "fake-user");
|
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) {
|
User createUser(String password) {
|
||||||
|
|
|
@ -2,7 +2,6 @@ package run.halo.app.infra;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
import java.util.HashMap;
|
|
||||||
import org.apache.commons.lang3.StringUtils;
|
import org.apache.commons.lang3.StringUtils;
|
||||||
import org.junit.jupiter.api.Nested;
|
import org.junit.jupiter.api.Nested;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
@ -19,74 +18,50 @@ class ValidationUtilsTest {
|
||||||
class NameValidationTest {
|
class NameValidationTest {
|
||||||
@Test
|
@Test
|
||||||
void nullName() {
|
void nullName() {
|
||||||
assertThat(ValidationUtils.validateName(null)).isFalse();
|
assertThat(validateName(null)).isFalse();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void emptyUsername() {
|
void emptyUsername() {
|
||||||
assertThat(ValidationUtils.validateName("")).isFalse();
|
assertThat(validateName("")).isFalse();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void startWithIllegalCharacter() {
|
void startWithIllegalCharacter() {
|
||||||
assertThat(ValidationUtils.validateName("-abc")).isFalse();
|
assertThat(validateName("-abc")).isFalse();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void endWithIllegalCharacter() {
|
void endWithIllegalCharacter() {
|
||||||
assertThat(ValidationUtils.validateName("abc-")).isFalse();
|
assertThat(validateName("abc-")).isFalse();
|
||||||
assertThat(ValidationUtils.validateName("abcD")).isFalse();
|
assertThat(validateName("abcD")).isFalse();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void middleWithIllegalCharacter() {
|
void middleWithIllegalCharacter() {
|
||||||
assertThat(ValidationUtils.validateName("ab?c")).isFalse();
|
assertThat(validateName("ab?c")).isFalse();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void moreThan63Characters() {
|
void moreThan63Characters() {
|
||||||
assertThat(ValidationUtils.validateName(StringUtils.repeat('a', 64))).isFalse();
|
assertThat(validateName(StringUtils.repeat('a', 64))).isFalse();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void correctUsername() {
|
void correctUsername() {
|
||||||
assertThat(ValidationUtils.validateName("abc")).isTrue();
|
assertThat(validateName("abc")).isTrue();
|
||||||
assertThat(ValidationUtils.validateName("ab-c")).isTrue();
|
assertThat(validateName("ab-c")).isTrue();
|
||||||
assertThat(ValidationUtils.validateName("1st")).isTrue();
|
assertThat(validateName("1st")).isTrue();
|
||||||
assertThat(ValidationUtils.validateName("ast1")).isTrue();
|
assertThat(validateName("ast1")).isTrue();
|
||||||
assertThat(ValidationUtils.validateName("ast-1")).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<String, Boolean>();
|
|
||||||
// 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;g<h>i[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));
|
|
||||||
}
|
|
||||||
}
|
}
|
|
@ -12,6 +12,7 @@ import { Toast, VButton, VModal, VSpace } from "@halo-dev/components";
|
||||||
import { setFocus } from "@/formkit/utils/focus";
|
import { setFocus } from "@/formkit/utils/focus";
|
||||||
import { useQueryClient } from "@tanstack/vue-query";
|
import { useQueryClient } from "@tanstack/vue-query";
|
||||||
import { useI18n } from "vue-i18n";
|
import { useI18n } from "vue-i18n";
|
||||||
|
import { PASSWORD_REGEX } from "@/constants/regex";
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
@ -112,9 +113,13 @@ const handleCreateUser = async () => {
|
||||||
:label="$t('core.user.change_password_modal.fields.new_password.label')"
|
:label="$t('core.user.change_password_modal.fields.new_password.label')"
|
||||||
type="password"
|
type="password"
|
||||||
name="password"
|
name="password"
|
||||||
validation="required:trim|length:5,100|matches:/^\S.*\S$/"
|
:validation="[
|
||||||
|
['required'],
|
||||||
|
['length', 5, 257],
|
||||||
|
['matches', PASSWORD_REGEX],
|
||||||
|
]"
|
||||||
:validation-messages="{
|
:validation-messages="{
|
||||||
matches: $t('core.formkit.validation.trim'),
|
matches: $t('core.formkit.validation.password'),
|
||||||
}"
|
}"
|
||||||
></FormKit>
|
></FormKit>
|
||||||
<FormKit
|
<FormKit
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import SubmitButton from "@/components/button/SubmitButton.vue";
|
import SubmitButton from "@/components/button/SubmitButton.vue";
|
||||||
|
import { PASSWORD_REGEX } from "@/constants/regex";
|
||||||
import { setFocus } from "@/formkit/utils/focus";
|
import { setFocus } from "@/formkit/utils/focus";
|
||||||
import type { User } from "@halo-dev/api-client";
|
import type { User } from "@halo-dev/api-client";
|
||||||
import { consoleApiClient } from "@halo-dev/api-client";
|
import { consoleApiClient } from "@halo-dev/api-client";
|
||||||
|
@ -81,9 +82,13 @@ const handleChangePassword = async () => {
|
||||||
:label="$t('core.user.change_password_modal.fields.new_password.label')"
|
:label="$t('core.user.change_password_modal.fields.new_password.label')"
|
||||||
name="password"
|
name="password"
|
||||||
type="password"
|
type="password"
|
||||||
validation="required:trim|length:5,100|matches:/^\S.*\S$/"
|
:validation="[
|
||||||
|
['required'],
|
||||||
|
['length', 5, 257],
|
||||||
|
['matches', PASSWORD_REGEX],
|
||||||
|
]"
|
||||||
:validation-messages="{
|
:validation-messages="{
|
||||||
matches: $t('core.formkit.validation.trim'),
|
matches: $t('core.formkit.validation.password'),
|
||||||
}"
|
}"
|
||||||
></FormKit>
|
></FormKit>
|
||||||
<FormKit
|
<FormKit
|
||||||
|
@ -92,10 +97,7 @@ const handleChangePassword = async () => {
|
||||||
"
|
"
|
||||||
name="password_confirm"
|
name="password_confirm"
|
||||||
type="password"
|
type="password"
|
||||||
validation="confirm|required:trim|length:5,100|matches:/^\S.*\S$/"
|
validation="confirm|required"
|
||||||
:validation-messages="{
|
|
||||||
matches: $t('core.formkit.validation.trim'),
|
|
||||||
}"
|
|
||||||
></FormKit>
|
></FormKit>
|
||||||
</FormKit>
|
</FormKit>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
export const PASSWORD_REGEX = /^[A-Za-z0-9!@#$%^&*]+$/;
|
|
@ -1664,6 +1664,7 @@ core:
|
||||||
creation_label: Create {text} tag
|
creation_label: Create {text} tag
|
||||||
validation:
|
validation:
|
||||||
trim: Please remove the leading and trailing spaces
|
trim: Please remove the leading and trailing spaces
|
||||||
|
password: "The password can only use uppercase and lowercase letters (A-Z, a-z), numbers (0-9), and the following special characters: !{'@'}#$%^&*"
|
||||||
verification_form:
|
verification_form:
|
||||||
no_action_defined: "{label} interface not defined"
|
no_action_defined: "{label} interface not defined"
|
||||||
verify_success: "{label} successful"
|
verify_success: "{label} successful"
|
||||||
|
|
|
@ -1557,6 +1557,7 @@ core:
|
||||||
creation_label: 创建 {text} 标签
|
creation_label: 创建 {text} 标签
|
||||||
validation:
|
validation:
|
||||||
trim: 不能以空格开头或结尾
|
trim: 不能以空格开头或结尾
|
||||||
|
password: "密码只能使用大小写字母 (A-Z, a-z)、数字 (0-9),以及以下特殊字符: !{'@'}#$%^&*"
|
||||||
verification_form:
|
verification_form:
|
||||||
no_action_defined: 未定义{label}接口
|
no_action_defined: 未定义{label}接口
|
||||||
verify_success: "{label}成功"
|
verify_success: "{label}成功"
|
||||||
|
|
|
@ -1535,6 +1535,7 @@ core:
|
||||||
creation_label: 創建 {text} 標籤
|
creation_label: 創建 {text} 標籤
|
||||||
validation:
|
validation:
|
||||||
trim: 不能以空格開頭或結尾
|
trim: 不能以空格開頭或結尾
|
||||||
|
password: "密碼只能使用大小寫字母 (A-Z, a-z)、數字 (0-9),以及以下特殊字符: !{'@'}#$%^&*"
|
||||||
verification_form:
|
verification_form:
|
||||||
no_action_defined: 未定義{label}介面
|
no_action_defined: 未定義{label}介面
|
||||||
verify_success: "{label}成功"
|
verify_success: "{label}成功"
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import SubmitButton from "@/components/button/SubmitButton.vue";
|
import SubmitButton from "@/components/button/SubmitButton.vue";
|
||||||
|
import { PASSWORD_REGEX } from "@/constants/regex";
|
||||||
import { setFocus } from "@/formkit/utils/focus";
|
import { setFocus } from "@/formkit/utils/focus";
|
||||||
import { consoleApiClient } from "@halo-dev/api-client";
|
import { consoleApiClient } from "@halo-dev/api-client";
|
||||||
import { VButton, VModal, VSpace } from "@halo-dev/components";
|
import { VButton, VModal, VSpace } from "@halo-dev/components";
|
||||||
|
@ -81,9 +82,13 @@ const handleChangePassword = async () => {
|
||||||
"
|
"
|
||||||
name="password"
|
name="password"
|
||||||
type="password"
|
type="password"
|
||||||
validation="required:trim|length:5,100|matches:/^\S.*\S$/"
|
:validation="[
|
||||||
|
['required'],
|
||||||
|
['length', 5, 257],
|
||||||
|
['matches', PASSWORD_REGEX],
|
||||||
|
]"
|
||||||
:validation-messages="{
|
:validation-messages="{
|
||||||
matches: $t('core.formkit.validation.trim'),
|
matches: $t('core.formkit.validation.password'),
|
||||||
}"
|
}"
|
||||||
></FormKit>
|
></FormKit>
|
||||||
<FormKit
|
<FormKit
|
||||||
|
@ -94,10 +99,7 @@ const handleChangePassword = async () => {
|
||||||
"
|
"
|
||||||
name="password_confirm"
|
name="password_confirm"
|
||||||
type="password"
|
type="password"
|
||||||
validation="confirm|required:trim|length:5,100|matches:/^\S.*\S$/"
|
validation="confirm|required"
|
||||||
:validation-messages="{
|
|
||||||
matches: $t('core.formkit.validation.trim'),
|
|
||||||
}"
|
|
||||||
></FormKit>
|
></FormKit>
|
||||||
</FormKit>
|
</FormKit>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
|
|
Loading…
Reference in New Issue