diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/AccountController.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/AccountController.java index f49e23f7b..6eedc1811 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/AccountController.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/AccountController.java @@ -91,6 +91,8 @@ import org.whispersystems.textsecuregcm.util.ForwardedIpUtil; import org.whispersystems.textsecuregcm.util.Hex; import org.whispersystems.textsecuregcm.util.ImpossiblePhoneNumberException; import org.whispersystems.textsecuregcm.util.NonNormalizedPhoneNumberException; +import org.whispersystems.textsecuregcm.util.Username; +import org.whispersystems.textsecuregcm.util.UsernameValidator; import org.whispersystems.textsecuregcm.util.Util; import org.whispersystems.textsecuregcm.util.VerificationCode; @@ -617,22 +619,12 @@ public class AccountController { @PUT @Path("/username/{username}") @Produces(MediaType.APPLICATION_JSON) - public Response setUsername(@Auth AuthenticatedAccount auth, @PathParam("username") String username) + public Response setUsername(@Auth AuthenticatedAccount auth, @PathParam("username") @Username String username) throws RateLimitExceededException { rateLimiters.getUsernameSetLimiter().validate(auth.getAccount().getUuid()); - if (username == null || username.isEmpty()) { - return Response.status(Response.Status.BAD_REQUEST).build(); - } - - username = username.toLowerCase(); - - if (!username.matches("^[a-z_][a-z0-9_]+$")) { - return Response.status(Response.Status.BAD_REQUEST).build(); - } - try { - accounts.setUsername(auth.getAccount(), username); + accounts.setUsername(auth.getAccount(), UsernameValidator.getCanonicalUsername(username)); } catch (final UsernameNotAvailableException e) { return Response.status(Response.Status.CONFLICT).build(); } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/util/Username.java b/service/src/main/java/org/whispersystems/textsecuregcm/util/Username.java new file mode 100644 index 000000000..b9d7af788 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/util/Username.java @@ -0,0 +1,27 @@ +/* + * Copyright 2013-2021 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.util; + +import static java.lang.annotation.ElementType.FIELD; +import static java.lang.annotation.ElementType.PARAMETER; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import java.lang.annotation.Retention; +import java.lang.annotation.Target; +import javax.validation.Constraint; +import javax.validation.Payload; + +@Target({ FIELD, PARAMETER }) +@Retention(RUNTIME) +@Constraint(validatedBy = UsernameValidator.class) +public @interface Username { + + String message() default "{org.whispersystems.textsecuregcm.util.Username.message}"; + + Class[] groups() default { }; + + Class[] payload() default { }; +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/util/UsernameValidator.java b/service/src/main/java/org/whispersystems/textsecuregcm/util/UsernameValidator.java new file mode 100644 index 000000000..89cd2112d --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/util/UsernameValidator.java @@ -0,0 +1,26 @@ +/* + * Copyright 2013-2021 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.util; + +import org.apache.commons.lang3.StringUtils; +import javax.validation.ConstraintValidator; +import javax.validation.ConstraintValidatorContext; +import java.util.regex.Pattern; + +public class UsernameValidator implements ConstraintValidator { + + private static final Pattern USERNAME_PATTERN = + Pattern.compile("^[a-z_][a-z0-9_]{3,25}$", Pattern.CASE_INSENSITIVE); + + @Override + public boolean isValid(final String username, final ConstraintValidatorContext context) { + return StringUtils.isNotBlank(username) && USERNAME_PATTERN.matcher(getCanonicalUsername(username)).matches(); + } + + public static String getCanonicalUsername(final String username) { + return username != null ? username.toLowerCase() : null; + } +} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/util/UsernameValidatorTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/util/UsernameValidatorTest.java new file mode 100644 index 000000000..32e7c3d83 --- /dev/null +++ b/service/src/test/java/org/whispersystems/textsecuregcm/util/UsernameValidatorTest.java @@ -0,0 +1,56 @@ +/* + * Copyright 2013-2021 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.stream.Stream; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +class UsernameValidatorTest { + + @ParameterizedTest + @MethodSource + void isValid(final String username, final boolean expectValid) { + final UsernameValidator usernameValidator = new UsernameValidator(); + + assertEquals(expectValid, usernameValidator.isValid(username, null)); + } + + private static Stream isValid() { + return Stream.of( + Arguments.of("test", true), + Arguments.of("_test", true), + Arguments.of("test123", true), + Arguments.of("a", false), // Too short + Arguments.of("thisIsAReallyReallyReallyLongUsernameThatWeWouldNotAllow", false), + Arguments.of("Illegal character", false), + Arguments.of("0test", false), // Illegal first character + Arguments.of("pаypal", false), // Unicode confusable characters + Arguments.of("test\uD83D\uDC4E", false), // Emoji + Arguments.of(" ", false), + Arguments.of("", false), + Arguments.of(null, false) + ); + } + + @ParameterizedTest + @MethodSource + void getCanonicalUsername(final String username, final String expectedCanonicalUsername) { + assertEquals(expectedCanonicalUsername, UsernameValidator.getCanonicalUsername(username)); + } + + private static Stream getCanonicalUsername() { + return Stream.of( + Arguments.of("test", "test"), + Arguments.of("TEst", "test"), + Arguments.of("t_e_S_T", "t_e_s_t"), + Arguments.of(null, null) + ); + } +}