From c7516cf57f0632e0f91b802c26864e4817d5d16c Mon Sep 17 00:00:00 2001 From: Auwate Date: Mon, 7 Apr 2025 17:22:10 -0400 Subject: [PATCH 01/17] Commit: # Description: - Change necessary files to add MFA/2FA/TOTP to login/register flow --- .github/workflows/maven-publish.yml | 2 + pom.xml | 16 +- .../config/JwtRequestFilter.java | 18 +- .../config/SecurityConfig.java | 4 + .../controller/MfaController.java | 67 ++++++ .../controller/UserController.java | 52 ++++- .../taskmanagerauth/dto/LoginRequest.java | 43 ++++ .../taskmanagerauth/dto/RegisterRequest.java | 29 +++ .../example/taskmanagerauth/entity/Mfa.java | 75 +++++++ .../example/taskmanagerauth/entity/User.java | 19 ++ .../handler/GlobalExceptionManager.java | 60 ++++++ .../server/MfaNotEnabledException.java | 7 + .../server/TotpInvalidException.java | 7 + .../server/TotpNotProvidedException.java | 7 + .../repository/MfaRepository.java | 8 + .../taskmanagerauth/service/JwtService.java | 48 ++++- .../taskmanagerauth/service/MfaService.java | 200 ++++++++++++++++++ .../taskmanagerauth/service/UserService.java | 79 +++++-- src/main/resources/application-test.yml | 4 + src/main/resources/application.yml | 4 + .../controller/UserControllerIT.java | 133 ++++++++++-- .../unit/config/JwtRequestFilterTests.java | 2 +- .../taskmanagerauth/unit/entity/MfaTests.java | 37 ++++ .../unit/entity/UserTests.java | 5 + .../unit/service/JwtServiceTests.java | 2 +- 25 files changed, 884 insertions(+), 44 deletions(-) create mode 100644 src/main/java/com/example/taskmanagerauth/controller/MfaController.java create mode 100644 src/main/java/com/example/taskmanagerauth/dto/LoginRequest.java create mode 100644 src/main/java/com/example/taskmanagerauth/dto/RegisterRequest.java create mode 100644 src/main/java/com/example/taskmanagerauth/entity/Mfa.java create mode 100644 src/main/java/com/example/taskmanagerauth/exception/server/MfaNotEnabledException.java create mode 100644 src/main/java/com/example/taskmanagerauth/exception/server/TotpInvalidException.java create mode 100644 src/main/java/com/example/taskmanagerauth/exception/server/TotpNotProvidedException.java create mode 100644 src/main/java/com/example/taskmanagerauth/repository/MfaRepository.java create mode 100644 src/main/java/com/example/taskmanagerauth/service/MfaService.java create mode 100644 src/test/java/com/example/taskmanagerauth/unit/entity/MfaTests.java diff --git a/.github/workflows/maven-publish.yml b/.github/workflows/maven-publish.yml index 0ab0e95..40fbb79 100644 --- a/.github/workflows/maven-publish.yml +++ b/.github/workflows/maven-publish.yml @@ -67,6 +67,8 @@ jobs: -e ORACLE_DB_USER="${{ secrets.ORACLE_DB_USER }}" \ -e ORACLE_DB_PASSWORD="${{ secrets.ORACLE_DB_PASSWORD }}" \ -e JWT_SECRET="${{ secrets.JWT_SECRET }}" \ + -e JWT_MFA="${{ secrets.JWT_MFA }}" \ + -e MFA_SECRET="${{ secrets.MFA_SECRET }}" \ -e DOMAIN_NAME="${{ secrets.DOMAIN_NAME }}" \ "${{ secrets.DOCKER_HUB_USER }}"/taskmanagerauth:latest sudo docker image prune -f \ No newline at end of file diff --git a/pom.xml b/pom.xml index 7bfc861..92d359d 100644 --- a/pom.xml +++ b/pom.xml @@ -58,12 +58,12 @@ spring-security-test test - com.h2database h2 2.3.232 + test @@ -79,6 +79,20 @@ 4.5.0 + + + com.warrenstrange + googleauth + 1.5.0 + + + + + com.google.crypto.tink + tink + 1.17.0 + + diff --git a/src/main/java/com/example/taskmanagerauth/config/JwtRequestFilter.java b/src/main/java/com/example/taskmanagerauth/config/JwtRequestFilter.java index 0d2ccb9..5c08595 100644 --- a/src/main/java/com/example/taskmanagerauth/config/JwtRequestFilter.java +++ b/src/main/java/com/example/taskmanagerauth/config/JwtRequestFilter.java @@ -66,6 +66,14 @@ protected void doFilterInternal( return; } + String cookie_name; + + if (isMfaPath(request.getServletPath())) { + cookie_name = "mfa_access_token"; + } else { + cookie_name = "taskmanager_access_token"; + } + if (request.getCookies() == null || request.getCookies().length == 0) { exceptionManager.handleJwtNotProvidedException( new JwtNotProvidedException("No tokens were provided."), @@ -74,11 +82,11 @@ protected void doFilterInternal( return; } - String access_token = null; + String access_token; try { access_token = Arrays.stream(request.getCookies()) - .filter(cookie -> cookie.getName().equals("taskmanager_access_token")) + .filter(cookie -> cookie.getName().equals(cookie_name)) .toList().getFirst().getValue(); } catch (Exception exception) { exceptionManager.handleJwtNotProvidedException( @@ -97,7 +105,7 @@ protected void doFilterInternal( try { - if (jwtService.validateToken(access_token)) { + if (jwtService.validateToken(access_token) || jwtService.validate2faToken(access_token)) { authorities = jwtService.extractAuthorities(access_token); username = jwtService.extractUser(access_token); @@ -165,4 +173,8 @@ private boolean isPermitAllPath(String servletPath) { return SecurityConfig.permitAllPaths.contains(servletPath); } + private boolean isMfaPath(String servletPath) { + return SecurityConfig.mfaPath.contains(servletPath); + } + } diff --git a/src/main/java/com/example/taskmanagerauth/config/SecurityConfig.java b/src/main/java/com/example/taskmanagerauth/config/SecurityConfig.java index ae2293b..01600a9 100644 --- a/src/main/java/com/example/taskmanagerauth/config/SecurityConfig.java +++ b/src/main/java/com/example/taskmanagerauth/config/SecurityConfig.java @@ -36,6 +36,10 @@ public class SecurityConfig { "/auth/login" ); + public static List mfaPath = List.of( + "/auth/2fa/setup" + ); + @Bean public SecurityFilterChain filterChain(HttpSecurity http, CorsConfigurationSource configurationSource) throws Exception { http diff --git a/src/main/java/com/example/taskmanagerauth/controller/MfaController.java b/src/main/java/com/example/taskmanagerauth/controller/MfaController.java new file mode 100644 index 0000000..e1116e4 --- /dev/null +++ b/src/main/java/com/example/taskmanagerauth/controller/MfaController.java @@ -0,0 +1,67 @@ +package com.example.taskmanagerauth.controller; + +import com.example.taskmanagerauth.dto.ApiResponse; +import com.example.taskmanagerauth.service.MfaService; +import com.example.taskmanagerauth.service.UserService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class MfaController { + + @Autowired + private MfaService mfaService; + + @Autowired + private UserService userService; + + private static final Logger logger = LoggerFactory.getLogger(MfaController.class); + + @PostMapping("/auth/2fa/setup") + public ResponseEntity> setup(@RequestBody String totp) { + + if (logger.isDebugEnabled()) { + logger.debug("2FA trying to be enabled."); + } + + logger.info("POST HTTP request received at /api/auth/2fa/setup"); + + mfaService.setupMfa(totp, userService.createUserDetails(userService.loadUserByContext())); + + ApiResponse response = ApiResponse.of( + HttpStatus.OK.value(), + "Success", + null + ); + + return ResponseEntity.status(HttpStatus.OK).body(response); + + } + + @GetMapping("/auth/2fa/generate") + public ResponseEntity> generateUrl() { + + if (logger.isDebugEnabled()) { + logger.debug("Trying to generate 2FA URL."); + } + + logger.info("GET HTTP request received at /api/auth/2fa/generate"); + + ApiResponse response = ApiResponse.of( + HttpStatus.OK.value(), + "Success", + mfaService.generateMfaCode() + ); + + return ResponseEntity.status(HttpStatus.OK).body(response); + + } + +} diff --git a/src/main/java/com/example/taskmanagerauth/controller/UserController.java b/src/main/java/com/example/taskmanagerauth/controller/UserController.java index dca98bd..51449d7 100644 --- a/src/main/java/com/example/taskmanagerauth/controller/UserController.java +++ b/src/main/java/com/example/taskmanagerauth/controller/UserController.java @@ -1,7 +1,10 @@ package com.example.taskmanagerauth.controller; import com.example.taskmanagerauth.dto.ApiResponse; +import com.example.taskmanagerauth.dto.LoginRequest; +import com.example.taskmanagerauth.dto.RegisterRequest; import com.example.taskmanagerauth.entity.User; +import com.example.taskmanagerauth.service.MfaService; import com.example.taskmanagerauth.service.UserService; import com.example.taskmanagerauth.service.JwtService; import jakarta.servlet.http.HttpServletResponse; @@ -10,6 +13,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.userdetails.UserDetails; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -26,6 +30,9 @@ public class UserController { @Autowired private JwtService jwtService; + @Autowired + private MfaService mfaService; + @GetMapping("/auth/validate") public ResponseEntity> validate() { @@ -46,7 +53,9 @@ public ResponseEntity> validate() { } @PostMapping("/auth/register") - public ResponseEntity> register(@RequestBody User user) { + public ResponseEntity> register( + @RequestBody RegisterRequest registerRequest + ) { if (logger.isDebugEnabled()) { logger.debug("Attempting to register..."); @@ -54,7 +63,11 @@ public ResponseEntity> register(@RequestBody User user) { logger.info("POST HTTP request received at /api/auth/register"); - userService.registerUser(user); + User user = User.of(registerRequest.getUsername(), registerRequest.getPassword()); + + userService.checkIfUserExists(user); + mfaService.instantiateMfaForUser(user); + userService.saveUser(user); ApiResponse response = ApiResponse.of( HttpStatus.OK.value(), @@ -67,8 +80,8 @@ public ResponseEntity> register(@RequestBody User user) { } @PostMapping("/auth/login") - public ResponseEntity> authenticate( - @RequestBody User user, + public ResponseEntity> login( + @RequestBody LoginRequest loginRequest, HttpServletResponse httpServletResponse ) { @@ -78,11 +91,36 @@ public ResponseEntity> authenticate( logger.info("POST HTTP request received at /api/auth/login"); + // Load user + User user = userService.getUserByUsernameAndPassword(loginRequest.getUsername(), loginRequest.getPassword()); + UserDetails userDetails = userService.createUserDetails(user); + + // Check if user has 2fa set up + if (!mfaService.hasMfaEnabled(user)) { + + httpServletResponse.addCookie( + jwtService.generate2faCookie( + userDetails + ) + ); + + ApiResponse response = ApiResponse.of( + 362, // Custom code for requiring TOTP, + "Success", + null + ); + + return ResponseEntity.status(HttpStatus.TEMPORARY_REDIRECT).body(response); + + } + + // Check TOTP + mfaService.validatePassword(loginRequest.getTotp(), user); + + // Create access token cookie httpServletResponse.addCookie( jwtService.generateJwtCookie( - userService.loadUserByUsernamePassword( - user.getUsername(), user.getPassword() - ) + userDetails ) ); diff --git a/src/main/java/com/example/taskmanagerauth/dto/LoginRequest.java b/src/main/java/com/example/taskmanagerauth/dto/LoginRequest.java new file mode 100644 index 0000000..e27a65f --- /dev/null +++ b/src/main/java/com/example/taskmanagerauth/dto/LoginRequest.java @@ -0,0 +1,43 @@ +package com.example.taskmanagerauth.dto; + +public class LoginRequest { + + private String username; + private String password; + private String totp; + + public LoginRequest() {} + + public LoginRequest(String username, String password, String totp) { + this.username = username; + this.password = password; + this.totp = totp; + } + + // Getters & Setters + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + + public String getTotp() { + return totp; + } + + public void setTotp(String totp) { + this.totp = totp; + } + +} diff --git a/src/main/java/com/example/taskmanagerauth/dto/RegisterRequest.java b/src/main/java/com/example/taskmanagerauth/dto/RegisterRequest.java new file mode 100644 index 0000000..f88e05f --- /dev/null +++ b/src/main/java/com/example/taskmanagerauth/dto/RegisterRequest.java @@ -0,0 +1,29 @@ +package com.example.taskmanagerauth.dto; + +public class RegisterRequest { + + private String username; + private String password; + + public RegisterRequest(String username, String password) { + this.username = username; + this.password = password; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + +} diff --git a/src/main/java/com/example/taskmanagerauth/entity/Mfa.java b/src/main/java/com/example/taskmanagerauth/entity/Mfa.java new file mode 100644 index 0000000..ce71762 --- /dev/null +++ b/src/main/java/com/example/taskmanagerauth/entity/Mfa.java @@ -0,0 +1,75 @@ +package com.example.taskmanagerauth.entity; + +import com.fasterxml.jackson.annotation.JsonBackReference; +import jakarta.persistence.*; + +@Entity +@Table(name = "mfa") +public class Mfa { + + @Id + @SequenceGenerator(name = "mfa_seq", sequenceName = "mfa_seq", allocationSize = 1) + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "mfa_seq") + private Long id; + + @OneToOne + @JoinColumn(name = "user_id", referencedColumnName = "id") + @JsonBackReference + private User user; + + @Column(name = "mfaEnabled", nullable = false) + private Boolean mfaEnabled; + + @Column(name = "mfaSecretKey", nullable = false) + private String mfaSecretKey; + + public Mfa() {} + + public Mfa(Long id, User user, Boolean mfaEnabled, String mfaSecretKey) { + this.id = id; + this.user = user; + this.mfaEnabled = mfaEnabled; + this.mfaSecretKey = mfaSecretKey; + } + + // Factory + + public static Mfa of(User user, Boolean mfaEnabled, String mfaSecretKey) { + return new Mfa(null, user, mfaEnabled, mfaSecretKey); + } + + // Getters & Setters + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public User getUser() { + return user; + } + + public void setUser(User user) { + this.user = user; + } + + public Boolean getMfaEnabled() { + return mfaEnabled; + } + + public void setMfaEnabled(Boolean mfa_enabled) { + this.mfaEnabled = mfa_enabled; + } + + public String getMfaSecretKey() { + return mfaSecretKey; + } + + public void setMfaSecretKey(String mfa_secret_key) { + this.mfaSecretKey = mfa_secret_key; + } + +} diff --git a/src/main/java/com/example/taskmanagerauth/entity/User.java b/src/main/java/com/example/taskmanagerauth/entity/User.java index e64b537..33ea32b 100644 --- a/src/main/java/com/example/taskmanagerauth/entity/User.java +++ b/src/main/java/com/example/taskmanagerauth/entity/User.java @@ -1,5 +1,6 @@ package com.example.taskmanagerauth.entity; +import com.fasterxml.jackson.annotation.JsonManagedReference; import jakarta.persistence.*; import java.time.LocalDateTime; @@ -32,6 +33,10 @@ public class User { ) private Set roles; + @OneToOne(fetch = FetchType.EAGER, mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) + @JsonManagedReference + private Mfa mfa; + public User() {} public User(Long id, String username, String password) { @@ -40,6 +45,16 @@ public User(Long id, String username, String password) { this.password = password; } + public User(Long id, String username, String password, Set roles) { + this.id = id; + this.username = username; + this.password = password; + } + + public static User of(String username, String password) { + return new User(null, username, password, Set.of(Role.of("USER"))); + } + public Long getId() { return id; } @@ -76,4 +91,8 @@ public void setPassword(String password) { public void setRoles(Set roles) { this.roles = roles; } + public Mfa getMfa() { return mfa; } + + public void setMfa(Mfa mfa) { this.mfa = mfa; } + } diff --git a/src/main/java/com/example/taskmanagerauth/exception/handler/GlobalExceptionManager.java b/src/main/java/com/example/taskmanagerauth/exception/handler/GlobalExceptionManager.java index 7a87cc0..0f8617a 100644 --- a/src/main/java/com/example/taskmanagerauth/exception/handler/GlobalExceptionManager.java +++ b/src/main/java/com/example/taskmanagerauth/exception/handler/GlobalExceptionManager.java @@ -103,4 +103,64 @@ public ResponseEntity> handleExpiredJwtException(ExpiredJwtE } + @ExceptionHandler(TotpNotProvidedException.class) + public ResponseEntity> handleTotpNotProvidedException(TotpNotProvidedException exception) { + + String message = "Bad Request: One time password not provided."; + + ApiResponse response = ApiResponse.of( + 461, // Custom code for requiring TOTP + message, + exception.getMessage() + ); + + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); + + } + + @ExceptionHandler(MfaNotEnabledException.class) + public ResponseEntity> handleMfaNotEnabledException(MfaNotEnabledException exception) { + + String message = "Bad Request: Please set up MFA for your account."; + + ApiResponse response = ApiResponse.of( + 462, // Custom code for requiring TOTP + message, + exception.getMessage() + ); + + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); + + } + + @ExceptionHandler(TotpInvalidException.class) + public ResponseEntity> handleTotpInvalidException(TotpInvalidException exception) { + + String message = "Bad Request: One time password was incorrect."; + + ApiResponse response = ApiResponse.of( + HttpStatus.FORBIDDEN.value(), + message, + exception.getMessage() + ); + + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(response); + + } + + @ExceptionHandler(RuntimeException.class) + public ResponseEntity> handleRuntimeException(RuntimeException exception) { + + String message = "Internal Server Error"; + + ApiResponse response = ApiResponse.of( + HttpStatus.FORBIDDEN.value(), + message, + exception.getMessage() + ); + + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response); + + } + } diff --git a/src/main/java/com/example/taskmanagerauth/exception/server/MfaNotEnabledException.java b/src/main/java/com/example/taskmanagerauth/exception/server/MfaNotEnabledException.java new file mode 100644 index 0000000..894156c --- /dev/null +++ b/src/main/java/com/example/taskmanagerauth/exception/server/MfaNotEnabledException.java @@ -0,0 +1,7 @@ +package com.example.taskmanagerauth.exception.server; + +public class MfaNotEnabledException extends RuntimeException { + public MfaNotEnabledException(String message) { + super(message); + } +} diff --git a/src/main/java/com/example/taskmanagerauth/exception/server/TotpInvalidException.java b/src/main/java/com/example/taskmanagerauth/exception/server/TotpInvalidException.java new file mode 100644 index 0000000..6a4d811 --- /dev/null +++ b/src/main/java/com/example/taskmanagerauth/exception/server/TotpInvalidException.java @@ -0,0 +1,7 @@ +package com.example.taskmanagerauth.exception.server; + +public class TotpInvalidException extends RuntimeException { + public TotpInvalidException(String message) { + super(message); + } +} diff --git a/src/main/java/com/example/taskmanagerauth/exception/server/TotpNotProvidedException.java b/src/main/java/com/example/taskmanagerauth/exception/server/TotpNotProvidedException.java new file mode 100644 index 0000000..b9fc167 --- /dev/null +++ b/src/main/java/com/example/taskmanagerauth/exception/server/TotpNotProvidedException.java @@ -0,0 +1,7 @@ +package com.example.taskmanagerauth.exception.server; + +public class TotpNotProvidedException extends RuntimeException{ + public TotpNotProvidedException(String message) { + super(message); + } +} diff --git a/src/main/java/com/example/taskmanagerauth/repository/MfaRepository.java b/src/main/java/com/example/taskmanagerauth/repository/MfaRepository.java new file mode 100644 index 0000000..fdf5fde --- /dev/null +++ b/src/main/java/com/example/taskmanagerauth/repository/MfaRepository.java @@ -0,0 +1,8 @@ +package com.example.taskmanagerauth.repository; + +import com.example.taskmanagerauth.entity.Mfa; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface MfaRepository extends JpaRepository {} diff --git a/src/main/java/com/example/taskmanagerauth/service/JwtService.java b/src/main/java/com/example/taskmanagerauth/service/JwtService.java index f25963f..7b9de31 100644 --- a/src/main/java/com/example/taskmanagerauth/service/JwtService.java +++ b/src/main/java/com/example/taskmanagerauth/service/JwtService.java @@ -21,11 +21,16 @@ @Component public class JwtService { - public JwtService(@Value("${jwt.secret}") String secret) { + public JwtService( + @Value("${jwt.secret}") String secret, + @Value("${jwt.mfa}") String secret2FA + ) { this.secret = secret; + this.secret2FA = secret2FA; } private final String secret; + private final String secret2FA; private final Duration EXPIRATION_TIMER = Duration.ofMinutes(10); // 10 minutes public long getExpirationTimerInMillis() { @@ -40,8 +45,11 @@ public String getSecret() { return secret; } - public String generateToken(UserDetails userDetails) { - Algorithm algorithm = Algorithm.HMAC512(getSecret()); + public String getSecret2FA() { + return secret2FA; + } + + private String createToken(UserDetails userDetails, Algorithm algorithm) { return JWT.create() .withSubject(userDetails.getUsername()) .withClaim("authorities", userDetails.getAuthorities().stream().map(GrantedAuthority::getAuthority).toList()) @@ -50,6 +58,16 @@ public String generateToken(UserDetails userDetails) { .sign(algorithm); } + public String generateToken(UserDetails userDetails) { + Algorithm algorithm = Algorithm.HMAC512(getSecret()); + return createToken(userDetails, algorithm); + } + + public String generate2FAToken(UserDetails userDetails) { + Algorithm algorithm = Algorithm.HMAC512(getSecret2FA()); + return createToken(userDetails, algorithm); + } + public String extractUser(String token) { try { DecodedJWT jwt = JWT.decode(token); @@ -68,9 +86,17 @@ public List extractAuthorities(String token) { } } + public boolean validate2faToken(String token) { + return validate(token, getSecret2FA()); + } + public boolean validateToken(String token) { + return validate(token, getSecret()); + } + + private boolean validate(String token, String secret) { try { - Algorithm algorithm = Algorithm.HMAC512(getSecret()); + Algorithm algorithm = Algorithm.HMAC512(secret); JWTVerifier verifier = JWT.require(algorithm) .build(); @@ -86,10 +112,20 @@ public boolean validateToken(String token) { } public Cookie generateJwtCookie(UserDetails userDetails) { + String jwt = generateToken(userDetails); + return createCookie("taskmanager_access_token", jwt); + } + + public Cookie generate2faCookie(UserDetails userDetails) { + String jwt = generate2FAToken(userDetails); + return createCookie("mfa_access_token", jwt); + } + + private Cookie createCookie(String cookie_name, String jwt) { Cookie cookie = new Cookie( - "taskmanager_access_token", - generateToken(userDetails) + cookie_name, + jwt ); cookie.setHttpOnly(true); diff --git a/src/main/java/com/example/taskmanagerauth/service/MfaService.java b/src/main/java/com/example/taskmanagerauth/service/MfaService.java new file mode 100644 index 0000000..f4dc877 --- /dev/null +++ b/src/main/java/com/example/taskmanagerauth/service/MfaService.java @@ -0,0 +1,200 @@ +package com.example.taskmanagerauth.service; + +import com.example.taskmanagerauth.entity.Mfa; +import com.example.taskmanagerauth.entity.User; +import com.example.taskmanagerauth.exception.server.MfaNotEnabledException; +import com.example.taskmanagerauth.exception.server.TotpInvalidException; +import com.example.taskmanagerauth.exception.server.TotpNotProvidedException; +import com.google.crypto.tink.*; +import com.google.crypto.tink.aead.AeadConfig; +import com.warrenstrange.googleauth.GoogleAuthenticator; +import com.warrenstrange.googleauth.GoogleAuthenticatorKey; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Service; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.util.Base64; + +@Service +public class MfaService { + + private final UserService userService; + + private final GoogleAuthenticator authenticator = new GoogleAuthenticator(); + private final KeysetHandle mfaKey; + + @Autowired + public MfaService( + @Value("${mfa.secret}") String mfaSecretKeySet, + UserService userService + ) { + this.userService = userService; + mfaKey = getMfaKey(mfaSecretKeySet); + } + + /** + * Get the Base64 encoded key from the environment to be used for decoding/encoding. + * @param mfaKeySet Base64 encoded keyset, loaded from GitHub secrets + * @return (KeysetHandle) The object representation of the Base64 imported string + */ + private KeysetHandle getMfaKey(String mfaKeySet) { + + try { + + AeadConfig.register(); + + byte[] keysetBytes = Base64.getDecoder().decode(mfaKeySet); + + try (ByteArrayInputStream input = new ByteArrayInputStream(keysetBytes)) { + + return CleartextKeysetHandle.read( + BinaryKeysetReader.withInputStream(input) + ); + + } + + } catch (GeneralSecurityException | IOException e) { + throw new RuntimeException(e); + } + + } + + /** + * Get the time-based one time password as a number + * @param totp String representation of the TOTP code + * @return (int) Integer representation of the TOTP code + */ + private int getTotp(String totp) { + + if (totp == null || totp.isEmpty()) { + throw new TotpNotProvidedException("One time password not provided."); + } + + int totp_num; + + try { + totp_num = Integer.parseInt(totp); + } catch (NumberFormatException exception) { + throw new TotpNotProvidedException("One time password not provided."); + } + + return totp_num; + + } + + public boolean hasMfaEnabled(User user) { + return user.getMfa().getMfaEnabled(); + } + + /** + * Encrypt using mfaKey + * @param key (String) A key to be encrypted + * @return The encrypted, base64 representation + */ + public String encrypt(String key) { + + try { + Aead aead = mfaKey.getPrimitive(RegistryConfiguration.get(), Aead.class); + + byte[] stringAsBytes = key.getBytes(StandardCharsets.UTF_8); + byte[] cipherText = aead.encrypt(stringAsBytes, "taskmanagerauth".getBytes()); + + return Base64.getEncoder().encodeToString(cipherText); + } catch (GeneralSecurityException e) { + throw new RuntimeException(e); + } + + } + + /** + * Decrypt using mfaKey + * @param encryptedKey (String) An encrypted key + * @return The decrypted, String representation + */ + public String decrypt(String encryptedKey) { + + try { + Aead aead = mfaKey.getPrimitive(RegistryConfiguration.get(), Aead.class); + + byte[] cipherText = Base64.getDecoder().decode(encryptedKey); + byte[] decrypted = aead.decrypt(cipherText, "taskmanagerauth".getBytes()); + + return new String(decrypted, StandardCharsets.UTF_8); + } catch (GeneralSecurityException e) { + throw new RuntimeException(e); + } + + } + + /** + * Generate the TOTP code for usage on frontend + * @return (String) The otpauth code + */ + public String generateMfaCode() { + + User user = userService.loadUserByContext(); + return "otpauth://totp/TaskManagerAuth:" + user.getId() + "?secret=" + decrypt(user.getMfa().getMfaSecretKey()) + "&issuer=TaskManagerAuth\n"; + + } + + /** + * Validate a time-based one time password + * @param totp One time password + * @param user User + */ + public void validatePassword(String totp, User user) { + + if (totp.isEmpty()) { + throw new TotpNotProvidedException("Please provide a TOTP code."); + } + + int totp_num = getTotp(totp); + + if (!hasMfaEnabled(user)) { + throw new MfaNotEnabledException("Mfa not enabled."); + } + + if (!authenticator.authorize(decrypt(user.getMfa().getMfaSecretKey()), totp_num)) { + throw new TotpInvalidException("Incorrect TOTP provided."); + } + + } + + /** + * Using a TOTP code the user provides, activate the user's MFA if correct + * @param totp Code provided + * @param userDetails The user + */ + public void setupMfa(String totp, UserDetails userDetails) { + + int totp_num = getTotp(totp); + User user = userService.getUserById(userDetails); + + if (!authenticator.authorize(decrypt(user.getMfa().getMfaSecretKey()), totp_num)) { + throw new TotpInvalidException("Incorrect TOTP provided."); + } + + user.getMfa().setMfaEnabled(true); + + userService.saveUser(user); + + } + + /** + * Create a row in the mfa table + * @param user The user + */ + public void instantiateMfaForUser(User user) { + + GoogleAuthenticatorKey key = authenticator.createCredentials(); + Mfa mfa = new Mfa(null, user, false, encrypt(key.getKey())); + user.setMfa(mfa); + + } + +} diff --git a/src/main/java/com/example/taskmanagerauth/service/UserService.java b/src/main/java/com/example/taskmanagerauth/service/UserService.java index b9be530..8244409 100644 --- a/src/main/java/com/example/taskmanagerauth/service/UserService.java +++ b/src/main/java/com/example/taskmanagerauth/service/UserService.java @@ -5,13 +5,14 @@ import com.example.taskmanagerauth.exception.server.InvalidCredentialsException; import com.example.taskmanagerauth.exception.server.UsernameTakenException; import com.example.taskmanagerauth.repository.UserRepository; -import com.example.taskmanagerauth.service.JwtService; import jakarta.transaction.Transactional; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; @@ -40,6 +41,16 @@ public UserService( private static final Logger logger = LoggerFactory.getLogger(UserService.class); + // UserDetail services + + public UserDetails createUserDetails(User user) { + return new org.springframework.security.core.userdetails.User( + String.valueOf(user.getId()), + user.getPassword(), + mapRolesToAuthorities(user.getRoles()) + ); + } + @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { @@ -51,11 +62,7 @@ public UserDetails loadUserByUsername(String username) throws UsernameNotFoundEx () -> new UsernameNotFoundException("Invalid credentials provided.") ); - return new org.springframework.security.core.userdetails.User( - String.valueOf(user.getId()), // Use the immutable ID instead of username - user.getPassword(), - mapRolesToAuthorities(user.getRoles()) - ); + return createUserDetails(user); } @@ -89,25 +96,71 @@ public UserDetails loadUserByUsernamePassword(String username, String password) } + public User loadUserByContext() { + + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + UserDetails userDetails = (UserDetails)authentication.getPrincipal(); + Long userId = Long.decode(userDetails.getUsername()); + + return userRepository.findById(userId).orElseThrow( + () -> new InvalidCredentialsException("User not found.") + ); + + } + private Collection mapRolesToAuthorities(Collection roles) { return roles.stream() .map(role -> new SimpleGrantedAuthority(role.getName())) .toList(); } + // Retrieve User objects + + public User getUserByUsernameAndPassword(String username, String password) { + + User user = userRepository.findByUsername(username).orElseThrow( + () -> new UsernameNotFoundException("Invalid credentials provided.") + ); + + if (!passwordEncoder.getEncoder().matches(password, user.getPassword())) { + throw new InvalidCredentialsException("Invalid credentials provided."); + } + + return user; + + } + + public User getUserById(UserDetails userDetails) { + + if (logger.isDebugEnabled()) { + logger.debug("Attempting to retrieve user with id {}", userDetails.getUsername()); + } + + return userRepository.findById(Long.parseLong(userDetails.getUsername())).orElseThrow( + () -> new InvalidCredentialsException("User not found.") + ); + + } + + public void checkIfUserExists(User user) { + userRepository.findByUsername(user.getUsername()).orElseThrow( + () -> new UsernameTakenException("A user with this name already exists.") + ); + } + + + + // Transactionals + @Transactional - public void registerUser(User user) { + public void saveUser(User user) { if (logger.isDebugEnabled()) { logger.debug("Attempting to save user {}", user.getUsername()); } - if (userRepository.findByUsername(user.getUsername()).isEmpty()) { - user.setPassword(passwordEncoder.encode(user.getPassword())); - userRepository.saveAndFlush(user); - } else { - throw new UsernameTakenException("Please provide a different username."); - } + user.setPassword(passwordEncoder.encode(user.getPassword())); + userRepository.saveAndFlush(user); } diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml index 64a9531..5253cdc 100644 --- a/src/main/resources/application-test.yml +++ b/src/main/resources/application-test.yml @@ -33,6 +33,10 @@ server: jwt: secret: "Test" + mfa: "Test2" + +mfa: + secret: "CMmRpMMOEmQKWAowdHlwZS5nb29nbGVhcGlzLmNvbS9nb29nbGUuY3J5cHRvLnRpbmsuQWVzR2NtS2V5EiIaILBjvpHue4z0MJuNMpRTZDsvpgvXT5jVNA/1Su8RwTB1GAEQARjJkaTDDiAB" domain: name: "http://localhost:9095" \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 41ddac9..b4102bd 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -27,6 +27,10 @@ server: jwt: secret: ${JWT_SECRET} + mfa: ${JWT_MFA} + +mfa: + secret: ${MFA_SECRET} domain: name: ${DOMAIN_NAME} \ No newline at end of file diff --git a/src/test/java/com/example/taskmanagerauth/integration/controller/UserControllerIT.java b/src/test/java/com/example/taskmanagerauth/integration/controller/UserControllerIT.java index c4b06ab..f95de41 100644 --- a/src/test/java/com/example/taskmanagerauth/integration/controller/UserControllerIT.java +++ b/src/test/java/com/example/taskmanagerauth/integration/controller/UserControllerIT.java @@ -3,11 +3,18 @@ import com.auth0.jwt.JWT; import com.auth0.jwt.algorithms.Algorithm; import com.example.taskmanagerauth.dto.ApiResponse; +import com.example.taskmanagerauth.dto.LoginRequest; +import com.example.taskmanagerauth.dto.RegisterRequest; +import com.example.taskmanagerauth.entity.Mfa; import com.example.taskmanagerauth.entity.Role; import com.example.taskmanagerauth.entity.User; +import com.example.taskmanagerauth.repository.MfaRepository; import com.example.taskmanagerauth.repository.UserRepository; +import com.example.taskmanagerauth.service.MfaService; import com.example.taskmanagerauth.service.PasswordEncodingService; import com.example.taskmanagerauth.service.JwtService; +import com.warrenstrange.googleauth.GoogleAuthenticator; +import jakarta.servlet.http.Cookie; import org.junit.jupiter.api.*; import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.beans.factory.annotation.Autowired; @@ -23,7 +30,6 @@ import java.util.Date; import java.util.List; -import java.util.Set; @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT) @ExtendWith(SpringExtension.class) @@ -35,23 +41,34 @@ public class UserControllerIT { public UserControllerIT( RestTemplate testRestTemplate, UserRepository userRepository, + MfaRepository mfaRepository, PasswordEncodingService passwordEncodingService, - JwtService jwtService + JwtService jwtService, + MfaService mfaService ) { this.testRestTemplate = testRestTemplate; this.userRepository = userRepository; + this.mfaRepository = mfaRepository; this.passwordEncodingService = passwordEncodingService; this.jwtService = jwtService; + this.mfaService = mfaService; } private final RestTemplate testRestTemplate; private final UserRepository userRepository; + private final MfaRepository mfaRepository; private final PasswordEncodingService passwordEncodingService; private final JwtService jwtService; + private final MfaService mfaService; + + private String secretKey; + private String cookie; private static final String LOGIN_QUERY_URL = "https://localhost:9095/api/auth/login"; private static final String REGISTER_QUERY_URL = "https://localhost:9095/api/auth/register"; private static final String VALIDATE_QUERY_URL = "https://localhost:9095/api/auth/validate"; + private static final String GENERATE_QUERY_URL = "https://localhost:9095/api/auth/2fa/generate"; + private static final String SETUP_QUERY_URL = "https://localhost:9095/api/auth/2fa/setup"; HttpEntity HttpEntityFactory(T data) { return new HttpEntity<>(data); @@ -87,10 +104,7 @@ HttpHeaders invalidHeaderFactory() { @Order(1) void testRegisterSuccess() { - User payload = new User(); - payload.setUsername("test_user"); - payload.setPassword("test_pass"); - payload.setRoles(Set.of(Role.of("USER"))); + RegisterRequest payload = new RegisterRequest("test_user", "test_pass"); ResponseEntity> response = testRestTemplate.exchange( REGISTER_QUERY_URL, @@ -106,22 +120,117 @@ void testRegisterSuccess() { assertNull(response.getBody().getData()); assertEquals(HttpStatus.OK.value(), response.getBody().getStatus()); - // Database + // User database assertions User databaseUser = userRepository.findAll().getFirst(); assertEquals(payload.getUsername(), databaseUser.getUsername()); assertTrue(passwordEncodingService.getEncoder().matches(payload.getPassword(), databaseUser.getPassword())); - assertEquals(1, payload.getRoles().size()); - assertEquals("USER", payload.getRoles().stream().findFirst().orElseThrow().getName()); + + // Mfa database assertions + Mfa databaseMfa = mfaRepository.findAll().getFirst(); + assertEquals(payload.getUsername(), databaseMfa.getUser().getUsername()); + assertEquals(false, databaseMfa.getMfaEnabled()); } @Test @Order(2) + void testLoginWithoutTotp() { + + LoginRequest payload = new LoginRequest("test_user", "test_pass", ""); + + ResponseEntity> response = testRestTemplate.exchange( + LOGIN_QUERY_URL, + HttpMethod.POST, + HttpEntityFactory(payload), + new ParameterizedTypeReference<>() {} + ); + + // Assertions + assertEquals(HttpStatus.TEMPORARY_REDIRECT, response.getStatusCode()); + assertNotNull(response.getBody()); + assertEquals("Success", response.getBody().getMessage()); + assertEquals(362, response.getBody().getStatus()); + + // Get cookie + String testJWT = response.getHeaders().getFirst("Set-Cookie"); + assertNotNull(testJWT); + + testJWT = testJWT.substring(testJWT.indexOf("=") + 1, testJWT.indexOf(";")); + + jwtService.validate2faToken(testJWT); + + assertEquals("1", jwtService.extractUser(testJWT)); + assertEquals("USER", jwtService.extractAuthorities(testJWT).getFirst()); + + // For use in next test + this.cookie = testJWT; + + } + + @Test + @Order(3) + void generateTotp() { + + ResponseEntity> response = testRestTemplate.exchange( + GENERATE_QUERY_URL, + HttpMethod.GET, + HttpEntityFactory(null), + new ParameterizedTypeReference<>() {} + ); + + // Assertions + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertNotNull(response.getBody()); + assertEquals("Success", response.getBody().getMessage()); + assertEquals(HttpStatus.OK.value(), response.getBody().getStatus()); + + this.secretKey = mfaService.decrypt(mfaRepository.findAll().getFirst().getMfaSecretKey()); + + assertEquals( + "otpauth://totp/TaskManagerAuth:1?secret=" + secretKey + "&issuer=TaskManagerAuth\n", + response.getBody().getData() + ); + + } + + @Test + @Order(4) + void setupTotp() { + + GoogleAuthenticator authenticator = new GoogleAuthenticator(); + String payload = String.valueOf(authenticator.getTotpPassword(secretKey)); + + HttpHeaders headers = new HttpHeaders(); + headers.set(HttpHeaders.COOKIE, "mfa_access_token=" + this.cookie); + + + ResponseEntity> response = testRestTemplate.exchange( + SETUP_QUERY_URL, + HttpMethod.POST, + HttpEntityFactory(payload, headers), + new ParameterizedTypeReference<>() {} + ); + + // Assertions + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertNotNull(response.getBody()); + assertEquals("Success", response.getBody().getMessage()); + assertEquals(HttpStatus.OK.value(), response.getBody().getStatus()); + + // Database + User userDB = userRepository.findAll().getFirst(); + assertTrue(userDB.getMfa().getMfaEnabled()); + + } + + @Test + @Order(5) void testLoginSuccess() { - User payload = new User(1L, "test_user", "test_pass"); - payload.setRoles(Set.of(Role.of("USER"))); + GoogleAuthenticator authenticator = new GoogleAuthenticator(); + String totp = String.valueOf(authenticator.getTotpPassword(secretKey)); + LoginRequest payload = new LoginRequest("test_user", "test_pass", totp); ResponseEntity> response = testRestTemplate.exchange( LOGIN_QUERY_URL, @@ -149,7 +258,7 @@ void testLoginSuccess() { } @Test - @Order(3) + @Order(6) void testValidateSuccess() { ResponseEntity> response = testRestTemplate.exchange( diff --git a/src/test/java/com/example/taskmanagerauth/unit/config/JwtRequestFilterTests.java b/src/test/java/com/example/taskmanagerauth/unit/config/JwtRequestFilterTests.java index 238aaa0..7d554d9 100644 --- a/src/test/java/com/example/taskmanagerauth/unit/config/JwtRequestFilterTests.java +++ b/src/test/java/com/example/taskmanagerauth/unit/config/JwtRequestFilterTests.java @@ -54,7 +54,7 @@ public class JwtRequestFilterTests { @BeforeEach void setUp() { - JwtService jwtService = new JwtService("Test"); + JwtService jwtService = new JwtService("Test", "Test2"); this.jwtRequestFilter = new JwtRequestFilter(userService, jwtService, filterExceptionManager); } diff --git a/src/test/java/com/example/taskmanagerauth/unit/entity/MfaTests.java b/src/test/java/com/example/taskmanagerauth/unit/entity/MfaTests.java new file mode 100644 index 0000000..2377f46 --- /dev/null +++ b/src/test/java/com/example/taskmanagerauth/unit/entity/MfaTests.java @@ -0,0 +1,37 @@ +package com.example.taskmanagerauth.unit.entity; + +import com.example.taskmanagerauth.entity.Mfa; +import com.example.taskmanagerauth.entity.User; +import org.junit.jupiter.api.Test; +import org.springframework.test.context.ActiveProfiles; + +import static org.junit.jupiter.api.Assertions.*; + +@ActiveProfiles("test") +public class MfaTests { + + /** + * Test the basic behavior of the Mfa entity class + */ + @Test + void testBasicBehavior() { + + // Creation + Mfa testMfa = new Mfa(); + + // Setters + testMfa.setMfaEnabled(true); + testMfa.setUser(new User(1L, "Test", "Test")); + testMfa.setId(1L); + testMfa.setMfaSecretKey("Test"); + + // Getters + assertEquals(true, testMfa.getMfaEnabled()); + assertEquals(1L, testMfa.getUser().getId()); + assertEquals("Test", testMfa.getMfaSecretKey()); + assertEquals("Test", testMfa.getUser().getUsername()); + assertEquals("Test", testMfa.getUser().getPassword()); + + } + +} diff --git a/src/test/java/com/example/taskmanagerauth/unit/entity/UserTests.java b/src/test/java/com/example/taskmanagerauth/unit/entity/UserTests.java index 5157366..823679f 100644 --- a/src/test/java/com/example/taskmanagerauth/unit/entity/UserTests.java +++ b/src/test/java/com/example/taskmanagerauth/unit/entity/UserTests.java @@ -1,5 +1,6 @@ package com.example.taskmanagerauth.unit.entity; +import com.example.taskmanagerauth.entity.Mfa; import com.example.taskmanagerauth.entity.Role; import com.example.taskmanagerauth.entity.User; import org.junit.jupiter.api.Test; @@ -23,6 +24,7 @@ void testBasicBehavior() { // Creation User testUser = new User(); + Mfa mfa = Mfa.of(testUser, true, "Test"); // Setters testUser.setId(1L); @@ -30,6 +32,7 @@ void testBasicBehavior() { testUser.setRoles(Set.of(Role.of("USER"))); testUser.setUsername("Test username"); testUser.setLastAccessedAt(LocalDateTime.of(2000, 12, 30, 12, 30, 45)); + testUser.setMfa(mfa); // Getters assertEquals(1L, testUser.getId()); @@ -37,6 +40,8 @@ void testBasicBehavior() { assertEquals("USER", testUser.getRoles().stream().findFirst().orElseThrow().getName()); assertEquals("Test username", testUser.getUsername()); assertEquals(LocalDateTime.of(2000, 12, 30, 12, 30, 45), testUser.getLastAccessedAt()); + assertEquals(true, testUser.getMfa().getMfaEnabled()); + assertEquals("Test", testUser.getMfa().getMfaSecretKey()); } diff --git a/src/test/java/com/example/taskmanagerauth/unit/service/JwtServiceTests.java b/src/test/java/com/example/taskmanagerauth/unit/service/JwtServiceTests.java index 695b16d..64aa4dc 100644 --- a/src/test/java/com/example/taskmanagerauth/unit/service/JwtServiceTests.java +++ b/src/test/java/com/example/taskmanagerauth/unit/service/JwtServiceTests.java @@ -24,7 +24,7 @@ public class JwtServiceTests { @BeforeEach void setUp() { - this.jwtService = new JwtService("Test"); + this.jwtService = new JwtService("Test","Test2"); this.userDetails = new User( "Test id", // <- ID's are used instead of usernames From 1c2abb423e8a5a148a221e4848a5e5faff3294fd Mon Sep 17 00:00:00 2001 From: Auwate Date: Mon, 7 Apr 2025 17:34:55 -0400 Subject: [PATCH 02/17] Commit: # Description: - Refactor logic to check if user is present and then throw, instead of throwing when not present --- .../com/example/taskmanagerauth/service/UserService.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/example/taskmanagerauth/service/UserService.java b/src/main/java/com/example/taskmanagerauth/service/UserService.java index 8244409..aa27bec 100644 --- a/src/main/java/com/example/taskmanagerauth/service/UserService.java +++ b/src/main/java/com/example/taskmanagerauth/service/UserService.java @@ -143,9 +143,9 @@ public User getUserById(UserDetails userDetails) { } public void checkIfUserExists(User user) { - userRepository.findByUsername(user.getUsername()).orElseThrow( - () -> new UsernameTakenException("A user with this name already exists.") - ); + if (userRepository.findByUsername(user.getUsername()).isPresent()) { + throw new UsernameTakenException("A user with this name already exists."); + } } From 4f85387f5cbc0c2ef974737baf7b0758bcb54e57 Mon Sep 17 00:00:00 2001 From: Auwate Date: Mon, 7 Apr 2025 17:49:13 -0400 Subject: [PATCH 03/17] Commit: # Description: - Fix bug in User --- src/main/java/com/example/taskmanagerauth/entity/User.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/com/example/taskmanagerauth/entity/User.java b/src/main/java/com/example/taskmanagerauth/entity/User.java index 33ea32b..c92712d 100644 --- a/src/main/java/com/example/taskmanagerauth/entity/User.java +++ b/src/main/java/com/example/taskmanagerauth/entity/User.java @@ -49,6 +49,7 @@ public User(Long id, String username, String password, Set roles) { this.id = id; this.username = username; this.password = password; + this.roles = roles; } public static User of(String username, String password) { From 2ec636dca3c219200ce19c926066ba6192617d9e Mon Sep 17 00:00:00 2001 From: Auwate Date: Mon, 7 Apr 2025 17:52:55 -0400 Subject: [PATCH 04/17] Commit: # Description - Fixed bug in UserControllerIT to add cookies in header --- .../integration/controller/UserControllerIT.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/test/java/com/example/taskmanagerauth/integration/controller/UserControllerIT.java b/src/test/java/com/example/taskmanagerauth/integration/controller/UserControllerIT.java index f95de41..ff51b4d 100644 --- a/src/test/java/com/example/taskmanagerauth/integration/controller/UserControllerIT.java +++ b/src/test/java/com/example/taskmanagerauth/integration/controller/UserControllerIT.java @@ -172,6 +172,9 @@ void testLoginWithoutTotp() { @Order(3) void generateTotp() { + HttpHeaders headers = new HttpHeaders(); + headers.set(HttpHeaders.COOKIE, "mfa_access_token=" + this.cookie); + ResponseEntity> response = testRestTemplate.exchange( GENERATE_QUERY_URL, HttpMethod.GET, From 9ebc38891ce801f57582dcf84eb4baa034f0351b Mon Sep 17 00:00:00 2001 From: Auwate Date: Mon, 7 Apr 2025 17:55:29 -0400 Subject: [PATCH 05/17] Commit: # Description - Fixed bug in UserControllerIT to add cookies in header --- .../integration/controller/UserControllerIT.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/com/example/taskmanagerauth/integration/controller/UserControllerIT.java b/src/test/java/com/example/taskmanagerauth/integration/controller/UserControllerIT.java index ff51b4d..ceab7ac 100644 --- a/src/test/java/com/example/taskmanagerauth/integration/controller/UserControllerIT.java +++ b/src/test/java/com/example/taskmanagerauth/integration/controller/UserControllerIT.java @@ -178,7 +178,7 @@ void generateTotp() { ResponseEntity> response = testRestTemplate.exchange( GENERATE_QUERY_URL, HttpMethod.GET, - HttpEntityFactory(null), + HttpEntityFactory(null, headers), new ParameterizedTypeReference<>() {} ); From 0bc1b60c496fc519ebcd67d078eec309774fb258 Mon Sep 17 00:00:00 2001 From: Auwate Date: Mon, 7 Apr 2025 17:58:07 -0400 Subject: [PATCH 06/17] Commit: # Description - Added /auth/2fa/generate to mfaPath --- .../com/example/taskmanagerauth/config/SecurityConfig.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/example/taskmanagerauth/config/SecurityConfig.java b/src/main/java/com/example/taskmanagerauth/config/SecurityConfig.java index 01600a9..0d3a47b 100644 --- a/src/main/java/com/example/taskmanagerauth/config/SecurityConfig.java +++ b/src/main/java/com/example/taskmanagerauth/config/SecurityConfig.java @@ -37,7 +37,8 @@ public class SecurityConfig { ); public static List mfaPath = List.of( - "/auth/2fa/setup" + "/auth/2fa/setup", + "/auth/2fa/generate" ); @Bean From e99ac3d6a5a824c53034065c594e9bcfb08a6101 Mon Sep 17 00:00:00 2001 From: Auwate Date: Mon, 7 Apr 2025 18:03:39 -0400 Subject: [PATCH 07/17] Commit: # Description - Added separate validation checks based on cookie name --- .../com/example/taskmanagerauth/config/JwtRequestFilter.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/example/taskmanagerauth/config/JwtRequestFilter.java b/src/main/java/com/example/taskmanagerauth/config/JwtRequestFilter.java index 5c08595..24cd6bc 100644 --- a/src/main/java/com/example/taskmanagerauth/config/JwtRequestFilter.java +++ b/src/main/java/com/example/taskmanagerauth/config/JwtRequestFilter.java @@ -105,7 +105,10 @@ protected void doFilterInternal( try { - if (jwtService.validateToken(access_token) || jwtService.validate2faToken(access_token)) { + if ( + cookie_name.equals("taskmanager_access_token") && jwtService.validateToken(access_token) || + cookie_name.equals("mfa_access_token") && jwtService.validate2faToken(access_token) + ) { authorities = jwtService.extractAuthorities(access_token); username = jwtService.extractUser(access_token); From cb342727c2ead55683823c262da10413c5b90777 Mon Sep 17 00:00:00 2001 From: Auwate Date: Mon, 7 Apr 2025 18:42:22 -0400 Subject: [PATCH 08/17] Commit: # Description - Added separate validation checks based on cookie name --- .../example/taskmanagerauth/config/JwtRequestFilter.java | 8 +++----- src/main/resources/application-test.yml | 2 +- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/example/taskmanagerauth/config/JwtRequestFilter.java b/src/main/java/com/example/taskmanagerauth/config/JwtRequestFilter.java index 24cd6bc..dfb1799 100644 --- a/src/main/java/com/example/taskmanagerauth/config/JwtRequestFilter.java +++ b/src/main/java/com/example/taskmanagerauth/config/JwtRequestFilter.java @@ -105,10 +105,8 @@ protected void doFilterInternal( try { - if ( - cookie_name.equals("taskmanager_access_token") && jwtService.validateToken(access_token) || - cookie_name.equals("mfa_access_token") && jwtService.validate2faToken(access_token) - ) { + if (cookie_name.equals("taskmanager_access_token") && jwtService.validateToken(access_token) || + cookie_name.equals("mfa_access_token") && jwtService.validate2faToken(access_token)) { authorities = jwtService.extractAuthorities(access_token); username = jwtService.extractUser(access_token); @@ -180,4 +178,4 @@ private boolean isMfaPath(String servletPath) { return SecurityConfig.mfaPath.contains(servletPath); } -} +} \ No newline at end of file diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml index 5253cdc..0693354 100644 --- a/src/main/resources/application-test.yml +++ b/src/main/resources/application-test.yml @@ -15,7 +15,7 @@ spring: # Logging logging: level: - root: "INFO" + root: "DEBUG" file: name: "logs/application.log" pattern: From 258ac09dd290428a41bf33d2f76693078b6b24d6 Mon Sep 17 00:00:00 2001 From: Auwate Date: Mon, 7 Apr 2025 19:10:44 -0400 Subject: [PATCH 09/17] Commit: # Description - Added lifecycle management to integration tests --- .../taskmanagerauth/integration/controller/UserControllerIT.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/test/java/com/example/taskmanagerauth/integration/controller/UserControllerIT.java b/src/test/java/com/example/taskmanagerauth/integration/controller/UserControllerIT.java index ceab7ac..c493fc8 100644 --- a/src/test/java/com/example/taskmanagerauth/integration/controller/UserControllerIT.java +++ b/src/test/java/com/example/taskmanagerauth/integration/controller/UserControllerIT.java @@ -34,6 +34,7 @@ @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT) @ExtendWith(SpringExtension.class) @ActiveProfiles("test") +@TestInstance(TestInstance.Lifecycle.PER_CLASS) @TestMethodOrder(MethodOrderer.OrderAnnotation.class) public class UserControllerIT { From 79ba63d4b4459d3a813098543dd22d3a5b9f15b2 Mon Sep 17 00:00:00 2001 From: Auwate Date: Mon, 7 Apr 2025 19:27:16 -0400 Subject: [PATCH 10/17] Commit: # Description - Added debug statements to UserService --- .../com/example/taskmanagerauth/service/UserService.java | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/main/java/com/example/taskmanagerauth/service/UserService.java b/src/main/java/com/example/taskmanagerauth/service/UserService.java index aa27bec..5687f4b 100644 --- a/src/main/java/com/example/taskmanagerauth/service/UserService.java +++ b/src/main/java/com/example/taskmanagerauth/service/UserService.java @@ -118,10 +118,18 @@ private Collection mapRolesToAuthorities(Collection< public User getUserByUsernameAndPassword(String username, String password) { + if (logger.isDebugEnabled()) { + logger.debug("Attempting getUserByUsernameAndPassword with {}", username); + } + User user = userRepository.findByUsername(username).orElseThrow( () -> new UsernameNotFoundException("Invalid credentials provided.") ); + if (logger.isDebugEnabled()) { + logger.debug("Attempting password comparison between {} and {}", password, user.getPassword()); + } + if (!passwordEncoder.getEncoder().matches(password, user.getPassword())) { throw new InvalidCredentialsException("Invalid credentials provided."); } From 56fe84b9e18bfb6c909d30fe2c5cc0a64da733f9 Mon Sep 17 00:00:00 2001 From: Auwate Date: Mon, 7 Apr 2025 19:35:31 -0400 Subject: [PATCH 11/17] Commit: # Description - Added debug statements to UserService --- .../java/com/example/taskmanagerauth/service/UserService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/example/taskmanagerauth/service/UserService.java b/src/main/java/com/example/taskmanagerauth/service/UserService.java index 5687f4b..a4eb16d 100644 --- a/src/main/java/com/example/taskmanagerauth/service/UserService.java +++ b/src/main/java/com/example/taskmanagerauth/service/UserService.java @@ -127,7 +127,7 @@ public User getUserByUsernameAndPassword(String username, String password) { ); if (logger.isDebugEnabled()) { - logger.debug("Attempting password comparison between {} and {}", password, user.getPassword()); + logger.debug("Attempting password comparison between {} and {}. {} encoded is {}", password, user.getPassword(), password, passwordEncoder.encode(password)); } if (!passwordEncoder.getEncoder().matches(password, user.getPassword())) { From 9150b8b008f0d98f4615626c97f6abe20b023316 Mon Sep 17 00:00:00 2001 From: Auwate Date: Mon, 7 Apr 2025 19:41:59 -0400 Subject: [PATCH 12/17] Commit: # Description - Added debug statements to UserService --- .../com/example/taskmanagerauth/service/UserService.java | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/main/java/com/example/taskmanagerauth/service/UserService.java b/src/main/java/com/example/taskmanagerauth/service/UserService.java index a4eb16d..fb1ad44 100644 --- a/src/main/java/com/example/taskmanagerauth/service/UserService.java +++ b/src/main/java/com/example/taskmanagerauth/service/UserService.java @@ -126,10 +126,6 @@ public User getUserByUsernameAndPassword(String username, String password) { () -> new UsernameNotFoundException("Invalid credentials provided.") ); - if (logger.isDebugEnabled()) { - logger.debug("Attempting password comparison between {} and {}. {} encoded is {}", password, user.getPassword(), password, passwordEncoder.encode(password)); - } - if (!passwordEncoder.getEncoder().matches(password, user.getPassword())) { throw new InvalidCredentialsException("Invalid credentials provided."); } @@ -164,7 +160,7 @@ public void checkIfUserExists(User user) { public void saveUser(User user) { if (logger.isDebugEnabled()) { - logger.debug("Attempting to save user {}", user.getUsername()); + logger.debug("Attempting to save user {} with pass {}", user.getUsername(), user.getPassword()); } user.setPassword(passwordEncoder.encode(user.getPassword())); From 59806e5148db261ede9dfe6bda7197c1462bd19d Mon Sep 17 00:00:00 2001 From: Auwate Date: Tue, 8 Apr 2025 10:31:29 -0400 Subject: [PATCH 13/17] Commit: # Description - Added debug statements to UserService --- .../service/DefaultPasswordEncodingService.java | 4 ++++ .../taskmanagerauth/service/PasswordEncodingService.java | 1 + .../com/example/taskmanagerauth/service/UserService.java | 6 +++++- 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/example/taskmanagerauth/service/DefaultPasswordEncodingService.java b/src/main/java/com/example/taskmanagerauth/service/DefaultPasswordEncodingService.java index eb5a45c..451b5a1 100644 --- a/src/main/java/com/example/taskmanagerauth/service/DefaultPasswordEncodingService.java +++ b/src/main/java/com/example/taskmanagerauth/service/DefaultPasswordEncodingService.java @@ -14,6 +14,10 @@ public String encode(String password) { return passwordEncoder.encode(password); } + public Boolean matches(String rawPassword, String encoded) { + return passwordEncoder.matches(rawPassword, encoded); + } + public PasswordEncoder getEncoder() { return passwordEncoder; } diff --git a/src/main/java/com/example/taskmanagerauth/service/PasswordEncodingService.java b/src/main/java/com/example/taskmanagerauth/service/PasswordEncodingService.java index 37d3a29..58ee623 100644 --- a/src/main/java/com/example/taskmanagerauth/service/PasswordEncodingService.java +++ b/src/main/java/com/example/taskmanagerauth/service/PasswordEncodingService.java @@ -4,5 +4,6 @@ public interface PasswordEncodingService { String encode(String password); + Boolean matches(String rawPassword, String encoded); PasswordEncoder getEncoder(); } diff --git a/src/main/java/com/example/taskmanagerauth/service/UserService.java b/src/main/java/com/example/taskmanagerauth/service/UserService.java index fb1ad44..0f5fa70 100644 --- a/src/main/java/com/example/taskmanagerauth/service/UserService.java +++ b/src/main/java/com/example/taskmanagerauth/service/UserService.java @@ -126,7 +126,11 @@ public User getUserByUsernameAndPassword(String username, String password) { () -> new UsernameNotFoundException("Invalid credentials provided.") ); - if (!passwordEncoder.getEncoder().matches(password, user.getPassword())) { + if (logger.isDebugEnabled()) { + logger.debug("{} {} compared with {} {}", username, password, user.getUsername(), user.getPassword()); + } + + if (!passwordEncoder.matches(password, user.getPassword())) { throw new InvalidCredentialsException("Invalid credentials provided."); } From c3d70f9f0dbedbdbf27c7c02c0709a1eb73b3bea Mon Sep 17 00:00:00 2001 From: Auwate Date: Tue, 8 Apr 2025 10:52:00 -0400 Subject: [PATCH 14/17] Commit: # Description - Removed hash from saveUser --- .../taskmanagerauth/controller/UserController.java | 2 +- .../taskmanagerauth/service/UserService.java | 13 +++++-------- .../integration/controller/UserControllerIT.java | 5 +---- 3 files changed, 7 insertions(+), 13 deletions(-) diff --git a/src/main/java/com/example/taskmanagerauth/controller/UserController.java b/src/main/java/com/example/taskmanagerauth/controller/UserController.java index 51449d7..d8a0ea7 100644 --- a/src/main/java/com/example/taskmanagerauth/controller/UserController.java +++ b/src/main/java/com/example/taskmanagerauth/controller/UserController.java @@ -63,7 +63,7 @@ public ResponseEntity> register( logger.info("POST HTTP request received at /api/auth/register"); - User user = User.of(registerRequest.getUsername(), registerRequest.getPassword()); + User user = userService.createDatabaseUser(registerRequest.getUsername(), registerRequest.getPassword()); userService.checkIfUserExists(user); mfaService.instantiateMfaForUser(user); diff --git a/src/main/java/com/example/taskmanagerauth/service/UserService.java b/src/main/java/com/example/taskmanagerauth/service/UserService.java index 0f5fa70..9a9cf11 100644 --- a/src/main/java/com/example/taskmanagerauth/service/UserService.java +++ b/src/main/java/com/example/taskmanagerauth/service/UserService.java @@ -116,6 +116,10 @@ private Collection mapRolesToAuthorities(Collection< // Retrieve User objects + public User createDatabaseUser(String username, String password) { + return User.of(username, passwordEncoder.encode(password)); + } + public User getUserByUsernameAndPassword(String username, String password) { if (logger.isDebugEnabled()) { @@ -126,10 +130,6 @@ public User getUserByUsernameAndPassword(String username, String password) { () -> new UsernameNotFoundException("Invalid credentials provided.") ); - if (logger.isDebugEnabled()) { - logger.debug("{} {} compared with {} {}", username, password, user.getUsername(), user.getPassword()); - } - if (!passwordEncoder.matches(password, user.getPassword())) { throw new InvalidCredentialsException("Invalid credentials provided."); } @@ -156,18 +156,15 @@ public void checkIfUserExists(User user) { } } - - // Transactionals @Transactional public void saveUser(User user) { if (logger.isDebugEnabled()) { - logger.debug("Attempting to save user {} with pass {}", user.getUsername(), user.getPassword()); + logger.debug("Attempting to save user {}", user.getUsername()); } - user.setPassword(passwordEncoder.encode(user.getPassword())); userRepository.saveAndFlush(user); } diff --git a/src/test/java/com/example/taskmanagerauth/integration/controller/UserControllerIT.java b/src/test/java/com/example/taskmanagerauth/integration/controller/UserControllerIT.java index c493fc8..8e7d4cf 100644 --- a/src/test/java/com/example/taskmanagerauth/integration/controller/UserControllerIT.java +++ b/src/test/java/com/example/taskmanagerauth/integration/controller/UserControllerIT.java @@ -6,7 +6,6 @@ import com.example.taskmanagerauth.dto.LoginRequest; import com.example.taskmanagerauth.dto.RegisterRequest; import com.example.taskmanagerauth.entity.Mfa; -import com.example.taskmanagerauth.entity.Role; import com.example.taskmanagerauth.entity.User; import com.example.taskmanagerauth.repository.MfaRepository; import com.example.taskmanagerauth.repository.UserRepository; @@ -14,12 +13,10 @@ import com.example.taskmanagerauth.service.PasswordEncodingService; import com.example.taskmanagerauth.service.JwtService; import com.warrenstrange.googleauth.GoogleAuthenticator; -import jakarta.servlet.http.Cookie; import org.junit.jupiter.api.*; import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.web.client.TestRestTemplate; import org.springframework.core.ParameterizedTypeReference; import org.springframework.http.*; import org.springframework.test.context.ActiveProfiles; @@ -125,7 +122,7 @@ void testRegisterSuccess() { User databaseUser = userRepository.findAll().getFirst(); assertEquals(payload.getUsername(), databaseUser.getUsername()); - assertTrue(passwordEncodingService.getEncoder().matches(payload.getPassword(), databaseUser.getPassword())); + assertTrue(passwordEncodingService.matches(payload.getPassword(), databaseUser.getPassword())); // Mfa database assertions Mfa databaseMfa = mfaRepository.findAll().getFirst(); From 9861087ebc0165e45a59e573aa1a0521f9c6b613 Mon Sep 17 00:00:00 2001 From: Auwate Date: Tue, 8 Apr 2025 14:07:59 -0400 Subject: [PATCH 15/17] Commit: # Description: - Refactoring DTO objects to move data between controller and service - Added readable switch statement to control responses in login logic --- pom.xml | 2 +- .../controller/MfaController.java | 13 ++- .../controller/UserController.java | 98 +++++++++++-------- .../dto/{ => impl}/ApiResponse.java | 2 +- .../dto/{ => impl}/LoginRequest.java | 2 +- .../taskmanagerauth/dto/impl/MfaRequest.java | 21 ++++ .../dto/{ => impl}/RegisterRequest.java | 2 +- .../dto/responses/LoginResult.java | 3 + .../dto/responses/MfaRequired.java | 6 ++ .../dto/responses/Success.java | 6 ++ .../dto/responses/TotpRequired.java | 6 ++ .../handler/FilterExceptionManager.java | 4 +- .../handler/GlobalExceptionManager.java | 15 +-- .../taskmanagerauth/service/MfaService.java | 27 ++--- .../taskmanagerauth/service/UserService.java | 32 +++++- src/main/resources/application-test.yml | 2 +- src/main/resources/application.yml | 2 +- .../controller/UserControllerIT.java | 8 +- .../unit/dto/ApiResponseTests.java | 2 +- 19 files changed, 161 insertions(+), 92 deletions(-) rename src/main/java/com/example/taskmanagerauth/dto/{ => impl}/ApiResponse.java (96%) rename src/main/java/com/example/taskmanagerauth/dto/{ => impl}/LoginRequest.java (94%) create mode 100644 src/main/java/com/example/taskmanagerauth/dto/impl/MfaRequest.java rename src/main/java/com/example/taskmanagerauth/dto/{ => impl}/RegisterRequest.java (92%) create mode 100644 src/main/java/com/example/taskmanagerauth/dto/responses/LoginResult.java create mode 100644 src/main/java/com/example/taskmanagerauth/dto/responses/MfaRequired.java create mode 100644 src/main/java/com/example/taskmanagerauth/dto/responses/Success.java create mode 100644 src/main/java/com/example/taskmanagerauth/dto/responses/TotpRequired.java diff --git a/pom.xml b/pom.xml index 92d359d..3d618e9 100644 --- a/pom.xml +++ b/pom.xml @@ -63,7 +63,7 @@ com.h2database h2 2.3.232 - test + diff --git a/src/main/java/com/example/taskmanagerauth/controller/MfaController.java b/src/main/java/com/example/taskmanagerauth/controller/MfaController.java index e1116e4..7dc6515 100644 --- a/src/main/java/com/example/taskmanagerauth/controller/MfaController.java +++ b/src/main/java/com/example/taskmanagerauth/controller/MfaController.java @@ -1,6 +1,8 @@ package com.example.taskmanagerauth.controller; -import com.example.taskmanagerauth.dto.ApiResponse; +import com.example.taskmanagerauth.dto.impl.ApiResponse; +import com.example.taskmanagerauth.dto.impl.MfaRequest; +import com.example.taskmanagerauth.entity.User; import com.example.taskmanagerauth.service.MfaService; import com.example.taskmanagerauth.service.UserService; import org.slf4j.Logger; @@ -25,7 +27,7 @@ public class MfaController { private static final Logger logger = LoggerFactory.getLogger(MfaController.class); @PostMapping("/auth/2fa/setup") - public ResponseEntity> setup(@RequestBody String totp) { + public ResponseEntity> setup(@RequestBody MfaRequest mfaRequest) { if (logger.isDebugEnabled()) { logger.debug("2FA trying to be enabled."); @@ -33,7 +35,10 @@ public ResponseEntity> setup(@RequestBody String totp) { logger.info("POST HTTP request received at /api/auth/2fa/setup"); - mfaService.setupMfa(totp, userService.createUserDetails(userService.loadUserByContext())); + User user = userService.loadUserByContext(); + + mfaService.setupMfa(mfaRequest.getTotp(), user); + userService.saveUser(user); ApiResponse response = ApiResponse.of( HttpStatus.OK.value(), @@ -57,7 +62,7 @@ public ResponseEntity> generateUrl() { ApiResponse response = ApiResponse.of( HttpStatus.OK.value(), "Success", - mfaService.generateMfaCode() + mfaService.generateMfaCode(userService.loadUserByContext()) ); return ResponseEntity.status(HttpStatus.OK).body(response); diff --git a/src/main/java/com/example/taskmanagerauth/controller/UserController.java b/src/main/java/com/example/taskmanagerauth/controller/UserController.java index d8a0ea7..d175a9d 100644 --- a/src/main/java/com/example/taskmanagerauth/controller/UserController.java +++ b/src/main/java/com/example/taskmanagerauth/controller/UserController.java @@ -1,8 +1,12 @@ package com.example.taskmanagerauth.controller; -import com.example.taskmanagerauth.dto.ApiResponse; -import com.example.taskmanagerauth.dto.LoginRequest; -import com.example.taskmanagerauth.dto.RegisterRequest; +import com.example.taskmanagerauth.dto.impl.ApiResponse; +import com.example.taskmanagerauth.dto.impl.LoginRequest; +import com.example.taskmanagerauth.dto.impl.RegisterRequest; +import com.example.taskmanagerauth.dto.responses.LoginResult; +import com.example.taskmanagerauth.dto.responses.MfaRequired; +import com.example.taskmanagerauth.dto.responses.Success; +import com.example.taskmanagerauth.dto.responses.TotpRequired; import com.example.taskmanagerauth.entity.User; import com.example.taskmanagerauth.service.MfaService; import com.example.taskmanagerauth.service.UserService; @@ -91,46 +95,54 @@ public ResponseEntity> login( logger.info("POST HTTP request received at /api/auth/login"); - // Load user - User user = userService.getUserByUsernameAndPassword(loginRequest.getUsername(), loginRequest.getPassword()); - UserDetails userDetails = userService.createUserDetails(user); - - // Check if user has 2fa set up - if (!mfaService.hasMfaEnabled(user)) { - - httpServletResponse.addCookie( - jwtService.generate2faCookie( - userDetails - ) - ); - - ApiResponse response = ApiResponse.of( - 362, // Custom code for requiring TOTP, - "Success", - null - ); - - return ResponseEntity.status(HttpStatus.TEMPORARY_REDIRECT).body(response); - - } - - // Check TOTP - mfaService.validatePassword(loginRequest.getTotp(), user); - - // Create access token cookie - httpServletResponse.addCookie( - jwtService.generateJwtCookie( - userDetails - ) - ); - - ApiResponse response = ApiResponse.of( - HttpStatus.OK.value(), - "Success", - null - ); - - return ResponseEntity.status(HttpStatus.OK).body(response); + // Process user + LoginResult result = userService.login(loginRequest); + + return switch (result) { + case Success success -> { + httpServletResponse.addCookie( + jwtService.generateJwtCookie( + success.userDetails() + ) + ); + yield ResponseEntity.status(HttpStatus.OK).body( + ApiResponse.of( + HttpStatus.OK.value(), + "Success", + null + ) + ); + } + case MfaRequired mfa -> { + httpServletResponse.addCookie( + jwtService.generate2faCookie( + mfa.userDetails() + ) + ); + yield ResponseEntity.status(HttpStatus.OK).body( + ApiResponse.of( + 362, + "Please enable mfa.", + null + ) + ); + } + case TotpRequired totp -> { + httpServletResponse.addCookie( + jwtService.generate2faCookie( + totp.userDetails() + ) + ); + yield ResponseEntity.status(HttpStatus.OK).body( + ApiResponse.of( + 462, + "TOTP not provided.", + null + ) + ); + } + + }; } diff --git a/src/main/java/com/example/taskmanagerauth/dto/ApiResponse.java b/src/main/java/com/example/taskmanagerauth/dto/impl/ApiResponse.java similarity index 96% rename from src/main/java/com/example/taskmanagerauth/dto/ApiResponse.java rename to src/main/java/com/example/taskmanagerauth/dto/impl/ApiResponse.java index 1080680..54423bb 100644 --- a/src/main/java/com/example/taskmanagerauth/dto/ApiResponse.java +++ b/src/main/java/com/example/taskmanagerauth/dto/impl/ApiResponse.java @@ -1,4 +1,4 @@ -package com.example.taskmanagerauth.dto; +package com.example.taskmanagerauth.dto.impl; import com.fasterxml.jackson.annotation.JsonFormat; diff --git a/src/main/java/com/example/taskmanagerauth/dto/LoginRequest.java b/src/main/java/com/example/taskmanagerauth/dto/impl/LoginRequest.java similarity index 94% rename from src/main/java/com/example/taskmanagerauth/dto/LoginRequest.java rename to src/main/java/com/example/taskmanagerauth/dto/impl/LoginRequest.java index e27a65f..d003d2c 100644 --- a/src/main/java/com/example/taskmanagerauth/dto/LoginRequest.java +++ b/src/main/java/com/example/taskmanagerauth/dto/impl/LoginRequest.java @@ -1,4 +1,4 @@ -package com.example.taskmanagerauth.dto; +package com.example.taskmanagerauth.dto.impl; public class LoginRequest { diff --git a/src/main/java/com/example/taskmanagerauth/dto/impl/MfaRequest.java b/src/main/java/com/example/taskmanagerauth/dto/impl/MfaRequest.java new file mode 100644 index 0000000..30fdd8b --- /dev/null +++ b/src/main/java/com/example/taskmanagerauth/dto/impl/MfaRequest.java @@ -0,0 +1,21 @@ +package com.example.taskmanagerauth.dto.impl; + +public class MfaRequest { + + private String totp; + + public MfaRequest(String totp) { + this.totp = totp; + } + + // Getters & setters + + public String getTotp() { + return totp; + } + + public void setTotp(String totp) { + this.totp = totp; + } + +} diff --git a/src/main/java/com/example/taskmanagerauth/dto/RegisterRequest.java b/src/main/java/com/example/taskmanagerauth/dto/impl/RegisterRequest.java similarity index 92% rename from src/main/java/com/example/taskmanagerauth/dto/RegisterRequest.java rename to src/main/java/com/example/taskmanagerauth/dto/impl/RegisterRequest.java index f88e05f..3efba4c 100644 --- a/src/main/java/com/example/taskmanagerauth/dto/RegisterRequest.java +++ b/src/main/java/com/example/taskmanagerauth/dto/impl/RegisterRequest.java @@ -1,4 +1,4 @@ -package com.example.taskmanagerauth.dto; +package com.example.taskmanagerauth.dto.impl; public class RegisterRequest { diff --git a/src/main/java/com/example/taskmanagerauth/dto/responses/LoginResult.java b/src/main/java/com/example/taskmanagerauth/dto/responses/LoginResult.java new file mode 100644 index 0000000..1aa6b67 --- /dev/null +++ b/src/main/java/com/example/taskmanagerauth/dto/responses/LoginResult.java @@ -0,0 +1,3 @@ +package com.example.taskmanagerauth.dto.responses; + +public sealed interface LoginResult permits Success, MfaRequired, TotpRequired {} diff --git a/src/main/java/com/example/taskmanagerauth/dto/responses/MfaRequired.java b/src/main/java/com/example/taskmanagerauth/dto/responses/MfaRequired.java new file mode 100644 index 0000000..13e6824 --- /dev/null +++ b/src/main/java/com/example/taskmanagerauth/dto/responses/MfaRequired.java @@ -0,0 +1,6 @@ +package com.example.taskmanagerauth.dto.responses; + +import org.springframework.security.core.userdetails.UserDetails; + +public record MfaRequired(UserDetails userDetails) implements LoginResult { +} diff --git a/src/main/java/com/example/taskmanagerauth/dto/responses/Success.java b/src/main/java/com/example/taskmanagerauth/dto/responses/Success.java new file mode 100644 index 0000000..a8dc64b --- /dev/null +++ b/src/main/java/com/example/taskmanagerauth/dto/responses/Success.java @@ -0,0 +1,6 @@ +package com.example.taskmanagerauth.dto.responses; + +import org.springframework.security.core.userdetails.UserDetails; + +public record Success(UserDetails userDetails) implements LoginResult { +} diff --git a/src/main/java/com/example/taskmanagerauth/dto/responses/TotpRequired.java b/src/main/java/com/example/taskmanagerauth/dto/responses/TotpRequired.java new file mode 100644 index 0000000..0d4c3b4 --- /dev/null +++ b/src/main/java/com/example/taskmanagerauth/dto/responses/TotpRequired.java @@ -0,0 +1,6 @@ +package com.example.taskmanagerauth.dto.responses; + +import org.springframework.security.core.userdetails.UserDetails; + +public record TotpRequired(UserDetails userDetails) implements LoginResult { +} diff --git a/src/main/java/com/example/taskmanagerauth/exception/handler/FilterExceptionManager.java b/src/main/java/com/example/taskmanagerauth/exception/handler/FilterExceptionManager.java index 994e2fd..3d3feb3 100644 --- a/src/main/java/com/example/taskmanagerauth/exception/handler/FilterExceptionManager.java +++ b/src/main/java/com/example/taskmanagerauth/exception/handler/FilterExceptionManager.java @@ -1,14 +1,12 @@ package com.example.taskmanagerauth.exception.handler; -import com.example.taskmanagerauth.dto.ApiResponse; +import com.example.taskmanagerauth.dto.impl.ApiResponse; import com.example.taskmanagerauth.exception.server.ExpiredJwtException; import com.example.taskmanagerauth.exception.server.InvalidCredentialsException; import com.example.taskmanagerauth.exception.server.InvalidJwtException; import com.example.taskmanagerauth.exception.server.JwtNotProvidedException; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; -import jakarta.servlet.FilterChain; -import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.slf4j.Logger; import org.slf4j.LoggerFactory; diff --git a/src/main/java/com/example/taskmanagerauth/exception/handler/GlobalExceptionManager.java b/src/main/java/com/example/taskmanagerauth/exception/handler/GlobalExceptionManager.java index 0f8617a..2993aef 100644 --- a/src/main/java/com/example/taskmanagerauth/exception/handler/GlobalExceptionManager.java +++ b/src/main/java/com/example/taskmanagerauth/exception/handler/GlobalExceptionManager.java @@ -1,7 +1,8 @@ package com.example.taskmanagerauth.exception.handler; -import com.example.taskmanagerauth.dto.ApiResponse; +import com.example.taskmanagerauth.dto.impl.ApiResponse; import com.example.taskmanagerauth.exception.server.*; +import jakarta.servlet.http.HttpServletResponse; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.core.userdetails.UsernameNotFoundException; @@ -109,27 +110,27 @@ public ResponseEntity> handleTotpNotProvidedException(TotpNo String message = "Bad Request: One time password not provided."; ApiResponse response = ApiResponse.of( - 461, // Custom code for requiring TOTP + 462, // Custom code for requiring TOTP message, exception.getMessage() ); - return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); + return ResponseEntity.status(HttpStatus.OK).body(response); } @ExceptionHandler(MfaNotEnabledException.class) - public ResponseEntity> handleMfaNotEnabledException(MfaNotEnabledException exception) { + public ResponseEntity> handleMfaNotEnabledException(MfaNotEnabledException exception, HttpServletResponse response) { String message = "Bad Request: Please set up MFA for your account."; - ApiResponse response = ApiResponse.of( - 462, // Custom code for requiring TOTP + ApiResponse apiResponse = ApiResponse.of( + 362, // Custom code for requiring TOTP message, exception.getMessage() ); - return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); + return ResponseEntity.status(HttpStatus.OK).body(apiResponse); } diff --git a/src/main/java/com/example/taskmanagerauth/service/MfaService.java b/src/main/java/com/example/taskmanagerauth/service/MfaService.java index f4dc877..5940554 100644 --- a/src/main/java/com/example/taskmanagerauth/service/MfaService.java +++ b/src/main/java/com/example/taskmanagerauth/service/MfaService.java @@ -11,7 +11,6 @@ import com.warrenstrange.googleauth.GoogleAuthenticatorKey; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; -import org.springframework.security.core.userdetails.UserDetails; import org.springframework.stereotype.Service; import java.io.ByteArrayInputStream; @@ -23,17 +22,13 @@ @Service public class MfaService { - private final UserService userService; - private final GoogleAuthenticator authenticator = new GoogleAuthenticator(); private final KeysetHandle mfaKey; @Autowired public MfaService( - @Value("${mfa.secret}") String mfaSecretKeySet, - UserService userService + @Value("${mfa.secret}") String mfaSecretKeySet ) { - this.userService = userService; mfaKey = getMfaKey(mfaSecretKeySet); } @@ -135,11 +130,8 @@ public String decrypt(String encryptedKey) { * Generate the TOTP code for usage on frontend * @return (String) The otpauth code */ - public String generateMfaCode() { - - User user = userService.loadUserByContext(); + public String generateMfaCode(User user) { return "otpauth://totp/TaskManagerAuth:" + user.getId() + "?secret=" + decrypt(user.getMfa().getMfaSecretKey()) + "&issuer=TaskManagerAuth\n"; - } /** @@ -149,16 +141,16 @@ public String generateMfaCode() { */ public void validatePassword(String totp, User user) { + if (!hasMfaEnabled(user)) { + throw new MfaNotEnabledException("Mfa not enabled."); + } + if (totp.isEmpty()) { throw new TotpNotProvidedException("Please provide a TOTP code."); } int totp_num = getTotp(totp); - if (!hasMfaEnabled(user)) { - throw new MfaNotEnabledException("Mfa not enabled."); - } - if (!authenticator.authorize(decrypt(user.getMfa().getMfaSecretKey()), totp_num)) { throw new TotpInvalidException("Incorrect TOTP provided."); } @@ -168,12 +160,11 @@ public void validatePassword(String totp, User user) { /** * Using a TOTP code the user provides, activate the user's MFA if correct * @param totp Code provided - * @param userDetails The user + * @param user The user */ - public void setupMfa(String totp, UserDetails userDetails) { + public void setupMfa(String totp, User user) { int totp_num = getTotp(totp); - User user = userService.getUserById(userDetails); if (!authenticator.authorize(decrypt(user.getMfa().getMfaSecretKey()), totp_num)) { throw new TotpInvalidException("Incorrect TOTP provided."); @@ -181,8 +172,6 @@ public void setupMfa(String totp, UserDetails userDetails) { user.getMfa().setMfaEnabled(true); - userService.saveUser(user); - } /** diff --git a/src/main/java/com/example/taskmanagerauth/service/UserService.java b/src/main/java/com/example/taskmanagerauth/service/UserService.java index 9a9cf11..5439c21 100644 --- a/src/main/java/com/example/taskmanagerauth/service/UserService.java +++ b/src/main/java/com/example/taskmanagerauth/service/UserService.java @@ -1,9 +1,13 @@ package com.example.taskmanagerauth.service; +import com.example.taskmanagerauth.dto.impl.LoginRequest; +import com.example.taskmanagerauth.dto.responses.LoginResult; +import com.example.taskmanagerauth.dto.responses.MfaRequired; +import com.example.taskmanagerauth.dto.responses.Success; +import com.example.taskmanagerauth.dto.responses.TotpRequired; import com.example.taskmanagerauth.entity.Role; import com.example.taskmanagerauth.entity.User; -import com.example.taskmanagerauth.exception.server.InvalidCredentialsException; -import com.example.taskmanagerauth.exception.server.UsernameTakenException; +import com.example.taskmanagerauth.exception.server.*; import com.example.taskmanagerauth.repository.UserRepository; import jakarta.transaction.Transactional; import org.slf4j.Logger; @@ -26,21 +30,39 @@ public class UserService implements UserDetailsService { private final UserRepository userRepository; private final PasswordEncodingService passwordEncoder; - private final JwtService jwtService; + private final MfaService mfaService; @Autowired public UserService( UserRepository userRepository, PasswordEncodingService passwordEncoder, - JwtService jwtService + MfaService mfaService ) { this.userRepository = userRepository; this.passwordEncoder = passwordEncoder; - this.jwtService = jwtService; + this.mfaService = mfaService; } private static final Logger logger = LoggerFactory.getLogger(UserService.class); + // Wrapper methods + + public LoginResult login(LoginRequest loginRequest) { + + User user = getUserByUsernameAndPassword(loginRequest.getUsername(), loginRequest.getPassword()); + UserDetails userDetails = createUserDetails(user); + + try { + mfaService.validatePassword(loginRequest.getTotp(), user); + return new Success(userDetails); + } catch (MfaNotEnabledException exception) { + return new MfaRequired(userDetails); + } catch (TotpNotProvidedException exception) { + return new TotpRequired(userDetails); + } + + } + // UserDetail services public UserDetails createUserDetails(User user) { diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml index 0693354..02f8537 100644 --- a/src/main/resources/application-test.yml +++ b/src/main/resources/application-test.yml @@ -39,4 +39,4 @@ mfa: secret: "CMmRpMMOEmQKWAowdHlwZS5nb29nbGVhcGlzLmNvbS9nb29nbGUuY3J5cHRvLnRpbmsuQWVzR2NtS2V5EiIaILBjvpHue4z0MJuNMpRTZDsvpgvXT5jVNA/1Su8RwTB1GAEQARjJkaTDDiAB" domain: - name: "http://localhost:9095" \ No newline at end of file + name: "http://localhost:3000" \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index b4102bd..e6bb0c3 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -9,7 +9,7 @@ spring: jpa: database-platform: org.hibernate.dialect.OracleDialect hibernate: - ddl-auto: none + ddl-auto: create-drop # Logging logging: diff --git a/src/test/java/com/example/taskmanagerauth/integration/controller/UserControllerIT.java b/src/test/java/com/example/taskmanagerauth/integration/controller/UserControllerIT.java index 8e7d4cf..bb86657 100644 --- a/src/test/java/com/example/taskmanagerauth/integration/controller/UserControllerIT.java +++ b/src/test/java/com/example/taskmanagerauth/integration/controller/UserControllerIT.java @@ -2,9 +2,9 @@ import com.auth0.jwt.JWT; import com.auth0.jwt.algorithms.Algorithm; -import com.example.taskmanagerauth.dto.ApiResponse; -import com.example.taskmanagerauth.dto.LoginRequest; -import com.example.taskmanagerauth.dto.RegisterRequest; +import com.example.taskmanagerauth.dto.impl.ApiResponse; +import com.example.taskmanagerauth.dto.impl.LoginRequest; +import com.example.taskmanagerauth.dto.impl.RegisterRequest; import com.example.taskmanagerauth.entity.Mfa; import com.example.taskmanagerauth.entity.User; import com.example.taskmanagerauth.repository.MfaRepository; @@ -145,7 +145,7 @@ void testLoginWithoutTotp() { ); // Assertions - assertEquals(HttpStatus.TEMPORARY_REDIRECT, response.getStatusCode()); + assertEquals(HttpStatus.OK, response.getStatusCode()); assertNotNull(response.getBody()); assertEquals("Success", response.getBody().getMessage()); assertEquals(362, response.getBody().getStatus()); diff --git a/src/test/java/com/example/taskmanagerauth/unit/dto/ApiResponseTests.java b/src/test/java/com/example/taskmanagerauth/unit/dto/ApiResponseTests.java index 01c4d1f..4fb97ab 100644 --- a/src/test/java/com/example/taskmanagerauth/unit/dto/ApiResponseTests.java +++ b/src/test/java/com/example/taskmanagerauth/unit/dto/ApiResponseTests.java @@ -1,6 +1,6 @@ package com.example.taskmanagerauth.unit.dto; -import com.example.taskmanagerauth.dto.ApiResponse; +import com.example.taskmanagerauth.dto.impl.ApiResponse; import org.junit.jupiter.api.Test; import org.springframework.test.context.ActiveProfiles; From 04a5fa07b8446592146a36441303f352a7295414 Mon Sep 17 00:00:00 2001 From: Auwate Date: Tue, 8 Apr 2025 14:10:13 -0400 Subject: [PATCH 16/17] Commit: # Description - Changed integration tests --- .../exception/handler/GlobalExceptionManager.java | 3 +-- .../integration/controller/UserControllerIT.java | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/example/taskmanagerauth/exception/handler/GlobalExceptionManager.java b/src/main/java/com/example/taskmanagerauth/exception/handler/GlobalExceptionManager.java index 2993aef..80c2133 100644 --- a/src/main/java/com/example/taskmanagerauth/exception/handler/GlobalExceptionManager.java +++ b/src/main/java/com/example/taskmanagerauth/exception/handler/GlobalExceptionManager.java @@ -2,7 +2,6 @@ import com.example.taskmanagerauth.dto.impl.ApiResponse; import com.example.taskmanagerauth.exception.server.*; -import jakarta.servlet.http.HttpServletResponse; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.core.userdetails.UsernameNotFoundException; @@ -120,7 +119,7 @@ public ResponseEntity> handleTotpNotProvidedException(TotpNo } @ExceptionHandler(MfaNotEnabledException.class) - public ResponseEntity> handleMfaNotEnabledException(MfaNotEnabledException exception, HttpServletResponse response) { + public ResponseEntity> handleMfaNotEnabledException(MfaNotEnabledException exception) { String message = "Bad Request: Please set up MFA for your account."; diff --git a/src/test/java/com/example/taskmanagerauth/integration/controller/UserControllerIT.java b/src/test/java/com/example/taskmanagerauth/integration/controller/UserControllerIT.java index bb86657..3110244 100644 --- a/src/test/java/com/example/taskmanagerauth/integration/controller/UserControllerIT.java +++ b/src/test/java/com/example/taskmanagerauth/integration/controller/UserControllerIT.java @@ -147,7 +147,7 @@ void testLoginWithoutTotp() { // Assertions assertEquals(HttpStatus.OK, response.getStatusCode()); assertNotNull(response.getBody()); - assertEquals("Success", response.getBody().getMessage()); + assertEquals("Please enable mfa.", response.getBody().getMessage()); assertEquals(362, response.getBody().getStatus()); // Get cookie From a2914fffd5f046b42aaf19092cc7f8288fe7bd29 Mon Sep 17 00:00:00 2001 From: Auwate Date: Tue, 8 Apr 2025 14:13:41 -0400 Subject: [PATCH 17/17] Commit: # Description - Changed integration tests --- .../integration/controller/UserControllerIT.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/test/java/com/example/taskmanagerauth/integration/controller/UserControllerIT.java b/src/test/java/com/example/taskmanagerauth/integration/controller/UserControllerIT.java index 3110244..94e7ed2 100644 --- a/src/test/java/com/example/taskmanagerauth/integration/controller/UserControllerIT.java +++ b/src/test/java/com/example/taskmanagerauth/integration/controller/UserControllerIT.java @@ -4,6 +4,7 @@ import com.auth0.jwt.algorithms.Algorithm; import com.example.taskmanagerauth.dto.impl.ApiResponse; import com.example.taskmanagerauth.dto.impl.LoginRequest; +import com.example.taskmanagerauth.dto.impl.MfaRequest; import com.example.taskmanagerauth.dto.impl.RegisterRequest; import com.example.taskmanagerauth.entity.Mfa; import com.example.taskmanagerauth.entity.User; @@ -200,7 +201,7 @@ void generateTotp() { void setupTotp() { GoogleAuthenticator authenticator = new GoogleAuthenticator(); - String payload = String.valueOf(authenticator.getTotpPassword(secretKey)); + MfaRequest payload = new MfaRequest(String.valueOf(authenticator.getTotpPassword(secretKey))); HttpHeaders headers = new HttpHeaders(); headers.set(HttpHeaders.COOKIE, "mfa_access_token=" + this.cookie);