Merge pull request #6804 from guqing/refactor/user-validation

refactor: unified validation for username and password format
pull/6806/head
John Niang 2024-10-10 10:45:38 +08:00 committed by GitHub
commit 158c3e8a9e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 134 additions and 137 deletions

View File

@ -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()

View File

@ -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 {};
} }

View File

@ -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())) {

View File

@ -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);
}
} }

View File

@ -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);

View File

@ -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: !@#$%^&*

View File

@ -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),以及以下特殊字符: !@#$%^&*

View File

@ -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;

View File

@ -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) {

View File

@ -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));
}
}

View File

@ -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

View File

@ -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>

View File

@ -0,0 +1 @@
export const PASSWORD_REGEX = /^[A-Za-z0-9!@#$%^&*]+$/;

View File

@ -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"

View File

@ -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}成功"

View File

@ -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}成功"

View File

@ -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>