From aa3d381dd3106df4348d62824a66ae12944826be Mon Sep 17 00:00:00 2001 From: Rainnny7 Date: Mon, 16 Sep 2024 19:56:15 -0400 Subject: [PATCH] user registration --- README.md | 3 + pom.xml | 63 +++++++- .../cc/pulseapp/api/common/HashUtils.java | 56 +++++++ .../cc/pulseapp/api/common/StringUtils.java | 119 ++++++++++++++ .../cc/pulseapp/api/config/RedisConfig.java | 73 +++++++++ .../api/controller/AppController.java | 46 ++++++ .../api/controller/v1/AuthController.java | 39 +++++ .../exception/impl/BadRequestException.java | 5 +- .../cc/pulseapp/api/log/RequestLogger.java | 2 +- .../cc/pulseapp/api/model/AppInformation.java | 24 +++ .../pulseapp/api/model/IGenericResponse.java | 17 ++ src/main/java/cc/pulseapp/api/model/User.java | 36 ----- .../pulseapp/api/model/org/Organization.java | 33 ++++ .../pulseapp/api/model/page/StatusPage.java | 61 ++++++++ .../api/model/page/StatusPageTheme.java | 23 +++ .../cc/pulseapp/api/model/user/AuthToken.java | 45 ++++++ .../java/cc/pulseapp/api/model/user/User.java | 66 ++++++++ .../cc/pulseapp/api/model/user/UserDTO.java | 64 ++++++++ .../cc/pulseapp/api/model/user/UserFlag.java | 18 +++ .../cc/pulseapp/api/model/user/UserTier.java | 35 +++++ .../user/input/UserRegistrationInput.java | 51 ++++++ .../api/repository/AuthTokenRepository.java | 20 +++ .../api/repository/UserRepository.java | 20 +++ .../cc/pulseapp/api/service/AuthService.java | 147 ++++++++++++++++++ .../pulseapp/api/service/CaptchaService.java | 40 +++++ .../api/service/SnowflakeService.java | 48 ++++++ src/main/resources/application.yml | 24 ++- 27 files changed, 1128 insertions(+), 50 deletions(-) create mode 100644 src/main/java/cc/pulseapp/api/common/HashUtils.java create mode 100644 src/main/java/cc/pulseapp/api/common/StringUtils.java create mode 100644 src/main/java/cc/pulseapp/api/config/RedisConfig.java create mode 100644 src/main/java/cc/pulseapp/api/controller/AppController.java create mode 100644 src/main/java/cc/pulseapp/api/controller/v1/AuthController.java create mode 100644 src/main/java/cc/pulseapp/api/model/AppInformation.java create mode 100644 src/main/java/cc/pulseapp/api/model/IGenericResponse.java delete mode 100644 src/main/java/cc/pulseapp/api/model/User.java create mode 100644 src/main/java/cc/pulseapp/api/model/org/Organization.java create mode 100644 src/main/java/cc/pulseapp/api/model/page/StatusPage.java create mode 100644 src/main/java/cc/pulseapp/api/model/page/StatusPageTheme.java create mode 100644 src/main/java/cc/pulseapp/api/model/user/AuthToken.java create mode 100644 src/main/java/cc/pulseapp/api/model/user/User.java create mode 100644 src/main/java/cc/pulseapp/api/model/user/UserDTO.java create mode 100644 src/main/java/cc/pulseapp/api/model/user/UserFlag.java create mode 100644 src/main/java/cc/pulseapp/api/model/user/UserTier.java create mode 100644 src/main/java/cc/pulseapp/api/model/user/input/UserRegistrationInput.java create mode 100644 src/main/java/cc/pulseapp/api/repository/AuthTokenRepository.java create mode 100644 src/main/java/cc/pulseapp/api/repository/UserRepository.java create mode 100644 src/main/java/cc/pulseapp/api/service/AuthService.java create mode 100644 src/main/java/cc/pulseapp/api/service/CaptchaService.java create mode 100644 src/main/java/cc/pulseapp/api/service/SnowflakeService.java diff --git a/README.md b/README.md index e69de29..decb673 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,3 @@ +# API + +The backend API responsible for making things work! \ No newline at end of file diff --git a/pom.xml b/pom.xml index 37c14d0..7997679 100644 --- a/pom.xml +++ b/pom.xml @@ -48,6 +48,19 @@ + + + + + com.konghq + unirest-java-bom + 4.4.4 + pom + import + + + + @@ -63,14 +76,52 @@ 1.18.34 provided + + com.relops + snowflake + 1.1 + compile + + + + + com.konghq + unirest-java-core + + + com.konghq + unirest-modules-gson + + + + + org.springframework.boot + spring-boot-starter-data-mongodb + + + + + org.springframework.boot + spring-boot-starter-data-redis + + + io.lettuce + lettuce-core + + + + + redis.clients + jedis + - - - - - - + + io.sentry + sentry-spring-boot-starter-jakarta + 8.0.0-alpha.4 + compile + org.questdb questdb diff --git a/src/main/java/cc/pulseapp/api/common/HashUtils.java b/src/main/java/cc/pulseapp/api/common/HashUtils.java new file mode 100644 index 0000000..d9bf318 --- /dev/null +++ b/src/main/java/cc/pulseapp/api/common/HashUtils.java @@ -0,0 +1,56 @@ +package cc.pulseapp.api.common; + +import lombok.NonNull; +import lombok.SneakyThrows; +import lombok.experimental.UtilityClass; + +import javax.crypto.SecretKeyFactory; +import javax.crypto.spec.PBEKeySpec; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.security.spec.KeySpec; +import java.util.Base64; + +/** + * @author Braydon + */ +@UtilityClass +public final class HashUtils { + private static final SecretKeyFactory PBKDF2; + private static final int ITERATION_COUNT = 512000; + private static final int KEY_LENGTH = 256; + private static final SecureRandom RANDOM = new SecureRandom(); + + static { + try { + PBKDF2 = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256"); + } catch (NoSuchAlgorithmException ex) { + throw new RuntimeException(ex); + } + } + + /** + * Hash the given input. + * + * @param salt the salt to hash with + * @param input the input to hash + * @return the hashed input + */ + @SneakyThrows + public static String hash(byte[] salt, @NonNull String input) { + KeySpec spec = new PBEKeySpec(input.toCharArray(), salt, ITERATION_COUNT, KEY_LENGTH); + byte[] hash = PBKDF2.generateSecret(spec).getEncoded(); + return Base64.getEncoder().encodeToString(hash); + } + + /** + * Generate a salt. + * + * @return the generated salt + */ + public static byte[] generateSalt() { + byte[] salt = new byte[32]; + RANDOM.nextBytes(salt); + return salt; + } +} \ No newline at end of file diff --git a/src/main/java/cc/pulseapp/api/common/StringUtils.java b/src/main/java/cc/pulseapp/api/common/StringUtils.java new file mode 100644 index 0000000..b3ec8a9 --- /dev/null +++ b/src/main/java/cc/pulseapp/api/common/StringUtils.java @@ -0,0 +1,119 @@ +package cc.pulseapp.api.common; + +import cc.pulseapp.api.model.IGenericResponse; +import lombok.NonNull; +import lombok.experimental.UtilityClass; + +import java.security.SecureRandom; +import java.util.regex.Pattern; + +/** + * @author Braydon + */ +@UtilityClass +public final class StringUtils { + private static final String ALPHABET_STRING = "abcdefghijklmnopqrstuvwxyzABCDEFGJKLMNPRSTUVWXYZ"; + private static final String NUMERIC_STRING = "0123456789"; + private static final String SPECIAL_STRING = "!@#$%^&*()_+-=[]{}|;:,.<>?"; + private static final SecureRandom RANDOM = new SecureRandom(); + + private static final Pattern EMAIL_PATTERN = Pattern.compile("^[A-Za-z0-9+_.-]+@(.+)$"); + private static final Pattern USERNAME_PATTERN = Pattern.compile("^[a-zA-Z0-9_.]*$"); + + /** + * Check if the given email is valid. + * + * @param email the email to check + * @return whether the email is valid + */ + public static boolean isValidEmail(@NonNull String email) { + return !email.isBlank() && EMAIL_PATTERN.matcher(email).matches(); + } + + /** + * Check if the given username is valid. + * + * @param username the username to check + * @return whether the username is valid + */ + public static boolean isValidUsername(@NonNull String username) { + return USERNAME_PATTERN.matcher(username).matches(); + } + + /** + * Check if the given password meets the requirements. + * + * @param password the password to check + * @return the error if password requirements are not met, otherwise null + */ + public static PasswordError checkPasswordRequirements(@NonNull String password) { + return checkPasswordRequirements(password, 8, 76, true, true, true); + } + + /** + * Check if the given password meets the requirements. + * + * @param password the password to check + * @param minLength the minimum length of the password + * @param maxLength the maximum length of the password + * @param alphabet whether the password must contain alphabet characters + * @param numeric whether the password must contain numeric characters + * @param special whether the password must contain special characters + * @return the error if password requirements are not met, otherwise null + */ + private static PasswordError checkPasswordRequirements(@NonNull String password, int minLength, int maxLength, boolean alphabet, boolean numeric, boolean special) { + boolean tooShort = password.length() < minLength; + if (password.length() < minLength || password.length() > maxLength) { + return tooShort ? PasswordError.PASSWORD_TOO_SHORT : PasswordError.PASSWORD_TOO_LONG; + } + if (alphabet && !password.matches(".*[a-zA-Z].*")) { + return PasswordError.PASSWORD_MISSING_ALPHABET; + } + if (numeric && !password.matches(".*\\d.*")) { + return PasswordError.PASSWORD_MISSING_NUMERIC; + } + if (special && !password.matches(".*[^a-zA-Z0-9].*")) { + return PasswordError.PASSWORD_MISSING_SPECIAL; + } + return null; // Password meets the requirements + } + + /** + * Generate a random string with the given length. + * + * @param length the length of the string + * @param alphabet whether the string should contain alphabet characters + * @param numeric whether the string should contain numeric characters + * @param special whether the string should contain special characters + * @return the generated random string + */ + @NonNull + public static String generateRandom(int length, boolean alphabet, boolean numeric, boolean special) { + if (length < 1) { + throw new IllegalArgumentException("Length must be at least 1"); + } + if (!alphabet && !numeric && !special) { // Validate + throw new IllegalArgumentException("At least one of alphabet, numeric, or special must be true"); + } + // Build the symbols string + StringBuilder symbols = new StringBuilder(); + if (alphabet) symbols.append(ALPHABET_STRING); + if (numeric) symbols.append(NUMERIC_STRING); + if (special) symbols.append(SPECIAL_STRING); + + // Generate the random string + char[] buffer = new char[length]; + for (int idx = 0; idx < buffer.length; ++idx) { + buffer[idx] = symbols.charAt(RANDOM.nextInt(symbols.length())); + } + return new String(buffer); + } + + public enum PasswordError implements IGenericResponse { + PASSWORD_TOO_SHORT, + PASSWORD_TOO_LONG, + PASSWORD_MISSING_ALPHABET, + PASSWORD_MISSING_NUMERIC, + PASSWORD_MISSING_SPECIAL + } +} \ No newline at end of file diff --git a/src/main/java/cc/pulseapp/api/config/RedisConfig.java b/src/main/java/cc/pulseapp/api/config/RedisConfig.java new file mode 100644 index 0000000..83061a4 --- /dev/null +++ b/src/main/java/cc/pulseapp/api/config/RedisConfig.java @@ -0,0 +1,73 @@ +package cc.pulseapp.api.config; + +import lombok.NonNull; +import lombok.extern.log4j.Log4j2; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisStandaloneConfiguration; +import org.springframework.data.redis.connection.jedis.JedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; + +/** + * @author Braydon + */ +@Configuration +@Log4j2(topic = "Redis") +public class RedisConfig { + /** + * The Redis server host. + */ + @Value("${spring.data.redis.host}") + private String host; + + /** + * The Redis server port. + */ + @Value("${spring.data.redis.port}") + private int port; + + /** + * The Redis database index. + */ + @Value("${spring.data.redis.database}") + private int database; + + /** + * The optional Redis password. + */ + @Value("${spring.data.redis.auth}") + private String auth; + + /** + * Build the config to use for Redis. + * + * @return the config + * @see RedisTemplate for config + */ + @Bean @NonNull + public RedisTemplate redisTemplate() { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(jedisConnectionFactory()); + return template; + } + + /** + * Build the connection factory to use + * when making connections to Redis. + * + * @return the built factory + * @see JedisConnectionFactory for factory + */ + @Bean @NonNull + public JedisConnectionFactory jedisConnectionFactory() { + log.info("Connecting to Redis at {}:{}/{}", host, port, database); + RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(host, port); + config.setDatabase(database); + if (!auth.trim().isEmpty()) { // Auth with our provided password + log.info("Using auth..."); + config.setPassword(auth); + } + return new JedisConnectionFactory(config); + } +} \ No newline at end of file diff --git a/src/main/java/cc/pulseapp/api/controller/AppController.java b/src/main/java/cc/pulseapp/api/controller/AppController.java new file mode 100644 index 0000000..6b33205 --- /dev/null +++ b/src/main/java/cc/pulseapp/api/controller/AppController.java @@ -0,0 +1,46 @@ +package cc.pulseapp.api.controller; + +import cc.pulseapp.api.common.EnvironmentUtils; +import cc.pulseapp.api.model.AppInformation; +import lombok.NonNull; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.info.BuildProperties; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.lang.Nullable; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.bind.annotation.RestController; + +/** + * The root controller for this app. + * + * @author Braydon + */ +@RestController +@RequestMapping(value = "/", produces = MediaType.APPLICATION_JSON_VALUE) +public final class AppController { + /** + * The build properties for this app, null if not available. + */ + private final BuildProperties buildProperties; + + @Autowired + public AppController(@Nullable BuildProperties buildProperties) { + this.buildProperties = buildProperties; + } + + /** + * A GET endpoint to get info about this app. + * + * @return the info response + */ + @GetMapping @ResponseBody @NonNull + public ResponseEntity getAppInfo() { + return ResponseEntity.ok(new AppInformation( + buildProperties == null ? "unknown" : buildProperties.getVersion(), + EnvironmentUtils.isProduction() ? "production" : "staging" + )); + } +} \ No newline at end of file diff --git a/src/main/java/cc/pulseapp/api/controller/v1/AuthController.java b/src/main/java/cc/pulseapp/api/controller/v1/AuthController.java new file mode 100644 index 0000000..598e156 --- /dev/null +++ b/src/main/java/cc/pulseapp/api/controller/v1/AuthController.java @@ -0,0 +1,39 @@ +package cc.pulseapp.api.controller.v1; + +import cc.pulseapp.api.exception.impl.BadRequestException; +import cc.pulseapp.api.model.user.AuthToken; +import cc.pulseapp.api.model.user.input.UserRegistrationInput; +import cc.pulseapp.api.service.AuthService; +import lombok.NonNull; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.bind.annotation.RestController; + +/** + * This controller is responsible for + * handling user authentication requests. + * + * @author Braydon + */ +@RestController +@RequestMapping(value = "/v1/auth", produces = MediaType.APPLICATION_JSON_VALUE) +public final class AuthController { + /** + * The user service to use. + */ + @NonNull private final AuthService authService; + + @Autowired + public AuthController(@NonNull AuthService authService) { + this.authService = authService; + } + + @PostMapping("/register") @ResponseBody @NonNull + public ResponseEntity register(UserRegistrationInput input) throws BadRequestException { + return ResponseEntity.ok(authService.registerUser(input)); + } +} \ No newline at end of file diff --git a/src/main/java/cc/pulseapp/api/exception/impl/BadRequestException.java b/src/main/java/cc/pulseapp/api/exception/impl/BadRequestException.java index 5ccf25d..dac5785 100644 --- a/src/main/java/cc/pulseapp/api/exception/impl/BadRequestException.java +++ b/src/main/java/cc/pulseapp/api/exception/impl/BadRequestException.java @@ -1,5 +1,6 @@ package cc.pulseapp.api.exception.impl; +import cc.pulseapp.api.model.IGenericResponse; import lombok.NonNull; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.ResponseStatus; @@ -12,7 +13,7 @@ import org.springframework.web.bind.annotation.ResponseStatus; */ @ResponseStatus(HttpStatus.BAD_REQUEST) public final class BadRequestException extends RuntimeException { - public BadRequestException(@NonNull String message) { - super(message); + public BadRequestException(@NonNull IGenericResponse error) { + super(error.name()); } } \ No newline at end of file diff --git a/src/main/java/cc/pulseapp/api/log/RequestLogger.java b/src/main/java/cc/pulseapp/api/log/RequestLogger.java index 803c3d7..324540d 100644 --- a/src/main/java/cc/pulseapp/api/log/RequestLogger.java +++ b/src/main/java/cc/pulseapp/api/log/RequestLogger.java @@ -22,7 +22,7 @@ import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice; * @author Braydon */ @ControllerAdvice -@Slf4j(topic = "Req/Res Transaction") +@Slf4j(topic = "HTTP Request") public class RequestLogger implements ResponseBodyAdvice { @Override public boolean supports(@NonNull MethodParameter returnType, @NonNull Class> converterType) { diff --git a/src/main/java/cc/pulseapp/api/model/AppInformation.java b/src/main/java/cc/pulseapp/api/model/AppInformation.java new file mode 100644 index 0000000..61688c0 --- /dev/null +++ b/src/main/java/cc/pulseapp/api/model/AppInformation.java @@ -0,0 +1,24 @@ +package cc.pulseapp.api.model; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NonNull; +import lombok.ToString; + +/** + * Information about this app. + * + * @author Braydon + */ +@AllArgsConstructor @Getter @ToString +public final class AppInformation { + /** + * The version of the app. + */ + @NonNull private final String version; + + /** + * The environment of the app. + */ + @NonNull private final String environment; +} \ No newline at end of file diff --git a/src/main/java/cc/pulseapp/api/model/IGenericResponse.java b/src/main/java/cc/pulseapp/api/model/IGenericResponse.java new file mode 100644 index 0000000..65107a1 --- /dev/null +++ b/src/main/java/cc/pulseapp/api/model/IGenericResponse.java @@ -0,0 +1,17 @@ +package cc.pulseapp.api.model; + +import lombok.NonNull; + +/** + * Represents a generic response. + * + * @author Braydon + */ +public interface IGenericResponse { + /** + * Get the name of this response. + * + * @return the response name + */ + @NonNull String name(); +} \ No newline at end of file diff --git a/src/main/java/cc/pulseapp/api/model/User.java b/src/main/java/cc/pulseapp/api/model/User.java deleted file mode 100644 index 766120a..0000000 --- a/src/main/java/cc/pulseapp/api/model/User.java +++ /dev/null @@ -1,36 +0,0 @@ -package cc.pulseapp.api.model; - -import lombok.*; - -import java.util.Date; - -/** - * @author Braydon - */ -@AllArgsConstructor @Getter @EqualsAndHashCode(onlyExplicitlyIncluded = true) @ToString -public final class User { - /** - * The snowflake id of this user. - */ - @EqualsAndHashCode.Include private final long id; - - /** - * This user's username. - */ - @NonNull private final String username; - - /** - * The password for this user. - */ - @NonNull private final String password; - - /** - * The salt for this user's password. - */ - @NonNull private final String passwordSalt; - - /** - * The date this user last logged in. - */ - @NonNull private final Date lastLogin; -} \ No newline at end of file diff --git a/src/main/java/cc/pulseapp/api/model/org/Organization.java b/src/main/java/cc/pulseapp/api/model/org/Organization.java new file mode 100644 index 0000000..a474adc --- /dev/null +++ b/src/main/java/cc/pulseapp/api/model/org/Organization.java @@ -0,0 +1,33 @@ +package cc.pulseapp.api.model.org; + +import cc.pulseapp.api.model.user.User; +import lombok.*; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.index.Indexed; +import org.springframework.data.mongodb.core.mapping.Document; + +/** + * An organization owned by a {@link User}. + * + * @author Braydon + */ +@AllArgsConstructor @Getter +@EqualsAndHashCode(onlyExplicitlyIncluded = true) @ToString +@Document("organizations") +public final class Organization { + /** + * The snowflake id of this organization. + */ + @Id @EqualsAndHashCode.Include private final long id; + + /** + * The name of this organization. + */ + @Indexed @NonNull private final String name; + + /** + * The snowflake of the {@link User} + * that owns this organization. + */ + @Indexed private final long ownerSnowflake; +} \ No newline at end of file diff --git a/src/main/java/cc/pulseapp/api/model/page/StatusPage.java b/src/main/java/cc/pulseapp/api/model/page/StatusPage.java new file mode 100644 index 0000000..0826827 --- /dev/null +++ b/src/main/java/cc/pulseapp/api/model/page/StatusPage.java @@ -0,0 +1,61 @@ +package cc.pulseapp.api.model.page; + +import cc.pulseapp.api.model.org.Organization; +import lombok.*; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.index.Indexed; +import org.springframework.data.mongodb.core.mapping.Document; + +/** + * @author Braydon + */ +@AllArgsConstructor @Getter +@EqualsAndHashCode(onlyExplicitlyIncluded = true) @ToString +@Document("pages") +public final class StatusPage { + /** + * The snowflake id of this status page. + */ + @Id @EqualsAndHashCode.Include private final long id; + + /** + * The name of this status page. + */ + @Indexed @NonNull private final String name; + + /** + * The description of this status page, if any. + */ + private final String description; + + /** + * The slug of this status page. + */ + @NonNull private final String slug; + + /** + * The id to the logo of this status page, if any. + */ + private final String logo; + + /** + * The id to the banner of this status page, if any. + */ + private final String banner; + + /** + * The theme of this status page. + */ + @NonNull private final StatusPageTheme theme; + + /** + * Whether this status page is visible in search engines. + */ + private final boolean visibleInSearchEngines; + + /** + * The snowflake of the {@link Organization} + * that owns this status page. + */ + private final long orgSnowflake; +} \ No newline at end of file diff --git a/src/main/java/cc/pulseapp/api/model/page/StatusPageTheme.java b/src/main/java/cc/pulseapp/api/model/page/StatusPageTheme.java new file mode 100644 index 0000000..7efc367 --- /dev/null +++ b/src/main/java/cc/pulseapp/api/model/page/StatusPageTheme.java @@ -0,0 +1,23 @@ +package cc.pulseapp.api.model.page; + +/** + * The theme of a {@link StatusPage}. + * + * @author Braydon + */ +public enum StatusPageTheme { + /** + * The theme is automatically chosen based on the user's OS. + */ + AUTO, + + /** + * The theme is forced to be dark. + */ + DARK, + + /** + * The theme is forced to be light. + */ + LIGHT +} \ No newline at end of file diff --git a/src/main/java/cc/pulseapp/api/model/user/AuthToken.java b/src/main/java/cc/pulseapp/api/model/user/AuthToken.java new file mode 100644 index 0000000..e503bd3 --- /dev/null +++ b/src/main/java/cc/pulseapp/api/model/user/AuthToken.java @@ -0,0 +1,45 @@ +package cc.pulseapp.api.model.user; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NonNull; +import org.springframework.data.annotation.Id; +import org.springframework.data.redis.core.RedisHash; +import org.springframework.data.redis.core.index.Indexed; + +import java.util.UUID; + +/** + * An authentication token for a {@link User}. + * + * @author Braydon + */ +@AllArgsConstructor @Getter +@RedisHash(value = "auth_token", timeToLive = 30 * 24 * 60 * 60) // Expire in 30 days (days, hours, mins, secs) +public final class AuthToken { + /** + * The ID of this token. + */ + @Id @JsonIgnore @NonNull private final UUID id; + + /** + * The snowflake of the user this token is for. + */ + @JsonIgnore private final long userSnowflake; + + /** + * The access token for the user. + */ + @Indexed @NonNull private final String accessToken; + + /** + * The refresh token for the user. + */ + @Indexed @NonNull private final String refreshToken; + + /** + * The unix timestamp of when this token expires. + */ + private final long expires; +} \ No newline at end of file diff --git a/src/main/java/cc/pulseapp/api/model/user/User.java b/src/main/java/cc/pulseapp/api/model/user/User.java new file mode 100644 index 0000000..6b12d87 --- /dev/null +++ b/src/main/java/cc/pulseapp/api/model/user/User.java @@ -0,0 +1,66 @@ +package cc.pulseapp.api.model.user; + +import lombok.*; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.index.Indexed; +import org.springframework.data.mongodb.core.mapping.Document; + +import java.util.Date; + +/** + * @author Braydon + */ +@AllArgsConstructor @Getter +@EqualsAndHashCode(onlyExplicitlyIncluded = true) @ToString +@Document("users") +public final class User { + /** + * The snowflake id of this user. + */ + @Id @EqualsAndHashCode.Include private final long id; + + /** + * This user's email. + */ + @Indexed @NonNull private final String email; + + /** + * This user's username. + */ + @Indexed @NonNull private final String username; + + /** + * The password for this user. + */ + @NonNull private final String password; + + /** + * The salt for this user's password. + */ + @NonNull private final String passwordSalt; + + /** + * The tier of this user. + */ + @NonNull private final UserTier tier; + + /** + * The flags for this user. + */ + private final int flags; + + /** + * The date this user last logged in. + */ + @NonNull private final Date lastLogin; + + /** + * Check if this user has a given flag. + * + * @param flag the flag to check + * @return whether this user has the flag + */ + public boolean hasFlag(@NonNull UserFlag flag) { + return (flags & flag.ordinal()) != 0; + } +} \ No newline at end of file diff --git a/src/main/java/cc/pulseapp/api/model/user/UserDTO.java b/src/main/java/cc/pulseapp/api/model/user/UserDTO.java new file mode 100644 index 0000000..78f1588 --- /dev/null +++ b/src/main/java/cc/pulseapp/api/model/user/UserDTO.java @@ -0,0 +1,64 @@ +package cc.pulseapp.api.model.user; + +import lombok.*; +import org.springframework.data.mongodb.core.index.Indexed; + +import java.util.Date; + +/** + * The DTO for a {@link User}. + * + * @author Braydon + */ +@AllArgsConstructor(access = AccessLevel.PRIVATE) @Getter +@EqualsAndHashCode(onlyExplicitlyIncluded = true) @ToString +public final class UserDTO { + /** + * The snowflake id of this user. + */ + @EqualsAndHashCode.Include private final long id; + + /** + * This user's email. + */ + @Indexed @NonNull private final String email; + + /** + * This user's username. + */ + @Indexed @NonNull private final String username; + + /** + * The tier of this user. + */ + @NonNull private final UserTier tier; + + /** + * The date this user last logged in. + */ + @NonNull private final Date lastLogin; + + /** + * The flags for this user. + */ + private final int flags; + + /** + * The date this user was created. + */ + @NonNull private final Date created; + + /** + * Create a DTO from the given user. + * + * @param user the user + * @param creationTime the user's creation time + * @return the user dto + */ + @NonNull + public static UserDTO asDTO(@NonNull User user, @NonNull Date creationTime) { + return new UserDTO(user.getId(), user.getEmail(), user.getUsername(), user.getTier(), + user.getLastLogin(), user.getFlags(), creationTime + ); + } +} \ No newline at end of file diff --git a/src/main/java/cc/pulseapp/api/model/user/UserFlag.java b/src/main/java/cc/pulseapp/api/model/user/UserFlag.java new file mode 100644 index 0000000..de40f1c --- /dev/null +++ b/src/main/java/cc/pulseapp/api/model/user/UserFlag.java @@ -0,0 +1,18 @@ +package cc.pulseapp.api.model.user; + +/** + * Flags for a {@link User}. + * + * @author Braydon + */ +public enum UserFlag { + /** + * The user is disabled. + */ + DISABLED, + + /** + * The user completed the onboarding process. + */ + COMPLETED_ONBOARDING +} \ No newline at end of file diff --git a/src/main/java/cc/pulseapp/api/model/user/UserTier.java b/src/main/java/cc/pulseapp/api/model/user/UserTier.java new file mode 100644 index 0000000..b2da1c2 --- /dev/null +++ b/src/main/java/cc/pulseapp/api/model/user/UserTier.java @@ -0,0 +1,35 @@ +package cc.pulseapp.api.model.user; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.ToString; + +/** + * The tier of a {@link User}. + * + * @author Braydon + */ +@AllArgsConstructor @Getter @ToString +public enum UserTier { + FREE(1, 2, false, 10); + + /** + * The maximum number of organizations a user can have. + */ + private final int maxOrganizations; + + /** + * The maximum number of status pages a user can have. + */ + private final int maxStatusPages; + + /** + * Whether this tier has banners on status pages. + */ + private final boolean statusPageBanners; + + /** + * The maximum number of components a user can have on a status page. + */ + private final int maxStatusPageComponents; +} \ No newline at end of file diff --git a/src/main/java/cc/pulseapp/api/model/user/input/UserRegistrationInput.java b/src/main/java/cc/pulseapp/api/model/user/input/UserRegistrationInput.java new file mode 100644 index 0000000..0be7874 --- /dev/null +++ b/src/main/java/cc/pulseapp/api/model/user/input/UserRegistrationInput.java @@ -0,0 +1,51 @@ +package cc.pulseapp.api.model.user.input; + +import cc.pulseapp.api.model.user.User; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.ToString; + +/** + * Input to register a new {@link User}. + * + * @author Braydon + */ +@AllArgsConstructor @Getter @ToString +public final class UserRegistrationInput { + /** + * The name of the user to create. + */ + private final String email; + + /** + * The username of the user to create. + */ + private final String username; + + /** + * The password of the user to create. + */ + private final String password; + + /** + * The confirmation password of the user to create. + */ + private final String passwordConfirmation; + + /** + * The captcha response token to validate. + */ + private final String captchaResponse; + + /** + * Check if this input is valid. + * + * @return whether this input is valid + */ + public boolean isValid() { + return email != null && (!email.isBlank()) + && password != null && (!password.isBlank()) + && passwordConfirmation != null && (!passwordConfirmation.isBlank()) + && captchaResponse != null && (!captchaResponse.isBlank()); + } +} \ No newline at end of file diff --git a/src/main/java/cc/pulseapp/api/repository/AuthTokenRepository.java b/src/main/java/cc/pulseapp/api/repository/AuthTokenRepository.java new file mode 100644 index 0000000..4357027 --- /dev/null +++ b/src/main/java/cc/pulseapp/api/repository/AuthTokenRepository.java @@ -0,0 +1,20 @@ +package cc.pulseapp.api.repository; + +import cc.pulseapp.api.model.user.AuthToken; +import lombok.NonNull; +import org.springframework.data.repository.CrudRepository; + +/** + * The repository for {@link AuthToken}'s. + * + * @author Braydon + */ +public interface AuthTokenRepository extends CrudRepository { + /** + * Find an auth token by the access token. + * + * @param accessToken the access token to search by + * @return the auth token, null if none + */ + AuthToken findByAccessToken(@NonNull String accessToken); +} \ No newline at end of file diff --git a/src/main/java/cc/pulseapp/api/repository/UserRepository.java b/src/main/java/cc/pulseapp/api/repository/UserRepository.java new file mode 100644 index 0000000..840d4f8 --- /dev/null +++ b/src/main/java/cc/pulseapp/api/repository/UserRepository.java @@ -0,0 +1,20 @@ +package cc.pulseapp.api.repository; + +import cc.pulseapp.api.model.user.User; +import lombok.NonNull; +import org.springframework.data.mongodb.repository.MongoRepository; + +/** + * The repository for interacting with {@link User}'s. + * + * @author Braydon + */ +public interface UserRepository extends MongoRepository { + /** + * Find a user by their email. + * + * @param email the email of the user + * @return the user with the email + */ + User findByEmailIgnoreCase(@NonNull String email); +} \ No newline at end of file diff --git a/src/main/java/cc/pulseapp/api/service/AuthService.java b/src/main/java/cc/pulseapp/api/service/AuthService.java new file mode 100644 index 0000000..86c8a62 --- /dev/null +++ b/src/main/java/cc/pulseapp/api/service/AuthService.java @@ -0,0 +1,147 @@ +package cc.pulseapp.api.service; + +import cc.pulseapp.api.common.EnvironmentUtils; +import cc.pulseapp.api.common.HashUtils; +import cc.pulseapp.api.common.StringUtils; +import cc.pulseapp.api.exception.impl.BadRequestException; +import cc.pulseapp.api.model.IGenericResponse; +import cc.pulseapp.api.model.user.AuthToken; +import cc.pulseapp.api.model.user.User; +import cc.pulseapp.api.model.user.UserFlag; +import cc.pulseapp.api.model.user.UserTier; +import cc.pulseapp.api.model.user.input.UserRegistrationInput; +import cc.pulseapp.api.repository.AuthTokenRepository; +import cc.pulseapp.api.repository.UserRepository; +import lombok.NonNull; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.Base64; +import java.util.Date; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +/** + * @author Braydon + */ +@Service +public final class AuthService { + /** + * The service to use for captcha validation. + */ + @NonNull private final CaptchaService captchaService; + + /** + * The service to use for snowflake generation and timestamp extraction. + */ + @NonNull private final SnowflakeService snowflakeService; + + /** + * The repository to store and retrieve users. + */ + @NonNull private final UserRepository userRepository; + + /** + * The auth token repository for generating and validating auth tokens. + */ + @NonNull private final AuthTokenRepository authTokenRepository; + + @Autowired + public AuthService(@NonNull CaptchaService captchaService, @NonNull SnowflakeService snowflakeService, + @NonNull UserRepository userRepository, @NonNull AuthTokenRepository authTokenRepository) { + this.captchaService = captchaService; + this.snowflakeService = snowflakeService; + this.userRepository = userRepository; + this.authTokenRepository = authTokenRepository; + } + + /** + * Register a new user. + * + * @param input the registration input + * @return the registered user's auth token + * @throws BadRequestException if the input has an error + */ + @NonNull + public AuthToken registerUser(UserRegistrationInput input) throws BadRequestException { + validateRegistrationInput(input); // Ensure the input is valid + + // Ensure the given email hasn't been used before + if (userRepository.findByEmailIgnoreCase(input.getEmail()) != null) { + throw new BadRequestException(Error.EMAIL_ALREADY_USED); + } + + // Create the user and return it + byte[] salt = HashUtils.generateSalt(); + Date now = new Date(); + return generateAuthToken(userRepository.save(new User( + snowflakeService.generateSnowflake(), input.getEmail(), input.getUsername(), + HashUtils.hash(salt, input.getPassword()), Base64.getEncoder().encodeToString(salt), + UserTier.FREE, 0, now + ))); + } + + /** + * Generate an auth token for a user. + * + * @param user the user to generate for + * @return the generated auth token + * @throws BadRequestException if the user is disabled + */ + @NonNull + private AuthToken generateAuthToken(@NonNull User user) throws BadRequestException { + // User's account has been disabled + if (user.hasFlag(UserFlag.DISABLED)) { + throw new BadRequestException(Error.USER_DISABLED); + } + return authTokenRepository.save(new AuthToken( + UUID.randomUUID(), user.getId(), + StringUtils.generateRandom(128, true, true, false), + StringUtils.generateRandom(128, true, true, false), + System.currentTimeMillis() + TimeUnit.DAYS.toMillis(30L) + )); + } + + /** + * Validate the given registration input. + * + * @param input the registration input + * @throws BadRequestException if the input has an error + */ + private void validateRegistrationInput(UserRegistrationInput input) throws BadRequestException { + // Ensure the input was provided + if (input == null || (!input.isValid())) { + throw new BadRequestException(Error.MALFORMED_INPUT); + } + // Ensure the email is valid + if (!StringUtils.isValidEmail(input.getEmail())) { + throw new BadRequestException(Error.EMAIL_INVALID); + } + // Ensure the username is valid + if (!StringUtils.isValidUsername(input.getUsername())) { + throw new BadRequestException(Error.USERNAME_INVALID); + } + // Password and confirmed password must match + if (!input.getPassword().equals(input.getPasswordConfirmation())) { + throw new BadRequestException(Error.PASSWORDS_DO_NOT_MATCH); + } + // The password must meet the requirements + StringUtils.PasswordError passwordError = StringUtils.checkPasswordRequirements(input.getPassword()); + if (passwordError != null) { + throw new BadRequestException(passwordError); + } + // Finally validate the captcha + if (EnvironmentUtils.isProduction()) { + captchaService.validateCaptcha(input.getCaptchaResponse()); + } + } + + private enum Error implements IGenericResponse { + MALFORMED_INPUT, + EMAIL_INVALID, + USERNAME_INVALID, + PASSWORDS_DO_NOT_MATCH, + EMAIL_ALREADY_USED, + USER_DISABLED + } +} \ No newline at end of file diff --git a/src/main/java/cc/pulseapp/api/service/CaptchaService.java b/src/main/java/cc/pulseapp/api/service/CaptchaService.java new file mode 100644 index 0000000..90ed150 --- /dev/null +++ b/src/main/java/cc/pulseapp/api/service/CaptchaService.java @@ -0,0 +1,40 @@ +package cc.pulseapp.api.service; + +import cc.pulseapp.api.exception.impl.BadRequestException; +import cc.pulseapp.api.model.IGenericResponse; +import com.google.gson.JsonObject; +import kong.unirest.core.HttpResponse; +import kong.unirest.core.JsonNode; +import kong.unirest.core.Unirest; +import lombok.NonNull; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +/** + * @author Braydon + */ +@Service +public final class CaptchaService { + @Value("${captcha.secret}") + private String secretKey; + + /** + * Validates the captcha response. + * + * @param captchaResponse the response to validate + * @throws BadRequestException if the response is invalid + */ + public void validateCaptcha(@NonNull String captchaResponse) throws BadRequestException { + JsonObject body = new JsonObject(); + body.addProperty("secret", secretKey); + body.addProperty("response", captchaResponse); + HttpResponse response = Unirest.post("https://challenges.cloudflare.com/turnstile/v0/siteverify").body(body).asJson(); + if (!response.getBody().getObject().getBoolean("success")) { + throw new BadRequestException(Error.CAPTCHA_INVALID); + } + } + + public enum Error implements IGenericResponse { + CAPTCHA_INVALID + } +} \ No newline at end of file diff --git a/src/main/java/cc/pulseapp/api/service/SnowflakeService.java b/src/main/java/cc/pulseapp/api/service/SnowflakeService.java new file mode 100644 index 0000000..5273f6b --- /dev/null +++ b/src/main/java/cc/pulseapp/api/service/SnowflakeService.java @@ -0,0 +1,48 @@ +package cc.pulseapp.api.service; + +import com.relops.snowflake.Snowflake; +import lombok.NonNull; +import org.springframework.stereotype.Service; + +/** + * The service responsible for generating snowflakes! + * + * @author Braydon + */ +@Service +public final class SnowflakeService { + private static final long TIMESTAMP_SHIFT = Snowflake.NODE_SHIFT + Snowflake.SEQ_SHIFT; + + /** + * The snowflake instance to use. + */ + @NonNull private final Snowflake snowflake; + + private SnowflakeService() { + snowflake = new Snowflake(7); + } + + /** + * Generate a new snowflake. + * + * @return the generated snowflake + */ + public long generateSnowflake() { + return snowflake.next(); + } + + /** + * Extract the creation time of the given + * snowflake by reversing the snowflake algorithm. + *

+ * The snowflake is right-shifted by 22 + * bits to get the original timestamp. + *

+ * + * @param snowflake the snowflake + * @return the snowflake unix creation time + */ + public long extractCreationTime(long snowflake) { + return snowflake >>> TIMESTAMP_SHIFT; + } +} \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 978cd76..a99e5b0 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -8,19 +8,33 @@ logging: file: path: "./logs" -# Sentry Configuration -sentry: - dsn: "CHANGE_ME" - tracesSampleRate: 1.0 - environment: "development" +# Cloudflare Captcha Configuration +captcha: + secret: "CHANGE_ME" # QuestDB Configuration (Metrics) questdb: enabled: false uri: "http::addr=localhost:9000;username=tether;password=p4$$w0rd;auto_flush_interval=5000;" +# Sentry Configuration +sentry: + dsn: "" # Leave blank for no sentry + tracesSampleRate: 1.0 + environment: "development" + # Spring Configuration spring: + data: + # MongoDB Configuration + mongodb: + uri: "mongodb://pulseapp:p4$$w0rd@localhost:27017/pulseapp?authSource=admin" + auto-index-creation: true # Automatically create collection indexes + + # Don't serialize null values by default with Jackson + jackson: + default-property-inclusion: non_null + # Ignore banner: location: "classpath:banner.txt" \ No newline at end of file