logins work
This commit is contained in:
parent
aa3d381dd3
commit
d326767c7c
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user