diff --git a/build.gradle b/build.gradle index e3bdcf8..478900c 100644 --- a/build.gradle +++ b/build.gradle @@ -61,6 +61,18 @@ dependencies { implementation 'com.itextpdf:kernel:7.2.5' implementation 'com.itextpdf:io:7.2.5' + // TOTP 2FA Library + implementation 'dev.samstevens.totp:totp-spring-boot-starter:1.7.1' + + // ZXing (optional, for QR if needed) + implementation 'com.google.zxing:core:3.5.3' + implementation 'com.google.zxing:javase:3.5.3' + + // Lombok + compileOnly 'org.projectlombok:lombok:1.18.34' + annotationProcessor 'org.projectlombok:lombok:1.18.34' + testCompileOnly 'org.projectlombok:lombok:1.18.34' + testAnnotationProcessor 'org.projectlombok:lombok:1.18.34' implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server' implementation 'org.springframework.security:spring-security-oauth2-jose' @@ -68,7 +80,7 @@ dependencies { runtimeOnly 'com.mysql:mysql-connector-j' runtimeOnly 'com.unboundid:unboundid-ldapsdk:6.0.9' - + testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' diff --git a/src/main/java/com/ffii/lioner/config/TotpConfig.java b/src/main/java/com/ffii/lioner/config/TotpConfig.java new file mode 100644 index 0000000..ce17778 --- /dev/null +++ b/src/main/java/com/ffii/lioner/config/TotpConfig.java @@ -0,0 +1,41 @@ +package com.ffii.lioner.config; + +import dev.samstevens.totp.code.CodeGenerator; +import dev.samstevens.totp.code.CodeVerifier; +import dev.samstevens.totp.code.DefaultCodeGenerator; +import dev.samstevens.totp.code.DefaultCodeVerifier; +import dev.samstevens.totp.secret.DefaultSecretGenerator; +import dev.samstevens.totp.secret.SecretGenerator; +import dev.samstevens.totp.time.SystemTimeProvider; +import dev.samstevens.totp.time.TimeProvider; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class TotpConfig { + + @Bean + public SecretGenerator secretGenerator() { + // Generates a 160-bit (20-byte) Base32 secret - standard for most apps + return new DefaultSecretGenerator(); + } + + @Bean + public TimeProvider timeProvider() { + return new SystemTimeProvider(); + } + + @Bean + public CodeGenerator codeGenerator(TimeProvider timeProvider) { + return new DefaultCodeGenerator(); + } + + @Bean + public CodeVerifier codeVerifier(CodeGenerator codeGenerator, TimeProvider timeProvider) { + DefaultCodeVerifier verifier = new DefaultCodeVerifier(codeGenerator, timeProvider); + // Allow +/- 1 time step (30 seconds drift) + verifier.setTimePeriod(30); + verifier.setAllowedTimePeriodDiscrepancy(1); + return verifier; + } +} \ No newline at end of file diff --git a/src/main/java/com/ffii/lioner/config/WebConfig.java b/src/main/java/com/ffii/lioner/config/WebConfig.java index df9ce86..8c43e89 100644 --- a/src/main/java/com/ffii/lioner/config/WebConfig.java +++ b/src/main/java/com/ffii/lioner/config/WebConfig.java @@ -13,23 +13,21 @@ public class WebConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { - registry.addMapping("/**") // Apply to all API endpoints - .allowedHeaders("*") - // **** CRITICAL FIX HERE **** - .allowedOrigins( - "http://localhost", // If you test locally via Nginx at http://localhost - "http://127.0.0.1", // Sometimes browsers resolve localhost to 127.0.0.1 - "http://10.40.0.4", - "http://20.2.170.164", - "https://20.2.170.164", - "http://localhost:3000", - "https://forms.lioner.com" - // Add any other specific domains/IPs/ports where your frontend will be hosted - ) - .allowedMethods("GET", "POST", "PUT", "DELETE", "HEAD", "OPTIONS") // **** IMPORTANT: Add OPTIONS **** - .allowCredentials(true) - .maxAge(3600); // Recommended: Caches preflight results for 1 hour -} + registry.addMapping("/**") + .allowedOrigins( + "http://localhost:3000", // React dev server + "http://localhost", + "http://127.0.0.1", + "http://10.40.0.4", + "http://20.2.170.164", + "https://20.2.170.164", + "https://forms.lioner.com" + ) + .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "HEAD") + .allowedHeaders("*") + .allowCredentials(true) + .maxAge(3600); + } @Bean public InternalResourceViewResolver defaultViewResolver() { diff --git a/src/main/java/com/ffii/lioner/config/security/SecurityConfig.java b/src/main/java/com/ffii/lioner/config/security/SecurityConfig.java index 142b47a..8164ede 100644 --- a/src/main/java/com/ffii/lioner/config/security/SecurityConfig.java +++ b/src/main/java/com/ffii/lioner/config/security/SecurityConfig.java @@ -33,11 +33,15 @@ public class SecurityConfig { public static final String INDEX_URL = "/"; public static final String LOGIN_URL = "/login"; public static final String REFRESH_TOKEN_URL = "/refresh-token"; + public static final String VERIFY_LOGIN = "/api/2fa/verify-login"; + public static final String VERIFY_LOGIN2 = "/2fa/verify-login"; - public static final String[] URL_WHITELIST = { - INDEX_URL, - LOGIN_URL, - REFRESH_TOKEN_URL + private static final String[] URL_WHITELIST = { + INDEX_URL, + LOGIN_URL, + REFRESH_TOKEN_URL, + VERIFY_LOGIN, + VERIFY_LOGIN2, }; @Lazy diff --git a/src/main/java/com/ffii/lioner/config/security/jwt/web/JwtAuthenticationController.java b/src/main/java/com/ffii/lioner/config/security/jwt/web/JwtAuthenticationController.java index a052ac7..4e6ee61 100644 --- a/src/main/java/com/ffii/lioner/config/security/jwt/web/JwtAuthenticationController.java +++ b/src/main/java/com/ffii/lioner/config/security/jwt/web/JwtAuthenticationController.java @@ -179,16 +179,23 @@ public class JwtAuthenticationController { } private ResponseEntity createAuthTokenResponse(UserDetails userDetails) { - long accessTokenExpiry = /* settingsService.getInt(SettingNames.SYS_IDLE_LOGOUT_TIME) */ TOKEN_DURATION - * EXPIRY_IN_MINTUE; + User user = userRepository.findByUsernameAndDeletedFalse(userDetails.getUsername()) + .orElseThrow(() -> new UsernameNotFoundException("User not found")); + + // === 2FA CHECK === + if (user.isTwoFactorEnabled()) { + // Do NOT issue tokens yet — require 2FA code + return ResponseEntity.ok(Map.of( + "requires2FA", true, + "username", user.getUsername() + )); + } + // === END 2FA CHECK === + + long accessTokenExpiry = TOKEN_DURATION * EXPIRY_IN_MINTUE; final String accessToken = jwtTokenUtil.generateToken(userDetails, accessTokenExpiry); final String refreshToken = jwtTokenUtil.createRefreshToken(userDetails.getUsername()).getToken(); - User user = userRepository.findByUsernameAndDeletedFalse(userDetails.getUsername()).get(); - - // Set abilities = new HashSet<>(); - // userAuthorityService.getUserAuthority(user).forEach(auth -> abilities.add(new - // AbilityModel(auth.getAuthority()))); List abilities = new ArrayList(); userAuthorityService.getUserAuthority(user).forEach(auth -> abilities.add(auth.get("authority").toString())); diff --git a/src/main/java/com/ffii/lioner/modules/lioner/web/TwoFactorController.java b/src/main/java/com/ffii/lioner/modules/lioner/web/TwoFactorController.java new file mode 100644 index 0000000..2d47aa9 --- /dev/null +++ b/src/main/java/com/ffii/lioner/modules/lioner/web/TwoFactorController.java @@ -0,0 +1,104 @@ +package com.ffii.lioner.modules.lioner.web; + +import dev.samstevens.totp.code.CodeVerifier; +import dev.samstevens.totp.qr.QrData; +import dev.samstevens.totp.secret.SecretGenerator; + +import lombok.RequiredArgsConstructor; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import com.ffii.lioner.modules.user.entity.User; +import com.ffii.lioner.modules.user.service.UserService; +import com.ffii.lioner.modules.user.service.UserAuthorityService; // ← ADD THIS IMPORT +import com.ffii.lioner.config.security.jwt.service.JwtUserDetailsService; +import com.ffii.core.utils.JwtTokenUtil; // ← Use the correct path from your project +import com.ffii.lioner.model.JwtResponse; + +@RestController +@RequestMapping("/2fa") +@RequiredArgsConstructor +public class TwoFactorController { + + private final SecretGenerator secretGenerator; + private final CodeVerifier codeVerifier; + private final UserService userService; + private final JwtUserDetailsService userDetailsService; + private final JwtTokenUtil jwtTokenUtil; + private final UserAuthorityService userAuthorityService; + + private static final long TOKEN_DURATION_MINUTES = 5; + + @PostMapping("/setup") + public Map setup2FA(Authentication authentication) { + User user = userService.getCurrentUser(authentication); + + String secret = secretGenerator.generate(); + user.setTwoFactorSecret(secret); + user.setTwoFactorEnabled(false); + userService.save(user); + + QrData data = new QrData.Builder() + .label(user.getUsername()) + .secret(secret) + .issuer("Lioner Form Mapping System") + .build(); + + return Map.of("otpauthUrl", data.getUri()); + } + + @PostMapping("/verify-setup") + public ResponseEntity verifySetup(@RequestBody Map request, Authentication auth) { + String code = request.get("code"); + User user = userService.getCurrentUser(auth); + + if (codeVerifier.isValidCode(user.getTwoFactorSecret(), code)) { + user.setTwoFactorEnabled(true); + userService.save(user); + return ResponseEntity.ok(Map.of("message", "2FA enabled successfully")); + } + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid verification code"); + } + + @PostMapping("/verify-login") + public ResponseEntity verifyLogin(@RequestBody Map request) { + String code = request.get("code"); + String username = request.get("username"); + + User user = userService.getUserByUsername(username); + + if (!user.isTwoFactorEnabled()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "2FA not enabled for this user"); + } + + if (!codeVerifier.isValidCode(user.getTwoFactorSecret(), code)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid 2FA code"); + } + + // Generate tokens + UserDetails userDetails = userDetailsService.loadUserByUsername(username); + long accessTokenExpiry = TOKEN_DURATION_MINUTES * 60 * 1000; + String accessToken = jwtTokenUtil.generateToken(userDetails, accessTokenExpiry); + String refreshToken = jwtTokenUtil.createRefreshToken(username).getToken(); + + // Load abilities — now works because service is injected + List abilities = new ArrayList<>(); + userAuthorityService.getUserAuthority(user).forEach(auth -> + abilities.add(auth.get("authority").toString()) + ); + + return ResponseEntity.ok(new JwtResponse(accessToken, refreshToken, null, user, abilities)); + } +} \ No newline at end of file diff --git a/src/main/java/com/ffii/lioner/modules/user/entity/User.java b/src/main/java/com/ffii/lioner/modules/user/entity/User.java index d3c34ec..21dd4b5 100644 --- a/src/main/java/com/ffii/lioner/modules/user/entity/User.java +++ b/src/main/java/com/ffii/lioner/modules/user/entity/User.java @@ -3,6 +3,8 @@ package com.ffii.lioner.modules.user.entity; import java.time.LocalDate; import java.util.Collection; +import org.hibernate.annotations.ColumnTransformer; + import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.Table; @@ -75,6 +77,22 @@ public class User extends BaseEntity implements UserDetails { @Column private String remarks; + /** + * The Base32-encoded secret key used for TOTP (Time-based One-Time Password). + * Generated once during 2FA setup and stored securely. + * NULL = 2FA not configured + */ + @Column + private String twoFactorSecret; + + /** + * Indicates whether 2FA is enabled for this user. + * false = not enabled (default) + * true = enabled and required during login + */ + @Column + private boolean twoFactorEnabled = false; + public boolean isLocked() { return this.locked == null ? false : this.locked; @@ -237,4 +255,20 @@ public class User extends BaseEntity implements UserDetails { this.department = department; } + public String getTwoFactorSecret() { + return twoFactorSecret; + } + + public void setTwoFactorSecret(String twoFactorSecret) { + this.twoFactorSecret = twoFactorSecret; + } + + public boolean isTwoFactorEnabled() { + return twoFactorEnabled; + } + + public void setTwoFactorEnabled(boolean twoFactorEnabled) { + this.twoFactorEnabled = twoFactorEnabled; + } + } diff --git a/src/main/java/com/ffii/lioner/modules/user/service/UserService.java b/src/main/java/com/ffii/lioner/modules/user/service/UserService.java index ae1f763..022a917 100644 --- a/src/main/java/com/ffii/lioner/modules/user/service/UserService.java +++ b/src/main/java/com/ffii/lioner/modules/user/service/UserService.java @@ -59,6 +59,8 @@ import com.ffii.core.utils.PasswordUtils; import jakarta.persistence.Table; +import org.springframework.security.core.Authentication; + @Service public class UserService extends AbstractBaseEntityService { private static final String USER_AUTH_SQL = "SELECT a.authority" @@ -748,4 +750,18 @@ public class UserService extends AbstractBaseEntityService new RuntimeException("Authenticated user not found: " + username)); + } + + public User getUserByUsername(String username) { + return findByUsername(username) + .orElseThrow(() -> new NotFoundException()); + } + }