diff options
author | Orangerot <purple@orangerot.dev> | 2024-06-19 00:14:49 +0200 |
---|---|---|
committer | Orangerot <purple@orangerot.dev> | 2024-06-27 12:11:14 +0200 |
commit | 5b8851b6c268d0e93c158908fbfae9f8473db5ff (patch) | |
tree | 7010eb85d86fa2da06ea4ffbcdb01a685d502ae8 /pse-server/src/main/java/org/psesquared/server/authentication/api |
Diffstat (limited to 'pse-server/src/main/java/org/psesquared/server/authentication/api')
15 files changed, 1352 insertions, 0 deletions
diff --git a/pse-server/src/main/java/org/psesquared/server/authentication/api/controller/AuthenticationController.java b/pse-server/src/main/java/org/psesquared/server/authentication/api/controller/AuthenticationController.java new file mode 100644 index 0000000..f580969 --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/authentication/api/controller/AuthenticationController.java @@ -0,0 +1,251 @@ +package org.psesquared.server.authentication.api.controller; + +import jakarta.servlet.http.HttpServletResponse; +import java.util.List; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import org.psesquared.server.authentication.api.service.AuthenticationService; +import org.psesquared.server.config.EmailConfigProperties; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * This is a controller class for the Authentication API that handles the + * requests from the client concerning login/logout and user account management. + */ +@RequestMapping("/api/2") +@RestController +@RequiredArgsConstructor +public class AuthenticationController { + + /** + * The name of the HTTP location header. + */ + private static final String LOCATION_HEADER = "Location"; + + /** + * The service class that this controller calls to further process requests. + */ + private final AuthenticationService authenticationService; + + /** + * The properties class that is used to return some externally stored URLs. + */ + private final EmailConfigProperties emailConfigProperties; + + /** + * The API-endpoint for registering a new + * {@link org.psesquared.server.model.User} with a username, email address and + * password. In order for the account to be used, the registration process + * must be concluded with the verification of the email address. For this an + * email with a link for verification is sent to {@code userInfo.email()}. + * + * @param userInfo The request-wrapper containing username, email and + * password. + * @return {@link HttpStatus#OK} on success, <br> + * {@link HttpStatus#BAD_REQUEST} for invalid user information + * @see AuthenticationService#registerUser(UserInfoRequest) + */ + @PostMapping("/auth/register.json") + public ResponseEntity<String> registerUser( + @RequestBody final UserInfoRequest userInfo) { + return new ResponseEntity<>(authenticationService.registerUser(userInfo)); + } + + /** + * The API-endpoint for verifying a newly created + * {@link org.psesquared.server.model.User}. This method is invoked via the + * link in the verification email that is sent in + * {@link #registerUser(UserInfoRequest)}. + * On success, it transfers the user to the dashboard and on failure it sets + * the following status codes: + * {@link HttpStatus#OK} on success, <br> + * {@link HttpStatus#BAD_REQUEST} user exists and is already verified, + * <br> + * {@link HttpStatus#UNAUTHORIZED} invalid token, <br> + * {@link HttpStatus#NOT_FOUND} user not found + * + * @param username The username of the user that needs to be verified + * @param token The JWT that indicates the authority of the request + * @param response The {@link HttpServletResponse} for setting up a + * redirection to the frontend + * @see AuthenticationService#verifyRegistration(String, String) + */ + @GetMapping("/auth/{username}/verify.json") + public void verifyRegistration( + @PathVariable final String username, + @RequestParam("token") final String token, + @NonNull final HttpServletResponse response) { + HttpStatus status + = authenticationService.verifyRegistration(username, token); + if (status.equals(HttpStatus.OK)) { + response.setHeader(LOCATION_HEADER, + emailConfigProperties.dashboardBaseUrl()); + response.setStatus(HttpStatus.FOUND.value()); + } else { + response.setStatus(status.value()); + } + } + + /** + * The API-endpoint for setting a JWT access token with a lifespan of one hour + * as the "sessionid" cookie for authorization with further requests. <br> + * (This is a secured endpoint requiring authorization via HTTP basic or JWT.) + * + * @param username The username of the user who wants to log in + * @param response The {@link HttpServletResponse} for setting the "sessionid" + * cookie + * @return {@link HttpStatus#OK} on success, <br> + * {@link HttpStatus#NOT_FOUND} user not found + * @see AuthenticationService#login(String, HttpServletResponse) + */ + @PostMapping("/auth/{username}/login.json") + public ResponseEntity<String> login( + @PathVariable final String username, + @NonNull final HttpServletResponse response) { + return new ResponseEntity<>( + authenticationService.login(username, response)); + } + + /** + * The API-endpoint for invalidating the "sessionid" cookie containing a JWT + * access token. Following authorized requests require HTTP basic + * authentication or a new login. <br> + * (This is a secured endpoint requiring authorization via HTTP basic or JWT.) + * + * @param username The username of the user who wants to log out + * @param response The {@link HttpServletResponse} for invalidating the + * "sessionid" cookie + * @return {@link HttpStatus#OK} on success, <br> + * {@link HttpStatus#NOT_FOUND} user not found + * @see AuthenticationService#logout(String, HttpServletResponse) + */ + @PostMapping("/auth/{username}/logout.json") + public ResponseEntity<String> logout( + @PathVariable final String username, + @NonNull final HttpServletResponse response) { + return new ResponseEntity<>( + authenticationService.logout(username, response)); + } + + /** + * The API-endpoint for sending an email to the given address + * ({@link ForgotPasswordRequest#email()} with an url to reset the password of + * the user with that email address. + * + * @param email The email address of the user who wants to reset their + * password + * @return {@link HttpStatus#OK} on success, <br> + * {@link HttpStatus#NOT_FOUND} user not found + * @see AuthenticationService#forgotPassword(String) + */ + @PostMapping("/auth/{email}/forgot.json") + public ResponseEntity<String> forgotPassword( + @PathVariable final String email) { + return new ResponseEntity<>(authenticationService.forgotPassword(email)); + } + + /** + * The API-endpoint for resetting the password of a + * {@link org.psesquared.server.model.User}. This method is invoked via the + * link in the verification email that is sent in + * {@link #forgotPassword(String)}. + * + * @param username The username of the user who wants to reset their + * password + * @param token The JWT that indicates the authority of the request + * @param requestBody The request-wrapper containing the new password + * @return {@link HttpStatus#OK} on success, <br> + * {@link HttpStatus#BAD_REQUEST} password doesn't meet requirements, + * <br> + * {@link HttpStatus#UNAUTHORIZED} invalid token, <br> + * {@link HttpStatus#NOT_FOUND} user not found + * @see AuthenticationService#resetPassword(String, String, PasswordRequest) + */ + @PutMapping("/auth/{username}/resetpassword.json") + public ResponseEntity<String> resetPassword( + @PathVariable final String username, + @RequestParam("token") final String token, + @RequestBody final PasswordRequest requestBody) { + return new ResponseEntity<>( + authenticationService.resetPassword(username, token, requestBody)); + } + + /** + * The API-endpoint for changing the password of a + * {@link org.psesquared.server.model.User}, who is logged-in in the + * dashboard. + * (This is a secured endpoint requiring authorization via HTTP basic or JWT.) + * + * @param username The username of the user who wants to change their + * password + * @param requestBody The request-wrapper containing old and new password + * @return {@link HttpStatus#OK} on success, <br> + * {@link HttpStatus#BAD_REQUEST} old password is wrong, <br> + * {@link HttpStatus#NOT_FOUND} user not found + * @see AuthenticationService#changePassword(String, ChangePasswordRequest) + */ + @PutMapping("/auth/{username}/changepassword.json") + public ResponseEntity<String> changePassword( + @PathVariable final String username, + @RequestBody final ChangePasswordRequest requestBody) { + return new ResponseEntity<>( + authenticationService.changePassword(username, requestBody)); + } + + /** + * The API-endpoint for deleting a {@link org.psesquared.server.model.User}. + * This action is performed by a logged-in user from the dashboard. + * The user must enter their password ({@link PasswordRequest#password()}) + * and if correct, the user along with all associated data is deleted. + * (This is a secured endpoint requiring authorization via HTTP basic or JWT.) + * + * @param username The username of the user who wants to delete their + * account + * @param requestBody The request-wrapper containing the user's password + * @return {@link HttpStatus#OK} on success, <br> + * {@link HttpStatus#BAD_REQUEST} wrong password, <br> + * {@link HttpStatus#NOT_FOUND} user not found + * @see AuthenticationService#deleteUser(String, PasswordRequest) + */ + @DeleteMapping("/auth/{username}/delete.json") + public ResponseEntity<String> deleteUser( + @PathVariable final String username, + @RequestBody final PasswordRequest requestBody) { + return new ResponseEntity<>( + authenticationService.deleteUser(username, requestBody)); + } + + /** + * This API-endpoint exists for compatibility with podcatchers, especially + * AntennaPod and Kasts, which initially call this endpoint instead of + * {@link #login(String, HttpServletResponse)}. + * Accordingly, a call to this endpoint is internally treated as a login. + * In particular, devices remain unsupported. + * + * @param username The username of the user to be synchronized + * @param response The {@link HttpServletResponse} for setting the "sessionid" + * cookie + * @return A dummy response with a single dummy device for the given user + * @see AuthenticationService#login(String, HttpServletResponse) + */ + @GetMapping("/devices/{username}.json") + public ResponseEntity<List<DeviceWrapper>> getDeviceList( + @PathVariable final String username, + @NonNull final HttpServletResponse response) { + DeviceWrapper dummyDevice = new DeviceWrapper(); + return new ResponseEntity<>( + List.of(dummyDevice), + authenticationService.login(username, response)); + } + +} diff --git a/pse-server/src/main/java/org/psesquared/server/authentication/api/controller/ChangePasswordRequest.java b/pse-server/src/main/java/org/psesquared/server/authentication/api/controller/ChangePasswordRequest.java new file mode 100644 index 0000000..d8b2357 --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/authentication/api/controller/ChangePasswordRequest.java @@ -0,0 +1,15 @@ +package org.psesquared.server.authentication.api.controller; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * A request for changing the password containing the old, i.e. current, and new + * password. + * + * @param oldPassword The user's current password + * @param newPassword The new password + */ +public record ChangePasswordRequest( + @JsonProperty(value = "password", required = true) String oldPassword, + @JsonProperty(value = "new_password", required = true) String newPassword) { +} diff --git a/pse-server/src/main/java/org/psesquared/server/authentication/api/controller/DeviceWrapper.java b/pse-server/src/main/java/org/psesquared/server/authentication/api/controller/DeviceWrapper.java new file mode 100644 index 0000000..35dae3d --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/authentication/api/controller/DeviceWrapper.java @@ -0,0 +1,46 @@ +package org.psesquared.server.authentication.api.controller; + +/** + * This record wraps a dummy device that is required to be returned by <br> + * {@link AuthenticationController#getDeviceList(String, + * jakarta.servlet.http.HttpServletResponse)}. + * + * @param id The device id + * @param caption The caption, i.e. name, of the device + * @param type The device type + * @param subscriptions The number of subscriptions of the device + */ +public record DeviceWrapper( + String id, + String caption, + String type, + int subscriptions) { + + /** + * The id of the dummy device. + */ + private static final String DUMMY_ID = "dummy"; + + /** + * The name of the dummy device. + */ + private static final String DUMMY_DEVICE = "device"; + + /** + * The type of the dummy device. + */ + private static final String DUMMY_TYPE = "other"; + + /** + * The number of subscriptions of the dummy device. + */ + private static final int DUMMY_SUBSCRIPTIONS = 0; + + /** + * The no-args-constructor for a device-wrapper containing a dummy device. + */ + public DeviceWrapper() { + this(DUMMY_ID, DUMMY_DEVICE, DUMMY_TYPE, DUMMY_SUBSCRIPTIONS); + } + +} diff --git a/pse-server/src/main/java/org/psesquared/server/authentication/api/controller/ForgotPasswordRequest.java b/pse-server/src/main/java/org/psesquared/server/authentication/api/controller/ForgotPasswordRequest.java new file mode 100644 index 0000000..700fb08 --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/authentication/api/controller/ForgotPasswordRequest.java @@ -0,0 +1,13 @@ +package org.psesquared.server.authentication.api.controller; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * A request for sending an email with a link for resetting a user's password to + * the user's {@link #email} address. + * + * @param email The email address + */ +public record ForgotPasswordRequest( + @JsonProperty(required = true) String email) { +} diff --git a/pse-server/src/main/java/org/psesquared/server/authentication/api/controller/PasswordRequest.java b/pse-server/src/main/java/org/psesquared/server/authentication/api/controller/PasswordRequest.java new file mode 100644 index 0000000..f772c7b --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/authentication/api/controller/PasswordRequest.java @@ -0,0 +1,13 @@ +package org.psesquared.server.authentication.api.controller; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * A request that contains a {@link #password}, which is either set as a new + * password or used for confirming the deletion of an account. + * + * @param password The password + */ +public record PasswordRequest( + @JsonProperty(required = true) String password) { +} diff --git a/pse-server/src/main/java/org/psesquared/server/authentication/api/controller/UserInfoRequest.java b/pse-server/src/main/java/org/psesquared/server/authentication/api/controller/UserInfoRequest.java new file mode 100644 index 0000000..9c112b1 --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/authentication/api/controller/UserInfoRequest.java @@ -0,0 +1,16 @@ +package org.psesquared.server.authentication.api.controller; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * A request that contains the {@link #username}, {@link #email} address and + * {@link #password} for registering a new user. + * + * @param username The username + * @param email The email + * @param password The password + */ +public record UserInfoRequest(@JsonProperty(required = true) String username, + @JsonProperty(required = true) String email, + @JsonProperty(required = true) String password) { +} diff --git a/pse-server/src/main/java/org/psesquared/server/authentication/api/controller/package-info.java b/pse-server/src/main/java/org/psesquared/server/authentication/api/controller/package-info.java new file mode 100644 index 0000000..79ae33d --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/authentication/api/controller/package-info.java @@ -0,0 +1,13 @@ +/** + * This package represents the highest logical layer of the authentication API + * ({@link org.psesquared.server.authentication.api}) - the controller layer. + * <br> + * It contains the + * {@link + * org.psesquared.server.authentication.api.controller.AuthenticationController} + * along with a series of wrapper classes for JSON request and response bodies. + * + * @author PSE-Squared Team + * @version 1.0 + */ +package org.psesquared.server.authentication.api.controller; diff --git a/pse-server/src/main/java/org/psesquared/server/authentication/api/data/access/AuthenticationDao.java b/pse-server/src/main/java/org/psesquared/server/authentication/api/data/access/AuthenticationDao.java new file mode 100644 index 0000000..2073633 --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/authentication/api/data/access/AuthenticationDao.java @@ -0,0 +1,62 @@ +package org.psesquared.server.authentication.api.data.access; + +import java.util.Optional; +import org.psesquared.server.model.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +/** + * This JPA repository manages all database transactions by automatically + * implementing the logic behind custom queries via method naming convention. + */ +@Repository +public interface AuthenticationDao extends JpaRepository<User, Long> { + + /** + * Checks if a user exists via their username. + * + * @param username The username + * @return {@code true} if the user with the given username exists, <br> + * {@code false} otherwise + */ + boolean existsByUsername(String username); + + /** + * Finds the {@link User} with the given username if present. + * + * @param username The username of the user that is being searched for + * @return An {@link Optional} containing the user with the given + * username if present + */ + Optional<User> findByUsername(String username); + + /** + * Finds the {@link User} with the given email address if present. + * + * @param email The email address of the user that is being searched for + * @return An {@link Optional} containing the user with the given email + * address if present + */ + Optional<User> findByEmail(String email); + + /** + * Finds a {@link User} with the given username if present or with the + * given email address otherwise. + * + * @param username The username of the user that is being searched for + * @param email The email address of the user that is being searched for + * @return An {@link Optional} containing the user with the given username + * or email address if present + */ + Optional<User> findByUsernameOrEmail(String username, String email); + + /** + * Deletes all users that haven't been verified yet and have registered + * before the time specified by the given timestamp. + * + * @param timestamp The timestamp representing the number of seconds from + * the epoch of 1970-01-01T00:00:00Z. + */ + void deleteAllByEnabledFalseAndCreatedAtLessThan(long timestamp); + +} diff --git a/pse-server/src/main/java/org/psesquared/server/authentication/api/data/access/package-info.java b/pse-server/src/main/java/org/psesquared/server/authentication/api/data/access/package-info.java new file mode 100644 index 0000000..1b20cab --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/authentication/api/data/access/package-info.java @@ -0,0 +1,11 @@ +/** + * This package represents the lowest logical layer of the authentication API + * ({@link org.psesquared.server.authentication.api}) - the data-access layer. + * <br> + * It features the interface {@link + * org.psesquared.server.authentication.api.data.access.AuthenticationDao}. + * + * @author PSE-Squared Team + * @version 1.0 + */ +package org.psesquared.server.authentication.api.data.access; diff --git a/pse-server/src/main/java/org/psesquared/server/authentication/api/service/AuthenticationService.java b/pse-server/src/main/java/org/psesquared/server/authentication/api/service/AuthenticationService.java new file mode 100644 index 0000000..e21c3fc --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/authentication/api/service/AuthenticationService.java @@ -0,0 +1,389 @@ +package org.psesquared.server.authentication.api.service; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletResponse; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.NoSuchElementException; +import lombok.RequiredArgsConstructor; +import org.psesquared.server.authentication.api.controller.ChangePasswordRequest; +import org.psesquared.server.authentication.api.controller.PasswordRequest; +import org.psesquared.server.authentication.api.controller.UserInfoRequest; +import org.psesquared.server.authentication.api.data.access.AuthenticationDao; +import org.psesquared.server.config.JwtService; +import org.psesquared.server.model.Role; +import org.psesquared.server.model.User; +import org.springframework.http.HttpStatus; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * This service class manages all business logic associated with the + * authentication API. + * <br> + * It is called from the + * {@link + * org.psesquared.server.authentication.api.controller.AuthenticationController} + * and passes on requests concerning data access to the + * {@link AuthenticationDao}. + */ +@Service +@Transactional +@RequiredArgsConstructor +public class AuthenticationService { + + /** + * A {@link User} is not enabled until verification. + */ + private static final boolean ENABLED_DEFAULT = false; + + /** + * A {@link User} becomes enabled after verification. + */ + private static final boolean VERIFIED = true; + + /** + * The age of an expired cookie. + */ + private static final int EXPIRED_AGE = 0; + + /** + * The name of the cookie used by podcatchers for authentication. + * If a {@link User} has logged in, this cookie holds the JWT. + */ + private static final String COOKIE_NAME = "sessionid"; + + /** + * Specifies that the cookie should be sent to all URLs. + */ + private static final String COOKIE_PATH_GLOBAL = "/"; + + /** + * The default role of a {@link User} is {@link Role#USER}. + */ + private static final Role DEFAULT_USER = Role.USER; + + /** + * The JPA repository that handles all user related database requests. + */ + private final AuthenticationDao authenticationDao; + + /** + * The class used for the encryption of passwords. + */ + private final PasswordEncoder passwordEncoder; + + /** + * The service class used for managing JWTs. + */ + private final JwtService jwtService; + + /** + * The service class used for sending emails. + */ + private final EmailServiceImpl emailService; + + /** + * The service class used for encrypting email addresses. + */ + private final EncryptionService encryptionService; + + /** + * The service class used for checking if the given user information meets + * the specified requirements. + */ + private final InputCheckService inputCheckService; + + /** + * This method is invoked by the register method of the authentication + * controller. + * <br> + * 1. Checks if the given user information meets the requirements. + * <br> + * 2. Checks if no user with the given username already exists (if so, + * and email as well as password match, the verification email is sent again). + * <br> + * 3. Creates a user with the given information and sends verification email. + * + * @param userInfo The wrapper object containing username, email and password + * @return @return {@link HttpStatus#OK} on success, + * <br> + * {@link HttpStatus#BAD_REQUEST} for invalid user information + */ + public HttpStatus registerUser(final UserInfoRequest userInfo) { + if (inputCheckService.checkUsernameInvalid(userInfo.username()) + || inputCheckService.checkEmailInvalid(userInfo.email()) + || inputCheckService.checkPasswordInvalid(userInfo.password())) { + return HttpStatus.BAD_REQUEST; + } + + final String encryptedEmailFromRequest + = encryptionService.saltAndHashEmail(userInfo.email()); + User user; + + try { + user = authenticationDao + .findByUsernameOrEmail(userInfo.username(), encryptedEmailFromRequest) + .orElseThrow(); + + if (user.isEnabled() + || !user.getEmail().equals(encryptedEmailFromRequest) + || !user.getUsername().equals(userInfo.username()) + || !passwordEncoder + .matches(userInfo.password(), user.getPassword())) { + return HttpStatus.BAD_REQUEST; + } + + user.setCreatedAt(LocalDateTime.now().toEpochSecond(ZoneOffset.UTC)); + + } catch (NoSuchElementException e) { + user = User.builder() + .username(userInfo.username()) + .email(encryptionService.saltAndHashEmail(userInfo.email())) + .password(passwordEncoder.encode(userInfo.password())) + .enabled(ENABLED_DEFAULT) + .createdAt(LocalDateTime.now().toEpochSecond(ZoneOffset.UTC)) + .role(DEFAULT_USER) + .build(); + authenticationDao.save(user); + } + emailService.sendVerification(userInfo.email(), user); + return HttpStatus.OK; + } + + /** + * This method is invoked by the verifyRegistration method of the + * authentication controller. + * <br> + * If a not yet verified {@link User} with the given username exists, + * this user is verified via {@link User#setEnabled(boolean)}. + * + * @param username The username of the to be verified user + * @param token The JWT for authentication + * @return {@link HttpStatus#OK} on success, + * <br> + * {@link HttpStatus#BAD_REQUEST} user exists and is already verified, + * <br> + * {@link HttpStatus#UNAUTHORIZED} invalid token, + * <br> + * {@link HttpStatus#NOT_FOUND} user not found + */ + public HttpStatus verifyRegistration(final String username, + final String token) { + try { + var user = authenticationDao.findByUsername(username) + .orElseThrow(); + + if (user.isEnabled()) { + return HttpStatus.BAD_REQUEST; + } + + if (!jwtService.isUrlTokenValid(token, user)) { + return HttpStatus.UNAUTHORIZED; + } + + user.setEnabled(VERIFIED); + return HttpStatus.OK; + } catch (NoSuchElementException e) { + return HttpStatus.NOT_FOUND; + } + } + + /** + * This method is invoked by the login method of the authentication + * controller. + * <br> + * Sets the "sessionid" cookie with a valid JWT for further authentication. + * + * @param username The username of the user who wants to log in + * @param response The {@link HttpServletResponse} for setting the "sessionid" + * cookie + * @return {@link HttpStatus#OK} on success, + * <br> + * {@link HttpStatus#NOT_FOUND} user not found + */ + public HttpStatus login(final String username, + final HttpServletResponse response) { + try { + var user = authenticationDao.findByUsername(username) + .orElseThrow(); + var token = jwtService.generateAccessTokenString(user); + Cookie cookie = new Cookie(COOKIE_NAME, token); + cookie.setPath(COOKIE_PATH_GLOBAL); + response.addCookie(cookie); + return HttpStatus.OK; + } catch (NoSuchElementException e) { + return HttpStatus.NOT_FOUND; + } + } + + /** + * This method is invoked by the logout method of the authentication + * controller. + * <br> + * Invalidates the "sessionid" cookie. + * Thus, for further authentication until the next login only HTTP basic + * authentication (and no JWT authentication) is possible. + * + * @param username The username of the user who wants to log out + * @param response The {@link HttpServletResponse} for invalidating the + * "sessionid" cookie + * @return {@link HttpStatus#OK} on success, + * <br> + * {@link HttpStatus#NOT_FOUND} user not found + */ + public HttpStatus logout(final String username, + final HttpServletResponse response) { + if (authenticationDao.existsByUsername(username)) { + Cookie cookie = new Cookie(COOKIE_NAME, null); + cookie.setMaxAge(EXPIRED_AGE); + cookie.setPath(COOKIE_PATH_GLOBAL); + response.addCookie(cookie); + return HttpStatus.OK; + } + return HttpStatus.NOT_FOUND; + } + + /** + * This method is invoked by the forgotPassword method of the authentication + * controller. + * <br> + * Sends an email with a link to reset the password to the given email + * address, if a user with this email address exists. + * + * @param email The email address of the user who wants to reset their + * password + * @return {@link HttpStatus#OK} on success, + * <br> + * {@link HttpStatus#NOT_FOUND} user not found + */ + public HttpStatus forgotPassword(final String email) { + try { + var user = authenticationDao + .findByEmail(encryptionService.saltAndHashEmail(email)) + .orElseThrow(); + emailService.sendPasswordReset(email, user); + return HttpStatus.OK; + } catch (NoSuchElementException e) { + return HttpStatus.NOT_FOUND; + } + } + + /** + * This method is invoked by the resetPassword method of the authentication + * controller. + * <br> + * Sets a new password for the given user who has forgotten their + * password if the JWT is valid. + * + * @param username The username of the user who wants to reset their + * password + * @param token The JWT for authentication + * @param requestBody The request-wrapper containing the new password + * @return {@link HttpStatus#OK} on success, + * <br> + * {@link HttpStatus#BAD_REQUEST} password doesn't meet requirements, + * <br> + * {@link HttpStatus#UNAUTHORIZED} invalid token, + * <br> + * {@link HttpStatus#NOT_FOUND} user not found + */ + public HttpStatus resetPassword(final String username, + final String token, + final PasswordRequest requestBody) { + if (inputCheckService.checkPasswordInvalid(requestBody.password())) { + return HttpStatus.BAD_REQUEST; + } + try { + var user = authenticationDao.findByUsername(username) + .orElseThrow(); + if (jwtService.isUrlTokenValid(token, user)) { + user.setPassword(passwordEncoder.encode(requestBody.password())); + return HttpStatus.OK; + } + return HttpStatus.UNAUTHORIZED; + } catch (NoSuchElementException e) { + return HttpStatus.NOT_FOUND; + } + } + + /** + * This method is invoked by the changePassword method of the authentication + * controller. + * <br> + * Changes the password of a logged-in user. + * + * @param username The username of the user who wants to change their + * password + * @param requestBody The request-wrapper containing old and new password + * @return {@link HttpStatus#OK} on success, + * <br> + * {@link HttpStatus#BAD_REQUEST} old password is wrong, or new + * password doesn't meet requirements, + * <br> + * {@link HttpStatus#NOT_FOUND} user not found + */ + public HttpStatus changePassword(final String username, + final ChangePasswordRequest requestBody) { + if (inputCheckService.checkPasswordInvalid(requestBody.newPassword())) { + return HttpStatus.BAD_REQUEST; + } + try { + var user = authenticationDao.findByUsername(username) + .orElseThrow(); + if (passwordEncoder + .matches(requestBody.oldPassword(), user.getPassword())) { + user.setPassword(passwordEncoder.encode(requestBody.newPassword())); + return HttpStatus.OK; + } + return HttpStatus.BAD_REQUEST; + } catch (NoSuchElementException e) { + return HttpStatus.NOT_FOUND; + } + } + + /** + * This method is invoked by the deleteUser method of the authentication + * controller. + * <br> + * Deletes the user with the given username if existent and if the given + * password for confirmation is correct. + * + * @param username The username of the user who wants to delete their account + * @param requestBody The request-wrapper containing the password for + * confirmation + * @return {@link HttpStatus#OK} on success, + * <br> + * {@link HttpStatus#BAD_REQUEST} wrong password, + * <br> + * {@link HttpStatus#NOT_FOUND} user not found + */ + public HttpStatus deleteUser(final String username, + final PasswordRequest requestBody) { + try { + var user = authenticationDao.findByUsername(username) + .orElseThrow(); + if (passwordEncoder.matches(requestBody.password(), user.getPassword())) { + authenticationDao.delete(user); + return HttpStatus.OK; + } + return HttpStatus.BAD_REQUEST; + } catch (NoSuchElementException e) { + return HttpStatus.NOT_FOUND; + } + } + + /** + * This method is invoked by {@link org.psesquared.server.util.Scheduler} + * for cleaning the database from expired {@link User}s. + * + * @param timestamp The timestamp representing the number of seconds from + * the epoch of 1970-01-01T00:00:00Z. + * @see AuthenticationDao#deleteAllByEnabledFalseAndCreatedAtLessThan(long) + */ + public void deleteInvalidUsersOlderThan(final long timestamp) { + authenticationDao.deleteAllByEnabledFalseAndCreatedAtLessThan(timestamp); + } + +} diff --git a/pse-server/src/main/java/org/psesquared/server/authentication/api/service/EmailServiceImpl.java b/pse-server/src/main/java/org/psesquared/server/authentication/api/service/EmailServiceImpl.java new file mode 100644 index 0000000..c752286 --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/authentication/api/service/EmailServiceImpl.java @@ -0,0 +1,222 @@ +package org.psesquared.server.authentication.api.service; + +import lombok.RequiredArgsConstructor; +import org.psesquared.server.config.EmailConfigProperties; +import org.psesquared.server.config.JwtService; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Service; + +/** + * This service class is responsible for sending emails to + * {@link org.springframework.security.core.userdetails.User}s. + * + * @see JavaMailSender + */ +@Service +@RequiredArgsConstructor +public class EmailServiceImpl { + + /** + * The {@link JavaMailSender} used for the sending of emails. + */ + private final JavaMailSender emailSender; + + /** + * The properties class that is used to return some externally stored URLs. + */ + private final EmailConfigProperties emailConfigProperties; + + /** + * The service class for managing the JWTs that are sent via email. + */ + private final JwtService jwtService; + + /** + * The email address from which the emails are sent. + */ + @Value("${spring.mail.username}") + private String sender; + + /** + * The subject of the email that is sent for account verification. + */ + private static final String VERIFICATION_MAIL_SUBJECT + = "Bestätige deine E-Mail-Adresse für unseren" + + " Podcast-Synchronisations-Server | Validate your Mail"; + + /** + * The subject of the email that is sent for resetting the password of a user. + */ + private static final String PASSWORD_RESET_MAIL_SUBJECT + = "Setze dein Passwort für unseren Podcast-Synchronisation-Server" + + " zurück! | Reset Password"; + + /** + * The placeholder for the username. + */ + private static final String USERNAME_MAIL_PLACEHOLDER = "username"; + + /** + * The placeholder for the verification URL. + */ + private static final String VERIFICATION_MAIL_PLACEHOLDER + = "verificationURL"; + + /** + * The placeholder for the URL for resetting the password of a user. + */ + private static final String PASSWORD_RESET_MAIL_PLACEHOLDER + = "passwordResetURL"; + + /** + * The question mark symbol announcing a URL query parameter. + */ + private static final String URL_QUERY_PARAM = "?"; + + /** + * The format of the username URL query parameter. + */ + private static final String USERNAME_PARAM = "username="; + + /** + * The separator for URL query parameters. + */ + private static final String PARAM_SEPARATOR = "&"; + + /** + * The format of the token URL query parameter. + */ + private static final String TOKEN_PARAM = "token="; + + /** + * The contents of the verification URL with placeholders read from an + * external file. + */ + @Value("#{T(org.psesquared.server.authentication.api.service" + + ".ResourceReader).readFileToString('VerificationMail.txt')}") + private String verificationMailText; + + /** + * The contents of the URL for resetting the password of a user with + * placeholders read from an external file. + */ + @Value("#{T(org.psesquared.server.authentication.api.service" + + ".ResourceReader).readFileToString('PasswordResetMail.txt')}") + private String passwordResetMailText; + + /** + * Sends a generic email to a user enabling him/her to perform a certain + * action when clicking on the contained url. + * This method uses a template which lies at resources and contains a + * "verificationURL"-placeholder, which is replaced by the url. + * + * @param to Recipients email address + * @param mailSubject Subject of the email + * @param body Body of the email + */ + private void sendMail(final String to, + final String mailSubject, + final String body) { + // send simple mail message with credential from application.properties + SimpleMailMessage message = new SimpleMailMessage(); + message.setFrom(sender); + message.setTo(to); + message.setSubject(mailSubject); + message.setText(body); + emailSender.send(message); + } + + /** + * Substitutes username and URL placeholders in email template. + * + * @param template The email template with placeholders + * @param user The name of the user + * @param url The URL with the JWT for request authentication + * @return The email text with the actual username and URL + */ + private String substitutePlaceholders(final String template, + final UserDetails user, + final String url) { + return template + .replace(USERNAME_MAIL_PLACEHOLDER, user.getUsername()) + .replace(VERIFICATION_MAIL_PLACEHOLDER, url) + .replace(PASSWORD_RESET_MAIL_PLACEHOLDER, url); + } + + /** + * Generates the URL for verifying the account of a + * {@link org.springframework.security.core.userdetails.User} containing + * a JWT for authentication. + * + * @param userDetails The user details of the user who wants to verify their + * account + * @return The URL for verifying the user's account + */ + private String generateVerificationUrlString(final UserDetails userDetails) { + String token = jwtService.generateUrlTokenString(userDetails); + String verificationUrl + = String.format(emailConfigProperties.verificationUrl(), + userDetails.getUsername()); + + return verificationUrl + URL_QUERY_PARAM + TOKEN_PARAM + token; + } + + /** + * Generates the URL for resetting the password of a + * {@link org.springframework.security.core.userdetails.User} containing + * a JWT for authentication. + * + * @param userDetails The user details of the user who wants to reset their + * password + * @return The URL for resetting the user's password + */ + private String generatePasswordResetUrlString(final UserDetails userDetails) { + final String token = jwtService.generateUrlTokenString(userDetails); + return emailConfigProperties.dashboardBaseUrl() + + emailConfigProperties.resetUrlPath() + + URL_QUERY_PARAM + + USERNAME_PARAM + + userDetails.getUsername() + + PARAM_SEPARATOR + + TOKEN_PARAM + + token; + } + + /** + * Sends a validation E-Mail to validate a user account by clicking on the + * given URL. + * It uses a template which lies at resources/ValidationMail.txt and contains + * a "validationURL"-placeholder. + * + * @param to The email address of the user who wants to verify their account + * @param userDetails The user details of that user + */ + public void sendVerification(final String to, final UserDetails userDetails) { + final String url = generateVerificationUrlString(userDetails); + String mailText + = substitutePlaceholders(verificationMailText, userDetails, url); + + sendMail(to, VERIFICATION_MAIL_SUBJECT, mailText); + } + + /** + * Sends a password-reset E-Mail to a user with a URL which lets the user + * change his/her password. + * It uses a template which lies at resources/PasswordResetMail.txt and + * contains a "passwordResetURL"-placeholder. + * + * @param to The email address of the user who wants to reset their password + * @param userDetails The user details of that user + */ + public void sendPasswordReset(final String to, + final UserDetails userDetails) { + final String url = generatePasswordResetUrlString(userDetails); + String mailText + = substitutePlaceholders(passwordResetMailText, userDetails, url); + + sendMail(to, PASSWORD_RESET_MAIL_SUBJECT, mailText); + } +} diff --git a/pse-server/src/main/java/org/psesquared/server/authentication/api/service/EncryptionService.java b/pse-server/src/main/java/org/psesquared/server/authentication/api/service/EncryptionService.java new file mode 100644 index 0000000..f9cb68c --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/authentication/api/service/EncryptionService.java @@ -0,0 +1,85 @@ +package org.psesquared.server.authentication.api.service; + +import io.jsonwebtoken.io.Decoders; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import lombok.RequiredArgsConstructor; +import org.psesquared.server.config.SecurityConfigProperties; +import org.springframework.stereotype.Service; + +/** + * The service class responsible for encrypting the email addresses of + * {@link org.psesquared.server.model.User}s. + */ +@Service +@RequiredArgsConstructor +public class EncryptionService { + + /** + * The mask for a byte. + */ + private static final int BYTE_MASK = 0xff; + + /** + * The value added to the byte before conversion to String. + */ + private static final int ADDITION = 0x100; + + /** + * The hexadecimal radix. + */ + private static final int RADIX = 16; + + /** + * The index specifying the starting index for the substring method. + */ + private static final int BEGIN_INDEX = 1; + + /** + * The name of the hashing algorithm. + */ + private static final String SHA_512_ALGORITHM_NAME = "SHA-512"; + + /** + * The properties class that is used to return externally stored secret salt. + */ + private final SecurityConfigProperties securityConfigProperties; + + /** + * Encrypts a given email address by salting it with a fixed salt and hashing + * it afterwards. + * + * @param email The email address that needs to be salted and hashed + * @return The salted and hashed email address + */ + public String saltAndHashEmail(final String email) { + String generatedEmail = null; + try { + MessageDigest md = MessageDigest.getInstance(SHA_512_ALGORITHM_NAME); + md.update(getSalt()); + byte[] bytes = md.digest(email.getBytes(StandardCharsets.UTF_8)); + StringBuilder sb = new StringBuilder(); + for (byte b : bytes) { + sb.append(Integer + .toString((b & BYTE_MASK) + ADDITION, RADIX) + .substring(BEGIN_INDEX)); + } + generatedEmail = sb.toString(); + } catch (NoSuchAlgorithmException e) { + e.printStackTrace(); + } + return generatedEmail; + } + + /** + * Returns the salt for encrypting email addresses in the form of + * base64-decoded bytes of a locally stored secret signing key. + * + * @return {@code byte[]} containing the salt + */ + private byte[] getSalt() { + return Decoders.BASE64.decode(securityConfigProperties.emailSigningKey()); + } + +} diff --git a/pse-server/src/main/java/org/psesquared/server/authentication/api/service/InputCheckService.java b/pse-server/src/main/java/org/psesquared/server/authentication/api/service/InputCheckService.java new file mode 100644 index 0000000..be74a88 --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/authentication/api/service/InputCheckService.java @@ -0,0 +1,170 @@ +package org.psesquared.server.authentication.api.service; + +import jakarta.mail.internet.AddressException; +import jakarta.mail.internet.InternetAddress; +import java.util.regex.Pattern; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +/** + * The service class responsible for checking if user information (i.e. + * username, email address and password) meets the specified requirements. + */ +@Service +@RequiredArgsConstructor +public class InputCheckService { + + /** + * The strict boolean for + * {@link InternetAddress#InternetAddress(String, boolean)}. + */ + private static final boolean STRICT = true; + + /** + * The return value for valid user information. + */ + private static final boolean VALID = false; + + /** + * The return value for invalid user information. + */ + private static final boolean INVALID = true; + + /** + * Asserts position at start of a line. + */ + private static final String REGEX_START = "^"; + + /** + * Matches any word character (equivalent to [a-zA-Z0-9_]) character '-' + * between 1 and 255 times. + */ + private static final String USERNAME_REGEX_GROUP = "[\\w\\u002d]{1,255}"; + + /** + * Asserts that the password contains at least one digit. + */ + private static final String PW_REGEX_GROUP1 = "(?=.*\\d)"; + + /** + * Asserts that the password contains at least one lower case character. + */ + private static final String PW_REGEX_GROUP2 = "(?=.*[a-z])"; + + /** + * Asserts that the password contains at least one upper case character. + */ + private static final String PW_REGEX_GROUP3 = "(?=.*[A-Z])"; + + /** + * Asserts that the password contains at least one special character from + * the list [€°§´] or the Punct script extension. + */ + private static final String PW_REGEX_GROUP4 = "(?=.*[\\p{Punct}€°§´])"; + + /** + * Asserts that the password contains only word characters + * (equivalent to [a-zA-Z0-9_]) and the special characters specified in + * {@link InputCheckService#PW_REGEX_GROUP4}. + */ + private static final String PW_REGEX_GROUP5 = "[\\w\\p{Punct}€°§´]{8,255}"; + + /** + * Asserts position at the end of a line. + */ + private static final String REGEX_END = "$"; + + + /** + * The complete regex for a valid username consisting of the following regex + * groups: + * <br> + * {@link #REGEX_START}, {@link #USERNAME_REGEX_GROUP}, {@link #REGEX_END}. + */ + private static final String USERNAME_REGEX = REGEX_START + + USERNAME_REGEX_GROUP + + REGEX_END; + + /** + * The complete regex for a valid password consisting of the following regex + * groups: + * <br> + * {@link #REGEX_START}, {@link #PW_REGEX_GROUP1}, {@link #PW_REGEX_GROUP2}, + * {@link #PW_REGEX_GROUP3}, {@link #PW_REGEX_GROUP4}, + * {@link #PW_REGEX_GROUP5}, {@link #REGEX_END}. + */ + private static final String PW_REGEX = REGEX_START + + PW_REGEX_GROUP1 + + PW_REGEX_GROUP2 + + PW_REGEX_GROUP3 + + PW_REGEX_GROUP4 + + PW_REGEX_GROUP5 + + REGEX_END; + + /** + * Checks if the given {@code username} meets the following requirements: + * <br> + * - contains only word characters (equivalent to [a-zA-Z0-9_]) + * and the character '-'. + * <br> + * - is between 1 and 255 characters long. + * + * @param username The username that needs to be validated + * @return {@code false} if the username meets the requirements, + * <br> + * {@code true} otherwise + */ + public boolean checkUsernameInvalid(final String username) { + return !Pattern + .compile(USERNAME_REGEX) + .matcher(username) + .matches(); + } + + /** + * Checks if the given email address conforms to the RFC822 standard using + * {@link InternetAddress#validate()}. + * + * @param email The email address that needs to be validated + * @return {@code false} if the username meets the requirements, + * <br> + * {@code true} otherwise + */ + public boolean checkEmailInvalid(final String email) { + try { + InternetAddress internetAddress = new InternetAddress(email, STRICT); + internetAddress.validate(); + return VALID; + } catch (AddressException e) { + return INVALID; + } + } + + /** + * Checks if the given {@code password} meets the following requirements: + * <br> + * - contains at least one digit. + * <br> + * - contains at least one lower case character. + * <br> + * - contains at least one upper case character. + * <br> + * - contains at least one special character from the list [€°§´] or the + * Punct script extension. + * <br> + * - contains only word characters (equivalent to [a-zA-Z0-9_]) and special + * characters specified above. + * + * @param password The username that needs to be validated + * @return {@code false} if the username meets the requirements, + * <br> + * {@code true} otherwise + */ + public boolean checkPasswordInvalid(final String password) { + return !Pattern + .compile(PW_REGEX) + .matcher(password) + .matches(); + } + +} diff --git a/pse-server/src/main/java/org/psesquared/server/authentication/api/service/ResourceReader.java b/pse-server/src/main/java/org/psesquared/server/authentication/api/service/ResourceReader.java new file mode 100644 index 0000000..3501f9e --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/authentication/api/service/ResourceReader.java @@ -0,0 +1,33 @@ +package org.psesquared.server.authentication.api.service; + +import java.nio.charset.StandardCharsets; +import java.util.Objects; + +import org.apache.commons.io.IOUtils; + +/** + * This class allows reading text from files. + */ +public final class ResourceReader { + + /** + * Private constructor - cannot be called. + */ + private ResourceReader() { } + + /** + * This method reads text from a file specified by the given path. + * + * @param path The path to the file + * @return The contents of the file + * @throws java.io.IOException If an I/O error occurs + */ + public static String readFileToString(final String path) + throws java.io.IOException { + return IOUtils.toString(Objects.requireNonNull( + ResourceReader.class.getClassLoader().getResourceAsStream(path)), + StandardCharsets.UTF_8); + + } + +} diff --git a/pse-server/src/main/java/org/psesquared/server/authentication/api/service/package-info.java b/pse-server/src/main/java/org/psesquared/server/authentication/api/service/package-info.java new file mode 100644 index 0000000..9f16a0d --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/authentication/api/service/package-info.java @@ -0,0 +1,13 @@ +/** + * This package represents the logical middle layer of the authentication API + * ({@link org.psesquared.server.authentication.api}) - the service layer. + * <br> + * All business logic is handled here with the + * {@link + * org.psesquared.server.authentication.api.service.AuthenticationService} + * class, which in turn relies on some other service classes. + * + * @author PSE-Squared Team + * @version 1.0 + */ +package org.psesquared.server.authentication.api.service; |