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..3d618e9 100644
--- a/pom.xml
+++ b/pom.xml
@@ -58,12 +58,12 @@
spring-security-test
test
-
com.h2database
h2
2.3.232
+
@@ -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..dfb1799 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,8 @@ protected void doFilterInternal(
try {
- if (jwtService.validateToken(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);
@@ -165,4 +174,8 @@ private boolean isPermitAllPath(String servletPath) {
return SecurityConfig.permitAllPaths.contains(servletPath);
}
-}
+ private boolean isMfaPath(String servletPath) {
+ return SecurityConfig.mfaPath.contains(servletPath);
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/com/example/taskmanagerauth/config/SecurityConfig.java b/src/main/java/com/example/taskmanagerauth/config/SecurityConfig.java
index ae2293b..0d3a47b 100644
--- a/src/main/java/com/example/taskmanagerauth/config/SecurityConfig.java
+++ b/src/main/java/com/example/taskmanagerauth/config/SecurityConfig.java
@@ -36,6 +36,11 @@ public class SecurityConfig {
"/auth/login"
);
+ public static List mfaPath = List.of(
+ "/auth/2fa/setup",
+ "/auth/2fa/generate"
+ );
+
@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..7dc6515
--- /dev/null
+++ b/src/main/java/com/example/taskmanagerauth/controller/MfaController.java
@@ -0,0 +1,72 @@
+package com.example.taskmanagerauth.controller;
+
+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;
+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 MfaRequest mfaRequest) {
+
+ if (logger.isDebugEnabled()) {
+ logger.debug("2FA trying to be enabled.");
+ }
+
+ logger.info("POST HTTP request received at /api/auth/2fa/setup");
+
+ User user = userService.loadUserByContext();
+
+ mfaService.setupMfa(mfaRequest.getTotp(), user);
+ userService.saveUser(user);
+
+ 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(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 dca98bd..d175a9d 100644
--- a/src/main/java/com/example/taskmanagerauth/controller/UserController.java
+++ b/src/main/java/com/example/taskmanagerauth/controller/UserController.java
@@ -1,7 +1,14 @@
package com.example.taskmanagerauth.controller;
-import com.example.taskmanagerauth.dto.ApiResponse;
+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;
import com.example.taskmanagerauth.service.JwtService;
import jakarta.servlet.http.HttpServletResponse;
@@ -10,6 +17,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 +34,9 @@ public class UserController {
@Autowired
private JwtService jwtService;
+ @Autowired
+ private MfaService mfaService;
+
@GetMapping("/auth/validate")
public ResponseEntity> validate() {
@@ -46,7 +57,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 +67,11 @@ public ResponseEntity> register(@RequestBody User user) {
logger.info("POST HTTP request received at /api/auth/register");
- userService.registerUser(user);
+ User user = userService.createDatabaseUser(registerRequest.getUsername(), registerRequest.getPassword());
+
+ userService.checkIfUserExists(user);
+ mfaService.instantiateMfaForUser(user);
+ userService.saveUser(user);
ApiResponse response = ApiResponse.of(
HttpStatus.OK.value(),
@@ -67,8 +84,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,21 +95,54 @@ public ResponseEntity> authenticate(
logger.info("POST HTTP request received at /api/auth/login");
- httpServletResponse.addCookie(
- jwtService.generateJwtCookie(
- userService.loadUserByUsernamePassword(
- user.getUsername(), user.getPassword()
- )
- )
- );
-
- 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/impl/LoginRequest.java b/src/main/java/com/example/taskmanagerauth/dto/impl/LoginRequest.java
new file mode 100644
index 0000000..d003d2c
--- /dev/null
+++ b/src/main/java/com/example/taskmanagerauth/dto/impl/LoginRequest.java
@@ -0,0 +1,43 @@
+package com.example.taskmanagerauth.dto.impl;
+
+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/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/impl/RegisterRequest.java b/src/main/java/com/example/taskmanagerauth/dto/impl/RegisterRequest.java
new file mode 100644
index 0000000..3efba4c
--- /dev/null
+++ b/src/main/java/com/example/taskmanagerauth/dto/impl/RegisterRequest.java
@@ -0,0 +1,29 @@
+package com.example.taskmanagerauth.dto.impl;
+
+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/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/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..c92712d 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,17 @@ 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;
+ this.roles = roles;
+ }
+
+ 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 +92,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/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 7a87cc0..80c2133 100644
--- a/src/main/java/com/example/taskmanagerauth/exception/handler/GlobalExceptionManager.java
+++ b/src/main/java/com/example/taskmanagerauth/exception/handler/GlobalExceptionManager.java
@@ -1,6 +1,6 @@
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 org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
@@ -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(
+ 462, // Custom code for requiring TOTP
+ message,
+ exception.getMessage()
+ );
+
+ return ResponseEntity.status(HttpStatus.OK).body(response);
+
+ }
+
+ @ExceptionHandler(MfaNotEnabledException.class)
+ public ResponseEntity> handleMfaNotEnabledException(MfaNotEnabledException exception) {
+
+ String message = "Bad Request: Please set up MFA for your account.";
+
+ ApiResponse apiResponse = ApiResponse.of(
+ 362, // Custom code for requiring TOTP
+ message,
+ exception.getMessage()
+ );
+
+ return ResponseEntity.status(HttpStatus.OK).body(apiResponse);
+
+ }
+
+ @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/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/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..5940554
--- /dev/null
+++ b/src/main/java/com/example/taskmanagerauth/service/MfaService.java
@@ -0,0 +1,189 @@
+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.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 GoogleAuthenticator authenticator = new GoogleAuthenticator();
+ private final KeysetHandle mfaKey;
+
+ @Autowired
+ public MfaService(
+ @Value("${mfa.secret}") String mfaSecretKeySet
+ ) {
+ 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) {
+ 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 (!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 (!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 user The user
+ */
+ public void setupMfa(String totp, User user) {
+
+ int totp_num = getTotp(totp);
+
+ if (!authenticator.authorize(decrypt(user.getMfa().getMfaSecretKey()), totp_num)) {
+ throw new TotpInvalidException("Incorrect TOTP provided.");
+ }
+
+ user.getMfa().setMfaEnabled(true);
+
+ }
+
+ /**
+ * 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/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 b9be530..5439c21 100644
--- a/src/main/java/com/example/taskmanagerauth/service/UserService.java
+++ b/src/main/java/com/example/taskmanagerauth/service/UserService.java
@@ -1,17 +1,22 @@
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 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;
@@ -25,21 +30,49 @@ 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) {
+ 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 +84,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 +118,76 @@ 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 extends GrantedAuthority> mapRolesToAuthorities(Collection roles) {
return roles.stream()
.map(role -> new SimpleGrantedAuthority(role.getName()))
.toList();
}
+ // 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()) {
+ logger.debug("Attempting getUserByUsernameAndPassword with {}", username);
+ }
+
+ User user = userRepository.findByUsername(username).orElseThrow(
+ () -> new UsernameNotFoundException("Invalid credentials provided.")
+ );
+
+ if (!passwordEncoder.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) {
+ if (userRepository.findByUsername(user.getUsername()).isPresent()) {
+ throw 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.");
- }
+ userRepository.saveAndFlush(user);
}
diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml
index 64a9531..02f8537 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:
@@ -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
+ 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 41ddac9..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:
@@ -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..94e7ed2 100644
--- a/src/test/java/com/example/taskmanagerauth/integration/controller/UserControllerIT.java
+++ b/src/test/java/com/example/taskmanagerauth/integration/controller/UserControllerIT.java
@@ -2,17 +2,22 @@
import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
-import com.example.taskmanagerauth.dto.ApiResponse;
-import com.example.taskmanagerauth.entity.Role;
+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;
+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 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;
@@ -23,11 +28,11 @@
import java.util.Date;
import java.util.List;
-import java.util.Set;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT)
@ExtendWith(SpringExtension.class)
@ActiveProfiles("test")
+@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
public class UserControllerIT {
@@ -35,23 +40,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 +103,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 +119,120 @@ 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());
+ assertTrue(passwordEncodingService.matches(payload.getPassword(), databaseUser.getPassword()));
+
+ // 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.OK, response.getStatusCode());
+ assertNotNull(response.getBody());
+ assertEquals("Please enable mfa.", 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() {
+
+ HttpHeaders headers = new HttpHeaders();
+ headers.set(HttpHeaders.COOKIE, "mfa_access_token=" + this.cookie);
+
+ ResponseEntity> response = testRestTemplate.exchange(
+ GENERATE_QUERY_URL,
+ HttpMethod.GET,
+ HttpEntityFactory(null, headers),
+ 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();
+ MfaRequest payload = new MfaRequest(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 +260,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/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;
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