| @@ -61,6 +61,18 @@ dependencies { | |||||
| implementation 'com.itextpdf:kernel:7.2.5' | implementation 'com.itextpdf:kernel:7.2.5' | ||||
| implementation 'com.itextpdf:io: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.boot:spring-boot-starter-oauth2-resource-server' | ||||
| implementation 'org.springframework.security:spring-security-oauth2-jose' | implementation 'org.springframework.security:spring-security-oauth2-jose' | ||||
| @@ -68,7 +80,7 @@ dependencies { | |||||
| runtimeOnly 'com.mysql:mysql-connector-j' | runtimeOnly 'com.mysql:mysql-connector-j' | ||||
| runtimeOnly 'com.unboundid:unboundid-ldapsdk:6.0.9' | runtimeOnly 'com.unboundid:unboundid-ldapsdk:6.0.9' | ||||
| testImplementation 'org.springframework.boot:spring-boot-starter-test' | testImplementation 'org.springframework.boot:spring-boot-starter-test' | ||||
| testImplementation 'org.springframework.security:spring-security-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 | @Override | ||||
| public void addCorsMappings(CorsRegistry registry) { | 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 | @Bean | ||||
| public InternalResourceViewResolver defaultViewResolver() { | public InternalResourceViewResolver defaultViewResolver() { | ||||
| @@ -33,11 +33,15 @@ public class SecurityConfig { | |||||
| public static final String INDEX_URL = "/"; | public static final String INDEX_URL = "/"; | ||||
| public static final String LOGIN_URL = "/login"; | public static final String LOGIN_URL = "/login"; | ||||
| public static final String REFRESH_TOKEN_URL = "/refresh-token"; | 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 | @Lazy | ||||
| @@ -179,16 +179,23 @@ public class JwtAuthenticationController { | |||||
| } | } | ||||
| private ResponseEntity<?> createAuthTokenResponse(UserDetails userDetails) { | 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 accessToken = jwtTokenUtil.generateToken(userDetails, accessTokenExpiry); | ||||
| final String refreshToken = jwtTokenUtil.createRefreshToken(userDetails.getUsername()).getToken(); | 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>(); | List<String> abilities = new ArrayList<String>(); | ||||
| userAuthorityService.getUserAuthority(user).forEach(auth -> abilities.add(auth.get("authority").toString())); | 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.time.LocalDate; | ||||
| import java.util.Collection; | import java.util.Collection; | ||||
| import org.hibernate.annotations.ColumnTransformer; | |||||
| import jakarta.persistence.Column; | import jakarta.persistence.Column; | ||||
| import jakarta.persistence.Entity; | import jakarta.persistence.Entity; | ||||
| import jakarta.persistence.Table; | import jakarta.persistence.Table; | ||||
| @@ -75,6 +77,22 @@ public class User extends BaseEntity<Long> implements UserDetails { | |||||
| @Column | @Column | ||||
| private String remarks; | 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() { | public boolean isLocked() { | ||||
| return this.locked == null ? false : this.locked; | return this.locked == null ? false : this.locked; | ||||
| @@ -237,4 +255,20 @@ public class User extends BaseEntity<Long> implements UserDetails { | |||||
| this.department = department; | 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 jakarta.persistence.Table; | ||||
| import org.springframework.security.core.Authentication; | |||||
| @Service | @Service | ||||
| public class UserService extends AbstractBaseEntityService<User, Long, UserRepository> { | public class UserService extends AbstractBaseEntityService<User, Long, UserRepository> { | ||||
| private static final String USER_AUTH_SQL = "SELECT a.authority" | 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)); | 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()); | |||||
| } | |||||
| } | } | ||||