user registration

This commit is contained in:
Braydon 2024-09-16 19:56:15 -04:00
parent 1de8a8df8c
commit aa3d381dd3
27 changed files with 1128 additions and 50 deletions

@ -0,0 +1,3 @@
# API
The backend API responsible for making things work!

63
pom.xml

@ -48,6 +48,19 @@
</plugins>
</build>
<!-- Dependency Management -->
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.konghq</groupId>
<artifactId>unirest-java-bom</artifactId>
<version>4.4.4</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<!-- Dependencies -->
<dependencies>
<!-- Spring -->
@ -63,14 +76,52 @@
<version>1.18.34</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>com.relops</groupId>
<artifactId>snowflake</artifactId>
<version>1.1</version>
<scope>compile</scope>
</dependency>
<!-- Unirest -->
<dependency>
<groupId>com.konghq</groupId>
<artifactId>unirest-java-core</artifactId>
</dependency>
<dependency>
<groupId>com.konghq</groupId>
<artifactId>unirest-modules-gson</artifactId>
</dependency>
<!-- MongoDB -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-mongodb</artifactId>
</dependency>
<!-- Redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<exclusions>
<exclusion>
<groupId>io.lettuce</groupId>
<artifactId>lettuce-core</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
</dependency>
<!-- Error Reporting & Metrics -->
<!-- <dependency>-->
<!-- <groupId>io.sentry</groupId>-->
<!-- <artifactId>sentry-spring-boot-starter-jakarta</artifactId>-->
<!-- <version>8.0.0-alpha.4</version>-->
<!-- <scope>compile</scope>-->
<!-- </dependency>-->
<dependency>
<groupId>io.sentry</groupId>
<artifactId>sentry-spring-boot-starter-jakarta</artifactId>
<version>8.0.0-alpha.4</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.questdb</groupId>
<artifactId>questdb</artifactId>

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

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

@ -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<String, Object> redisTemplate() {
RedisTemplate<String, Object> 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);
}
}

@ -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<AppInformation> getAppInfo() {
return ResponseEntity.ok(new AppInformation(
buildProperties == null ? "unknown" : buildProperties.getVersion(),
EnvironmentUtils.isProduction() ? "production" : "staging"
));
}
}

@ -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<AuthToken> register(UserRegistrationInput input) throws BadRequestException {
return ResponseEntity.ok(authService.registerUser(input));
}
}

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

@ -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<Object> {
@Override
public boolean supports(@NonNull MethodParameter returnType, @NonNull Class<? extends HttpMessageConverter<?>> converterType) {

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

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

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

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

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

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

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

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

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

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

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

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

@ -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<AuthToken, String> {
/**
* 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);
}

@ -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<User, Long> {
/**
* Find a user by their email.
*
* @param email the email of the user
* @return the user with the email
*/
User findByEmailIgnoreCase(@NonNull String email);
}

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

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

@ -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.
* <p>
* The snowflake is right-shifted by 22
* bits to get the original timestamp.
* </p>
*
* @param snowflake the snowflake
* @return the snowflake unix creation time
*/
public long extractCreationTime(long snowflake) {
return snowflake >>> TIMESTAMP_SHIFT;
}
}

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