diff --git a/src/main/java/cc/pulseapp/api/common/IPUtils.java b/src/main/java/cc/pulseapp/api/common/RequestUtils.java similarity index 74% rename from src/main/java/cc/pulseapp/api/common/IPUtils.java rename to src/main/java/cc/pulseapp/api/common/RequestUtils.java index 785254a..de98bba 100644 --- a/src/main/java/cc/pulseapp/api/common/IPUtils.java +++ b/src/main/java/cc/pulseapp/api/common/RequestUtils.java @@ -3,12 +3,13 @@ package cc.pulseapp.api.common; import jakarta.servlet.http.HttpServletRequest; import lombok.NonNull; import lombok.experimental.UtilityClass; +import org.springframework.http.HttpHeaders; /** * @author Braydon */ @UtilityClass -public final class IPUtils { +public final class RequestUtils { private static final String[] IP_HEADERS = new String[] { "CF-Connecting-IP", "X-Forwarded-For" @@ -41,4 +42,15 @@ public final class IPUtils { } return ip; } + + /** + * Get the user agent from the given request. + * + * @param request the request to get from + * @return the user agent + */ + @NonNull + public static String getUserAgent(@NonNull HttpServletRequest request) { + return request.getHeader(HttpHeaders.USER_AGENT); + } } diff --git a/src/main/java/cc/pulseapp/api/controller/v1/AuthController.java b/src/main/java/cc/pulseapp/api/controller/v1/AuthController.java index 598e156..3e13d84 100644 --- a/src/main/java/cc/pulseapp/api/controller/v1/AuthController.java +++ b/src/main/java/cc/pulseapp/api/controller/v1/AuthController.java @@ -1,9 +1,11 @@ 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.Session; +import cc.pulseapp.api.model.user.input.UserLoginInput; import cc.pulseapp.api.model.user.input.UserRegistrationInput; import cc.pulseapp.api.service.AuthService; +import jakarta.servlet.http.HttpServletRequest; import lombok.NonNull; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.MediaType; @@ -32,8 +34,29 @@ public final class AuthController { this.authService = authService; } + /** + * A POST endpoint to register a new user. + * + * @param request the http request + * @param input the registration input + * @return the session for the registered user + * @throws BadRequestException if the registration fails + */ @PostMapping("/register") @ResponseBody @NonNull - public ResponseEntity register(UserRegistrationInput input) throws BadRequestException { - return ResponseEntity.ok(authService.registerUser(input)); + public ResponseEntity register(@NonNull HttpServletRequest request, UserRegistrationInput input) throws BadRequestException { + return ResponseEntity.ok(authService.registerUser(request, input)); + } + + /** + * A POST endpoint to login a user. + * + * @param request the http request + * @param input the login input + * @return the session for the login user + * @throws BadRequestException if the login fails + */ + @PostMapping("/login") @ResponseBody @NonNull + public ResponseEntity login(@NonNull HttpServletRequest request, UserLoginInput input) throws BadRequestException { + return ResponseEntity.ok(authService.loginUser(request, input)); } } \ 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 324540d..433cc58 100644 --- a/src/main/java/cc/pulseapp/api/log/RequestLogger.java +++ b/src/main/java/cc/pulseapp/api/log/RequestLogger.java @@ -1,6 +1,6 @@ package cc.pulseapp.api.log; -import cc.pulseapp.api.common.IPUtils; +import cc.pulseapp.api.common.RequestUtils; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.NonNull; @@ -37,7 +37,7 @@ public class RequestLogger implements ResponseBodyAdvice { HttpServletResponse response = ((ServletServerHttpResponse) rawResponse).getServletResponse(); // Get the request ip ip - String ip = IPUtils.getRealIp(request); + String ip = RequestUtils.getRealIp(request); log.info("%s | %s %s %s %s".formatted( ip, request.getMethod(), request.getRequestURI(), request.getProtocol(), response.getStatus() diff --git a/src/main/java/cc/pulseapp/api/model/org/Organization.java b/src/main/java/cc/pulseapp/api/model/org/Organization.java index a474adc..6f69ca1 100644 --- a/src/main/java/cc/pulseapp/api/model/org/Organization.java +++ b/src/main/java/cc/pulseapp/api/model/org/Organization.java @@ -18,7 +18,7 @@ public final class Organization { /** * The snowflake id of this organization. */ - @Id @EqualsAndHashCode.Include private final long id; + @Id @EqualsAndHashCode.Include private final long snowflake; /** * The name of this organization. diff --git a/src/main/java/cc/pulseapp/api/model/page/StatusPage.java b/src/main/java/cc/pulseapp/api/model/page/StatusPage.java index 0826827..958968b 100644 --- a/src/main/java/cc/pulseapp/api/model/page/StatusPage.java +++ b/src/main/java/cc/pulseapp/api/model/page/StatusPage.java @@ -16,7 +16,7 @@ public final class StatusPage { /** * The snowflake id of this status page. */ - @Id @EqualsAndHashCode.Include private final long id; + @Id @EqualsAndHashCode.Include private final long snowflake; /** * The name of this status page. diff --git a/src/main/java/cc/pulseapp/api/model/user/AuthToken.java b/src/main/java/cc/pulseapp/api/model/user/Session.java similarity index 57% rename from src/main/java/cc/pulseapp/api/model/user/AuthToken.java rename to src/main/java/cc/pulseapp/api/model/user/Session.java index e503bd3..4211130 100644 --- a/src/main/java/cc/pulseapp/api/model/user/AuthToken.java +++ b/src/main/java/cc/pulseapp/api/model/user/Session.java @@ -8,23 +8,21 @@ 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}. + * A session 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 { +@RedisHash(value = "sessions", timeToLive = 30 * 24 * 60 * 60) // Expire in 30 days (days, hours, mins, secs) +public final class Session { /** - * The ID of this token. + * The snowflake of this session. */ - @Id @JsonIgnore @NonNull private final UUID id; + @Id @JsonIgnore private final long snowflake; /** - * The snowflake of the user this token is for. + * The snowflake of the user this session is for. */ @JsonIgnore private final long userSnowflake; @@ -38,6 +36,16 @@ public final class AuthToken { */ @Indexed @NonNull private final String refreshToken; + /** + * The IP address of the user that created this session. + */ + @NonNull @JsonIgnore private final String ipAddress; + + /** + * The user agent of the user that created this session. + */ + @NonNull @JsonIgnore private final String userAgent; + /** * The unix timestamp of when this token expires. */ diff --git a/src/main/java/cc/pulseapp/api/model/user/User.java b/src/main/java/cc/pulseapp/api/model/user/User.java index 6b12d87..a5732fb 100644 --- a/src/main/java/cc/pulseapp/api/model/user/User.java +++ b/src/main/java/cc/pulseapp/api/model/user/User.java @@ -10,14 +10,14 @@ import java.util.Date; /** * @author Braydon */ -@AllArgsConstructor @Getter +@AllArgsConstructor @Setter @Getter @EqualsAndHashCode(onlyExplicitlyIncluded = true) @ToString @Document("users") public final class User { /** * The snowflake id of this user. */ - @Id @EqualsAndHashCode.Include private final long id; + @Id @EqualsAndHashCode.Include private final long snowflake; /** * This user's email. @@ -52,7 +52,7 @@ public final class User { /** * The date this user last logged in. */ - @NonNull private final Date lastLogin; + @NonNull private Date lastLogin; /** * Check if this user has a given flag. diff --git a/src/main/java/cc/pulseapp/api/model/user/UserDTO.java b/src/main/java/cc/pulseapp/api/model/user/UserDTO.java index 78f1588..513262b 100644 --- a/src/main/java/cc/pulseapp/api/model/user/UserDTO.java +++ b/src/main/java/cc/pulseapp/api/model/user/UserDTO.java @@ -16,7 +16,7 @@ public final class UserDTO { /** * The snowflake id of this user. */ - @EqualsAndHashCode.Include private final long id; + @EqualsAndHashCode.Include private final long snowflake; /** * This user's email. @@ -57,7 +57,7 @@ public final class UserDTO { */ @NonNull public static UserDTO asDTO(@NonNull User user, @NonNull Date creationTime) { - return new UserDTO(user.getId(), user.getEmail(), user.getUsername(), user.getTier(), + return new UserDTO(user.getSnowflake(), user.getEmail(), user.getUsername(), user.getTier(), user.getLastLogin(), user.getFlags(), creationTime ); } diff --git a/src/main/java/cc/pulseapp/api/model/user/input/UserLoginInput.java b/src/main/java/cc/pulseapp/api/model/user/input/UserLoginInput.java new file mode 100644 index 0000000..8b0a4a1 --- /dev/null +++ b/src/main/java/cc/pulseapp/api/model/user/input/UserLoginInput.java @@ -0,0 +1,45 @@ +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 login a {@link User}. + * + * @author Braydon + */ +@AllArgsConstructor @Getter @ToString +public final class UserLoginInput { + /** + * The email of the user to login with. + */ + private final String email; + + /** + * The username of the user to login with. + */ + private final String username; + + /** + * The password of the user to login with. + */ + private final String password; + + /** + * 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()) || username != null && (!username.isBlank())) + && password != null && (!password.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 deleted file mode 100644 index 4357027..0000000 --- a/src/main/java/cc/pulseapp/api/repository/AuthTokenRepository.java +++ /dev/null @@ -1,20 +0,0 @@ -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/SessionRepository.java b/src/main/java/cc/pulseapp/api/repository/SessionRepository.java new file mode 100644 index 0000000..ba81a80 --- /dev/null +++ b/src/main/java/cc/pulseapp/api/repository/SessionRepository.java @@ -0,0 +1,20 @@ +package cc.pulseapp.api.repository; + +import cc.pulseapp.api.model.user.Session; +import lombok.NonNull; +import org.springframework.data.repository.CrudRepository; + +/** + * The repository for {@link Session}'s. + * + * @author Braydon + */ +public interface SessionRepository extends CrudRepository { + /** + * Find a session by the access token. + * + * @param accessToken the access token to search by + * @return the session, null if none + */ + Session 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 index 840d4f8..aaae1bd 100644 --- a/src/main/java/cc/pulseapp/api/repository/UserRepository.java +++ b/src/main/java/cc/pulseapp/api/repository/UserRepository.java @@ -17,4 +17,12 @@ public interface UserRepository extends MongoRepository { * @return the user with the email */ User findByEmailIgnoreCase(@NonNull String email); + + /** + * Find a user by their username. + * + * @param username the username of the user + * @return the user with the username + */ + User findByUsernameIgnoreCase(@NonNull String username); } \ 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 index 86c8a62..6cedd75 100644 --- a/src/main/java/cc/pulseapp/api/service/AuthService.java +++ b/src/main/java/cc/pulseapp/api/service/AuthService.java @@ -2,23 +2,26 @@ package cc.pulseapp.api.service; import cc.pulseapp.api.common.EnvironmentUtils; import cc.pulseapp.api.common.HashUtils; +import cc.pulseapp.api.common.RequestUtils; 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.Session; 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.UserLoginInput; import cc.pulseapp.api.model.user.input.UserRegistrationInput; -import cc.pulseapp.api.repository.AuthTokenRepository; +import cc.pulseapp.api.repository.SessionRepository; import cc.pulseapp.api.repository.UserRepository; +import jakarta.servlet.http.HttpServletRequest; 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.Objects; import java.util.concurrent.TimeUnit; /** @@ -44,26 +47,27 @@ public final class AuthService { /** * The auth token repository for generating and validating auth tokens. */ - @NonNull private final AuthTokenRepository authTokenRepository; + @NonNull private final SessionRepository sessionRepository; @Autowired public AuthService(@NonNull CaptchaService captchaService, @NonNull SnowflakeService snowflakeService, - @NonNull UserRepository userRepository, @NonNull AuthTokenRepository authTokenRepository) { + @NonNull UserRepository userRepository, @NonNull SessionRepository sessionRepository) { this.captchaService = captchaService; this.snowflakeService = snowflakeService; this.userRepository = userRepository; - this.authTokenRepository = authTokenRepository; + this.sessionRepository = sessionRepository; } /** * Register a new user. * - * @param input the registration input + * @param request the http request + * @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 { + public Session registerUser(@NonNull HttpServletRequest request, UserRegistrationInput input) throws BadRequestException { validateRegistrationInput(input); // Ensure the input is valid // Ensure the given email hasn't been used before @@ -74,30 +78,58 @@ public final class AuthService { // Create the user and return it byte[] salt = HashUtils.generateSalt(); Date now = new Date(); - return generateAuthToken(userRepository.save(new User( + return generateSession(request, userRepository.save(new User( snowflakeService.generateSnowflake(), input.getEmail(), input.getUsername(), HashUtils.hash(salt, input.getPassword()), Base64.getEncoder().encodeToString(salt), UserTier.FREE, 0, now ))); } + /** + * Login a user. + * + * @param request the http request + * @param input the login input + * @return the logged in user's auth token + * @throws BadRequestException if the input has an error + */ + @NonNull + public Session loginUser(@NonNull HttpServletRequest request, UserLoginInput input) throws BadRequestException { + validateLoginInput(input); // Ensure the input is valid + + // Lookup the user by the email or username and ensure the user exists + User user = input.getEmail() == null ? userRepository.findByUsernameIgnoreCase(Objects.requireNonNull(input.getUsername())) + : userRepository.findByEmailIgnoreCase(input.getEmail()); + if (user == null) { + throw new BadRequestException(Error.USER_NOT_FOUND); + } + // Ensure the password matches + if (!HashUtils.hash(Base64.getDecoder().decode(user.getPasswordSalt()), input.getPassword()).equals(user.getPassword())) { + throw new BadRequestException(Error.PASSWORDS_DO_NOT_MATCH); + } + user.setLastLogin(new Date()); + return generateSession(request, userRepository.save(user)); + } + /** * Generate an auth token for a user. * - * @param user the user to generate for + * @param request the http request + * @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 { + private Session generateSession(@NonNull HttpServletRequest request, @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(), + return sessionRepository.save(new Session( + snowflakeService.generateSnowflake(), user.getSnowflake(), StringUtils.generateRandom(128, true, true, false), StringUtils.generateRandom(128, true, true, false), + RequestUtils.getRealIp(request), RequestUtils.getUserAgent(request), System.currentTimeMillis() + TimeUnit.DAYS.toMillis(30L) )); } @@ -136,10 +168,36 @@ public final class AuthService { } } + /** + * Validate the given login input. + * + * @param input the login input + * @throws BadRequestException if the input has an error + */ + private void validateLoginInput(UserLoginInput 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 (input.getEmail() != null && (!StringUtils.isValidEmail(input.getEmail()))) { + throw new BadRequestException(Error.EMAIL_INVALID); + } + // Ensure the username is valid + if (input.getUsername() != null && (!StringUtils.isValidUsername(input.getUsername()))) { + throw new BadRequestException(Error.USERNAME_INVALID); + } + // Finally validate the captcha + if (EnvironmentUtils.isProduction()) { + captchaService.validateCaptcha(input.getCaptchaResponse()); + } + } + private enum Error implements IGenericResponse { MALFORMED_INPUT, EMAIL_INVALID, USERNAME_INVALID, + USER_NOT_FOUND, PASSWORDS_DO_NOT_MATCH, EMAIL_ALREADY_USED, USER_DISABLED