vluk@2fi-solutions.com.hk 2 часов назад
Родитель
Сommit
d996f2e6fc
8 измененных файлов: 245 добавлений и 29 удалений
  1. +13
    -1
      build.gradle
  2. +41
    -0
      src/main/java/com/ffii/lioner/config/TotpConfig.java
  3. +15
    -17
      src/main/java/com/ffii/lioner/config/WebConfig.java
  4. +8
    -4
      src/main/java/com/ffii/lioner/config/security/SecurityConfig.java
  5. +14
    -7
      src/main/java/com/ffii/lioner/config/security/jwt/web/JwtAuthenticationController.java
  6. +104
    -0
      src/main/java/com/ffii/lioner/modules/lioner/web/TwoFactorController.java
  7. +34
    -0
      src/main/java/com/ffii/lioner/modules/user/entity/User.java
  8. +16
    -0
      src/main/java/com/ffii/lioner/modules/user/service/UserService.java

+ 13
- 1
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'


+ 41
- 0
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;
}
}

+ 15
- 17
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() {


+ 8
- 4
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


+ 14
- 7
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<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()));



+ 104
- 0
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<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));
}
}

+ 34
- 0
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<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;
}

}

+ 16
- 0
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<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());
}

}

Загрузка…
Отмена
Сохранить