| @@ -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' | |||
| @@ -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; | |||
| } | |||
| } | |||
| @@ -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() { | |||
| @@ -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 | |||
| @@ -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<AbilityModel> abilities = new HashSet<>(); | |||
| // userAuthorityService.getUserAuthority(user).forEach(auth -> abilities.add(new | |||
| // AbilityModel(auth.getAuthority()))); | |||
| List<String> abilities = new ArrayList<String>(); | |||
| userAuthorityService.getUserAuthority(user).forEach(auth -> abilities.add(auth.get("authority").toString())); | |||
| @@ -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<String, String> 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<String, String> 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<String, String> 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<String> abilities = new ArrayList<>(); | |||
| userAuthorityService.getUserAuthority(user).forEach(auth -> | |||
| abilities.add(auth.get("authority").toString()) | |||
| ); | |||
| return ResponseEntity.ok(new JwtResponse(accessToken, refreshToken, null, user, abilities)); | |||
| } | |||
| } | |||
| @@ -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<Long> 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<Long> 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; | |||
| } | |||
| } | |||
| @@ -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<User, Long, UserRepository> { | |||
| private static final String USER_AUTH_SQL = "SELECT a.authority" | |||
| @@ -748,4 +750,18 @@ public class UserService extends AbstractBaseEntityService<User, Long, UserRepos | |||
| return jdbcDao.queryForInt(sql.toString(), Map.of("userId", id)); | |||
| } | |||
| public User getCurrentUser(Authentication authentication) { | |||
| if (authentication == null || !authentication.isAuthenticated()) { | |||
| throw new IllegalStateException("No authenticated user"); | |||
| } | |||
| String username = authentication.getName(); | |||
| return findByUsername(username) | |||
| .orElseThrow(() -> new RuntimeException("Authenticated user not found: " + username)); | |||
| } | |||
| public User getUserByUsername(String username) { | |||
| return findByUsername(username) | |||
| .orElseThrow(() -> new NotFoundException()); | |||
| } | |||
| } | |||