logins work

This commit is contained in:
Braydon 2024-09-16 20:40:56 -04:00
parent aa3d381dd3
commit d326767c7c
13 changed files with 208 additions and 54 deletions

@ -3,12 +3,13 @@ package cc.pulseapp.api.common;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import lombok.NonNull; import lombok.NonNull;
import lombok.experimental.UtilityClass; import lombok.experimental.UtilityClass;
import org.springframework.http.HttpHeaders;
/** /**
* @author Braydon * @author Braydon
*/ */
@UtilityClass @UtilityClass
public final class IPUtils { public final class RequestUtils {
private static final String[] IP_HEADERS = new String[] { private static final String[] IP_HEADERS = new String[] {
"CF-Connecting-IP", "CF-Connecting-IP",
"X-Forwarded-For" "X-Forwarded-For"
@ -41,4 +42,15 @@ public final class IPUtils {
} }
return ip; 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);
}
} }

@ -1,9 +1,11 @@
package cc.pulseapp.api.controller.v1; package cc.pulseapp.api.controller.v1;
import cc.pulseapp.api.exception.impl.BadRequestException; 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.model.user.input.UserRegistrationInput;
import cc.pulseapp.api.service.AuthService; import cc.pulseapp.api.service.AuthService;
import jakarta.servlet.http.HttpServletRequest;
import lombok.NonNull; import lombok.NonNull;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
@ -32,8 +34,29 @@ public final class AuthController {
this.authService = authService; 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 @PostMapping("/register") @ResponseBody @NonNull
public ResponseEntity<AuthToken> register(UserRegistrationInput input) throws BadRequestException { public ResponseEntity<Session> register(@NonNull HttpServletRequest request, UserRegistrationInput input) throws BadRequestException {
return ResponseEntity.ok(authService.registerUser(input)); 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<Session> login(@NonNull HttpServletRequest request, UserLoginInput input) throws BadRequestException {
return ResponseEntity.ok(authService.loginUser(request, input));
} }
} }

@ -1,6 +1,6 @@
package cc.pulseapp.api.log; 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.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import lombok.NonNull; import lombok.NonNull;
@ -37,7 +37,7 @@ public class RequestLogger implements ResponseBodyAdvice<Object> {
HttpServletResponse response = ((ServletServerHttpResponse) rawResponse).getServletResponse(); HttpServletResponse response = ((ServletServerHttpResponse) rawResponse).getServletResponse();
// Get the request ip ip // Get the request ip ip
String ip = IPUtils.getRealIp(request); String ip = RequestUtils.getRealIp(request);
log.info("%s | %s %s %s %s".formatted( log.info("%s | %s %s %s %s".formatted(
ip, request.getMethod(), request.getRequestURI(), request.getProtocol(), response.getStatus() ip, request.getMethod(), request.getRequestURI(), request.getProtocol(), response.getStatus()

@ -18,7 +18,7 @@ public final class Organization {
/** /**
* The snowflake id of this 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. * The name of this organization.

@ -16,7 +16,7 @@ public final class StatusPage {
/** /**
* The snowflake id of this status page. * 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. * The name of this status page.

@ -8,23 +8,21 @@ import org.springframework.data.annotation.Id;
import org.springframework.data.redis.core.RedisHash; import org.springframework.data.redis.core.RedisHash;
import org.springframework.data.redis.core.index.Indexed; 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 * @author Braydon
*/ */
@AllArgsConstructor @Getter @AllArgsConstructor @Getter
@RedisHash(value = "auth_token", timeToLive = 30 * 24 * 60 * 60) // Expire in 30 days (days, hours, mins, secs) @RedisHash(value = "sessions", timeToLive = 30 * 24 * 60 * 60) // Expire in 30 days (days, hours, mins, secs)
public final class AuthToken { 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; @JsonIgnore private final long userSnowflake;
@ -38,6 +36,16 @@ public final class AuthToken {
*/ */
@Indexed @NonNull private final String refreshToken; @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. * The unix timestamp of when this token expires.
*/ */

@ -10,14 +10,14 @@ import java.util.Date;
/** /**
* @author Braydon * @author Braydon
*/ */
@AllArgsConstructor @Getter @AllArgsConstructor @Setter @Getter
@EqualsAndHashCode(onlyExplicitlyIncluded = true) @ToString @EqualsAndHashCode(onlyExplicitlyIncluded = true) @ToString
@Document("users") @Document("users")
public final class User { public final class User {
/** /**
* The snowflake id of this 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. * This user's email.
@ -52,7 +52,7 @@ public final class User {
/** /**
* The date this user last logged in. * The date this user last logged in.
*/ */
@NonNull private final Date lastLogin; @NonNull private Date lastLogin;
/** /**
* Check if this user has a given flag. * Check if this user has a given flag.

@ -16,7 +16,7 @@ public final class UserDTO {
/** /**
* The snowflake id of this user. * The snowflake id of this user.
*/ */
@EqualsAndHashCode.Include private final long id; @EqualsAndHashCode.Include private final long snowflake;
/** /**
* This user's email. * This user's email.
@ -57,7 +57,7 @@ public final class UserDTO {
*/ */
@NonNull @NonNull
public static UserDTO asDTO(@NonNull User user, @NonNull Date creationTime) { 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 user.getLastLogin(), user.getFlags(), creationTime
); );
} }

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

@ -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<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.Session;
import lombok.NonNull;
import org.springframework.data.repository.CrudRepository;
/**
* The repository for {@link Session}'s.
*
* @author Braydon
*/
public interface SessionRepository extends CrudRepository<Session, String> {
/**
* 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);
}

@ -17,4 +17,12 @@ public interface UserRepository extends MongoRepository<User, Long> {
* @return the user with the email * @return the user with the email
*/ */
User findByEmailIgnoreCase(@NonNull String 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);
} }

@ -2,23 +2,26 @@ package cc.pulseapp.api.service;
import cc.pulseapp.api.common.EnvironmentUtils; import cc.pulseapp.api.common.EnvironmentUtils;
import cc.pulseapp.api.common.HashUtils; import cc.pulseapp.api.common.HashUtils;
import cc.pulseapp.api.common.RequestUtils;
import cc.pulseapp.api.common.StringUtils; import cc.pulseapp.api.common.StringUtils;
import cc.pulseapp.api.exception.impl.BadRequestException; import cc.pulseapp.api.exception.impl.BadRequestException;
import cc.pulseapp.api.model.IGenericResponse; 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.User;
import cc.pulseapp.api.model.user.UserFlag; import cc.pulseapp.api.model.user.UserFlag;
import cc.pulseapp.api.model.user.UserTier; 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.model.user.input.UserRegistrationInput;
import cc.pulseapp.api.repository.AuthTokenRepository; import cc.pulseapp.api.repository.SessionRepository;
import cc.pulseapp.api.repository.UserRepository; import cc.pulseapp.api.repository.UserRepository;
import jakarta.servlet.http.HttpServletRequest;
import lombok.NonNull; import lombok.NonNull;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.Base64; import java.util.Base64;
import java.util.Date; import java.util.Date;
import java.util.UUID; import java.util.Objects;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
/** /**
@ -44,26 +47,27 @@ public final class AuthService {
/** /**
* The auth token repository for generating and validating auth tokens. * The auth token repository for generating and validating auth tokens.
*/ */
@NonNull private final AuthTokenRepository authTokenRepository; @NonNull private final SessionRepository sessionRepository;
@Autowired @Autowired
public AuthService(@NonNull CaptchaService captchaService, @NonNull SnowflakeService snowflakeService, public AuthService(@NonNull CaptchaService captchaService, @NonNull SnowflakeService snowflakeService,
@NonNull UserRepository userRepository, @NonNull AuthTokenRepository authTokenRepository) { @NonNull UserRepository userRepository, @NonNull SessionRepository sessionRepository) {
this.captchaService = captchaService; this.captchaService = captchaService;
this.snowflakeService = snowflakeService; this.snowflakeService = snowflakeService;
this.userRepository = userRepository; this.userRepository = userRepository;
this.authTokenRepository = authTokenRepository; this.sessionRepository = sessionRepository;
} }
/** /**
* Register a new user. * Register a new user.
* *
* @param request the http request
* @param input the registration input * @param input the registration input
* @return the registered user's auth token * @return the registered user's auth token
* @throws BadRequestException if the input has an error * @throws BadRequestException if the input has an error
*/ */
@NonNull @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 validateRegistrationInput(input); // Ensure the input is valid
// Ensure the given email hasn't been used before // Ensure the given email hasn't been used before
@ -74,30 +78,58 @@ public final class AuthService {
// Create the user and return it // Create the user and return it
byte[] salt = HashUtils.generateSalt(); byte[] salt = HashUtils.generateSalt();
Date now = new Date(); Date now = new Date();
return generateAuthToken(userRepository.save(new User( return generateSession(request, userRepository.save(new User(
snowflakeService.generateSnowflake(), input.getEmail(), input.getUsername(), snowflakeService.generateSnowflake(), input.getEmail(), input.getUsername(),
HashUtils.hash(salt, input.getPassword()), Base64.getEncoder().encodeToString(salt), HashUtils.hash(salt, input.getPassword()), Base64.getEncoder().encodeToString(salt),
UserTier.FREE, 0, now 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. * Generate an auth token for a user.
* *
* @param request the http request
* @param user the user to generate for * @param user the user to generate for
* @return the generated auth token * @return the generated auth token
* @throws BadRequestException if the user is disabled * @throws BadRequestException if the user is disabled
*/ */
@NonNull @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 // User's account has been disabled
if (user.hasFlag(UserFlag.DISABLED)) { if (user.hasFlag(UserFlag.DISABLED)) {
throw new BadRequestException(Error.USER_DISABLED); throw new BadRequestException(Error.USER_DISABLED);
} }
return authTokenRepository.save(new AuthToken( return sessionRepository.save(new Session(
UUID.randomUUID(), user.getId(), snowflakeService.generateSnowflake(), user.getSnowflake(),
StringUtils.generateRandom(128, true, true, false), StringUtils.generateRandom(128, true, true, false),
StringUtils.generateRandom(128, true, true, false), StringUtils.generateRandom(128, true, true, false),
RequestUtils.getRealIp(request), RequestUtils.getUserAgent(request),
System.currentTimeMillis() + TimeUnit.DAYS.toMillis(30L) 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 { private enum Error implements IGenericResponse {
MALFORMED_INPUT, MALFORMED_INPUT,
EMAIL_INVALID, EMAIL_INVALID,
USERNAME_INVALID, USERNAME_INVALID,
USER_NOT_FOUND,
PASSWORDS_DO_NOT_MATCH, PASSWORDS_DO_NOT_MATCH,
EMAIL_ALREADY_USED, EMAIL_ALREADY_USED,
USER_DISABLED USER_DISABLED