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

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

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

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

@ -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.
*/

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

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

@ -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
*/
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.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