feat(auth): OTP 2FA 백+프론트
This commit is contained in:
parent
81e3b6ebef
commit
c9c5d43c49
@ -26,6 +26,8 @@
|
||||
<dependency><groupId>com.fasterxml.jackson.core</groupId><artifactId>jackson-databind</artifactId></dependency>
|
||||
<!-- 로컬 임베디드 AI 학습 저장소(DuckDB) — 단일 의존성, /opt/guardia-fa/data/fa_learning.duckdb -->
|
||||
<dependency><groupId>org.duckdb</groupId><artifactId>duckdb_jdbc</artifactId><version>1.1.3</version></dependency>
|
||||
<!-- TOTP(RFC 6238) 2차 인증 — UIMS/OCR 동일 좌표. QR(otpauth) + 6자리 SHA1/30s -->
|
||||
<dependency><groupId>dev.samstevens.totp</groupId><artifactId>totp</artifactId><version>1.7.1</version></dependency>
|
||||
</dependencies>
|
||||
<build>
|
||||
<plugins>
|
||||
|
||||
@ -49,6 +49,42 @@ public class JwtUtil {
|
||||
}
|
||||
}
|
||||
|
||||
// ── OTP 2차 인증(verify-token) — 1차 로그인 성공 후 2단계 전용 단기 토큰 ─────────────
|
||||
// 기존 access 토큰(generateToken)과 분리(purpose=2fa) — 2단계 미완료 토큰으로 API 접근 차단.
|
||||
|
||||
/** 1차 통과 후 발급하는 단기 verify-token(2단계 전용). role 미포함(권한 없음). */
|
||||
public String generateVerifyToken(String username, long validitySeconds) {
|
||||
return Jwts.builder()
|
||||
.subject(username)
|
||||
.claim("purpose", "2fa")
|
||||
.issuedAt(new Date())
|
||||
.expiration(new Date(System.currentTimeMillis() + validitySeconds * 1000L))
|
||||
.signWith(getKey())
|
||||
.compact();
|
||||
}
|
||||
|
||||
/** 이 토큰이 verify-token(purpose=2fa)인지 여부. access 토큰 검증 시 거부 용도. */
|
||||
public boolean isVerifyToken(String token) {
|
||||
try {
|
||||
return "2fa".equals(getClaims(token).get("purpose", String.class));
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** verify-token 이면 subject(username) 반환, 아니면/무효면 null. 서명·만료 검증 포함. */
|
||||
public String parseVerifyTokenUsername(String token) {
|
||||
try {
|
||||
Claims c = getClaims(token);
|
||||
if (!"2fa".equals(c.get("purpose", String.class))) {
|
||||
return null;
|
||||
}
|
||||
return c.getSubject();
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private Claims getClaims(String token) {
|
||||
return Jwts.parser()
|
||||
.verifyWith(getKey())
|
||||
|
||||
187
backend/src/main/java/com/zioinfo/fa/auth/OtpAuthService.java
Normal file
187
backend/src/main/java/com/zioinfo/fa/auth/OtpAuthService.java
Normal file
@ -0,0 +1,187 @@
|
||||
package com.zioinfo.fa.auth;
|
||||
|
||||
import com.zioinfo.fa.auth.dto.OtpSetupResponse;
|
||||
import com.zioinfo.fa.common.audit.AuditService;
|
||||
import com.zioinfo.fa.domain.FaUser;
|
||||
import com.zioinfo.fa.mapper.FaUserMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* TOTP(OTP 2차 인증) 오케스트레이션 레이어 — UIMS AuthService 의 OTP 경로 미러(FA 경량화). [GUARDiA-FA]
|
||||
*
|
||||
* <p>흐름:
|
||||
* <ol>
|
||||
* <li>1차 로그인 성공 → {@link #beginOtp}: verify-token 발급 + (미등록이면 보류 시크릿+QR) 반환.</li>
|
||||
* <li>{@code POST /verify-otp}(verifyToken+code) → {@link #verifyOtp}: 6자리 검증 후 access 발급.
|
||||
* 최초 로그인이면 등록 확정(otp_enabled=true).</li>
|
||||
* <li>마이페이지: {@link #setup}/{@link #confirm}/{@link #disable} · 관리자 초기화: {@link #adminReset}.</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>보안 불변: 시크릿·QR·otpauth URI 는 setup/OTP_SETUP 응답에서만 노출. 로그·감사에 시크릿/코드 미기록.
|
||||
* 기존 auth(JWT·RBAC) 엔진 교체 없음 — TOTP·잠금 레이어만 추가.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class OtpAuthService {
|
||||
|
||||
private final FaUserMapper userMapper;
|
||||
private final JwtUtil jwtUtil;
|
||||
private final TotpService totpService;
|
||||
private final AuditService auditService;
|
||||
|
||||
/** 로그인 2단계에 OTP 경로를 사용할지(기본 on — 인증 강화). */
|
||||
@Value("${fa.auth.otp-enabled:true}")
|
||||
private boolean otpEnabled;
|
||||
|
||||
/** verify-token 유효기간(초). */
|
||||
@Value("${fa.auth.verify-token-validity-seconds:300}")
|
||||
private long verifyTokenValiditySeconds;
|
||||
|
||||
/** 로그인/OTP 연속 실패 임계(도달 시 잠금). */
|
||||
@Value("${fa.auth.max-login-fail:5}")
|
||||
private int maxLoginFail;
|
||||
|
||||
public boolean isEnabled() {
|
||||
return otpEnabled;
|
||||
}
|
||||
|
||||
public int getMaxLoginFail() {
|
||||
return maxLoginFail;
|
||||
}
|
||||
|
||||
public boolean isLocked(FaUser user) {
|
||||
return user != null && Boolean.TRUE.equals(user.getLocked());
|
||||
}
|
||||
|
||||
private static boolean blank(String s) {
|
||||
return s == null || s.isBlank();
|
||||
}
|
||||
|
||||
/**
|
||||
* 1차 로그인 성공 후 OTP 2단계 시작.
|
||||
* @return 미등록: { twofa:true, verifyToken, verifyMethod:OTP_SETUP, secret, otpAuthUri, qrImage }
|
||||
* 등록됨: { twofa:true, verifyToken, verifyMethod:OTP }
|
||||
*/
|
||||
@Transactional
|
||||
public Map<String, String> beginOtp(FaUser user) {
|
||||
String verifyToken = jwtUtil.generateVerifyToken(user.getUsername(), verifyTokenValiditySeconds);
|
||||
userMapper.resetLoginFail(user.getUsername()); // 1차 성공 → 실패카운트 회복
|
||||
|
||||
Map<String, String> resp = new LinkedHashMap<>();
|
||||
resp.put("twofa", "true");
|
||||
resp.put("verifyToken", verifyToken);
|
||||
|
||||
if (blank(user.getOtpSecret())) {
|
||||
// 미등록 최초 로그인 — 보류 시크릿 발급 + QR(이 응답에서만 노출)
|
||||
String secret = totpService.generateSecret();
|
||||
userMapper.updateOtpSecret(user.getUsername(), secret);
|
||||
resp.put("verifyMethod", "OTP_SETUP");
|
||||
resp.put("secret", secret);
|
||||
resp.put("otpAuthUri", totpService.otpAuthUri(secret, user.getUsername()));
|
||||
resp.put("qrImage", totpService.qrImageDataUri(secret, user.getUsername()));
|
||||
} else {
|
||||
resp.put("verifyMethod", "OTP");
|
||||
}
|
||||
return resp;
|
||||
}
|
||||
|
||||
/**
|
||||
* 2단계 검증: verify-token + 6자리 코드 → access 발급. 최초 로그인이면 등록 확정.
|
||||
* @return { twofa:false, token, type, username, role, workstationCode }
|
||||
*/
|
||||
@Transactional
|
||||
public Map<String, Object> verifyOtp(String verifyToken, String code) {
|
||||
String username = jwtUtil.parseVerifyTokenUsername(verifyToken);
|
||||
if (username == null) {
|
||||
throw new RuntimeException("ERR-AUTH-VERIFY-TOKEN: 인증 토큰이 유효하지 않거나 만료되었습니다.");
|
||||
}
|
||||
FaUser user = userMapper.findByUsername(username);
|
||||
if (user == null) {
|
||||
throw new RuntimeException("ERR-AUTH-VERIFY-TOKEN: 인증 토큰이 유효하지 않습니다.");
|
||||
}
|
||||
if (Boolean.TRUE.equals(user.getLocked())) {
|
||||
throw new RuntimeException("ERR-AUTH-LOCKED: 계정이 잠금 상태입니다. 관리자에게 문의하세요.");
|
||||
}
|
||||
if (!totpService.verify(user.getOtpSecret(), code)) {
|
||||
// OTP 오입력도 실패 카운트에 합산 — 임계 도달 시 잠금.
|
||||
userMapper.incrementLoginFail(username, maxLoginFail);
|
||||
throw new RuntimeException("ERR-AUTH-OTP: 인증 코드가 올바르지 않습니다.");
|
||||
}
|
||||
|
||||
// 최초 로그인(보류 시크릿) → 등록 확정(멱등)
|
||||
if (!Boolean.TRUE.equals(user.getOtpEnabled())) {
|
||||
userMapper.enableOtp(username);
|
||||
auditService.log(username, "OTP_ENROLL", username, "최초 로그인 OTP 등록 확정");
|
||||
}
|
||||
userMapper.resetLoginFail(username);
|
||||
|
||||
String token = jwtUtil.generateToken(user.getUsername(), user.getRole());
|
||||
Map<String, Object> resp = new LinkedHashMap<>();
|
||||
resp.put("twofa", "false");
|
||||
resp.put("token", token);
|
||||
resp.put("type", "Bearer");
|
||||
resp.put("username", user.getUsername());
|
||||
resp.put("role", user.getRole());
|
||||
resp.put("workstationCode", user.getWorkstationCode());
|
||||
return resp;
|
||||
}
|
||||
|
||||
/** 마이페이지 OTP 등록/재설정 시작: 새 보류 시크릿 발급(기존 시크릿 무효화) + QR 반환. */
|
||||
@Transactional
|
||||
public OtpSetupResponse setup(String username) {
|
||||
FaUser user = userMapper.findByUsername(username);
|
||||
if (user == null) {
|
||||
throw new RuntimeException("ERR-AUTH-USER: 사용자를 찾을 수 없습니다.");
|
||||
}
|
||||
String secret = totpService.generateSecret();
|
||||
userMapper.updateOtpSecret(username, secret); // 확정 전엔 otp_enabled 유지(confirm 에서 재확정)
|
||||
auditService.log(username, "OTP_SETUP", username, "OTP 등록/재설정 시작(새 시크릿 발급)");
|
||||
return new OtpSetupResponse(
|
||||
secret,
|
||||
totpService.otpAuthUri(secret, username),
|
||||
totpService.qrImageDataUri(secret, username));
|
||||
}
|
||||
|
||||
/** 마이페이지 OTP 확인·활성화: 앱 코드 검증 성공 시 otp_enabled=true. */
|
||||
@Transactional
|
||||
public void confirm(String username, String code) {
|
||||
FaUser user = userMapper.findByUsername(username);
|
||||
if (user == null) {
|
||||
throw new RuntimeException("ERR-AUTH-USER: 사용자를 찾을 수 없습니다.");
|
||||
}
|
||||
if (blank(user.getOtpSecret())) {
|
||||
throw new RuntimeException("ERR-AUTH-OTP: 등록된 OTP 시크릿이 없습니다. 먼저 등록을 시작하세요.");
|
||||
}
|
||||
if (!totpService.verify(user.getOtpSecret(), code)) {
|
||||
throw new RuntimeException("ERR-AUTH-OTP: 인증 코드가 올바르지 않습니다.");
|
||||
}
|
||||
userMapper.enableOtp(username);
|
||||
auditService.log(username, "OTP_ENABLE", username, "OTP 2차 인증 활성화");
|
||||
}
|
||||
|
||||
/** 마이페이지 OTP 해제: 시크릿 폐기 + otp_enabled=false. */
|
||||
@Transactional
|
||||
public void disable(String username) {
|
||||
FaUser user = userMapper.findByUsername(username);
|
||||
if (user == null) {
|
||||
throw new RuntimeException("ERR-AUTH-USER: 사용자를 찾을 수 없습니다.");
|
||||
}
|
||||
userMapper.disableOtp(username);
|
||||
auditService.log(username, "OTP_DISABLE", username, "OTP 2차 인증 해제");
|
||||
}
|
||||
|
||||
/** 관리자 OTP 초기화: 시크릿 폐기 + otp_enabled=false → 다음 로그인 시 QR 재등록 유도. */
|
||||
@Transactional
|
||||
public void adminReset(String username) {
|
||||
userMapper.disableOtp(username);
|
||||
auditService.log("OTP_RESET", username, "관리자 OTP 초기화(재등록 유도)");
|
||||
}
|
||||
}
|
||||
81
backend/src/main/java/com/zioinfo/fa/auth/TotpService.java
Normal file
81
backend/src/main/java/com/zioinfo/fa/auth/TotpService.java
Normal file
@ -0,0 +1,81 @@
|
||||
package com.zioinfo.fa.auth;
|
||||
|
||||
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.code.HashingAlgorithm;
|
||||
import dev.samstevens.totp.exceptions.QrGenerationException;
|
||||
import dev.samstevens.totp.qr.QrData;
|
||||
import dev.samstevens.totp.qr.QrGenerator;
|
||||
import dev.samstevens.totp.qr.ZxingPngQrGenerator;
|
||||
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 dev.samstevens.totp.util.Utils;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* TOTP(RFC 6238, HMAC-SHA1, 30s, 6자리, ±1 윈도우) 코덱 — UIMS TotpService 미러. [GUARDiA-FA]
|
||||
* 시크릿(otp_secret)은 fa_users 에 저장. issuer 만 GUARDiA-FA 로 교체.
|
||||
*
|
||||
* <p>보안 불변: 시크릿·otpauth URI·QR 은 등록 순간 발급 응답에서만 노출. 로그 기록 금지.
|
||||
*/
|
||||
@Service
|
||||
public class TotpService {
|
||||
|
||||
private final TimeProvider timeProvider = new SystemTimeProvider();
|
||||
private final CodeGenerator codeGenerator = new DefaultCodeGenerator(HashingAlgorithm.SHA1, 6);
|
||||
private final SecretGenerator secretGenerator = new DefaultSecretGenerator();
|
||||
private final CodeVerifier codeVerifier = buildVerifier();
|
||||
private final QrGenerator qrGenerator = new ZxingPngQrGenerator();
|
||||
|
||||
private static final String ISSUER = "GUARDiA-FA";
|
||||
|
||||
private CodeVerifier buildVerifier() {
|
||||
DefaultCodeVerifier verifier = new DefaultCodeVerifier(codeGenerator, timeProvider);
|
||||
verifier.setTimePeriod(30);
|
||||
verifier.setAllowedTimePeriodDiscrepancy(1); // ±1 윈도우 허용(시계 오차)
|
||||
return verifier;
|
||||
}
|
||||
|
||||
/** 신규 OTP 시크릿 생성(최초 로그인/마이페이지 OTP 등록용). */
|
||||
public String generateSecret() {
|
||||
return secretGenerator.generate();
|
||||
}
|
||||
|
||||
/** 주어진 시크릿에 대해 사용자 입력 코드가 유효한지 검증. */
|
||||
public boolean verify(String secret, String code) {
|
||||
if (secret == null || secret.isBlank() || code == null || code.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
return codeVerifier.isValidCode(secret, code.trim());
|
||||
}
|
||||
|
||||
/** Authenticator 앱 직접 등록용 otpauth:// URI. */
|
||||
public String otpAuthUri(String secret, String userId) {
|
||||
return buildQrData(secret, userId).getUri();
|
||||
}
|
||||
|
||||
/** QR 이미지(data:image/png;base64,...) — 앱으로 스캔해 등록. */
|
||||
public String qrImageDataUri(String secret, String userId) {
|
||||
try {
|
||||
byte[] image = qrGenerator.generate(buildQrData(secret, userId));
|
||||
return Utils.getDataUriForImage(image, qrGenerator.getImageMimeType());
|
||||
} catch (QrGenerationException e) {
|
||||
throw new IllegalStateException("OTP QR 생성 실패", e);
|
||||
}
|
||||
}
|
||||
|
||||
private QrData buildQrData(String secret, String userId) {
|
||||
return new QrData.Builder()
|
||||
.label(userId)
|
||||
.secret(secret)
|
||||
.issuer(ISSUER)
|
||||
.algorithm(HashingAlgorithm.SHA1)
|
||||
.digits(6)
|
||||
.period(30)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
package com.zioinfo.fa.auth.dto;
|
||||
|
||||
/** 마이페이지 비밀번호 변경 요청 — { currentPassword, newPassword }. 평문은 응답/로그 미기록. [GUARDiA-FA] */
|
||||
public record ChangePasswordRequest(String currentPassword, String newPassword) {
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
package com.zioinfo.fa.auth.dto;
|
||||
|
||||
/** Authenticator(TOTP) 등록 확인 요청 — 앱에 표시된 6자리 코드. [GUARDiA-FA] */
|
||||
public record OtpConfirmRequest(String code) {
|
||||
}
|
||||
@ -0,0 +1,13 @@
|
||||
package com.zioinfo.fa.auth.dto;
|
||||
|
||||
/**
|
||||
* Authenticator(TOTP) 등록 셋업 응답 — UIMS/OCR OtpSetupResponse 미러. [GUARDiA-FA]
|
||||
* <ul>
|
||||
* <li>secret : Base32 TOTP 시크릿(수동 입력용)</li>
|
||||
* <li>otpAuthUri : otpauth://totp/... (Authenticator 앱 직접 등록용 URI)</li>
|
||||
* <li>qrImage : data:image/png;base64,... (QR 이미지, <img src> 로 표시)</li>
|
||||
* </ul>
|
||||
* 보안 불변: 이 응답(등록 순간)에서만 시크릿/QR 노출. 조회/목록/재조회 응답에 재노출 금지·로그 미기록.
|
||||
*/
|
||||
public record OtpSetupResponse(String secret, String otpAuthUri, String qrImage) {
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
package com.zioinfo.fa.auth.dto;
|
||||
|
||||
/** 로그인 2단계 TOTP 검증 요청 — { verifyToken, code }. [GUARDiA-FA] */
|
||||
public record OtpVerifyRequest(String verifyToken, String code) {
|
||||
}
|
||||
@ -0,0 +1,131 @@
|
||||
package com.zioinfo.fa.config;
|
||||
|
||||
import com.zioinfo.fa.mapper.FaUserMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.ApplicationArguments;
|
||||
import org.springframework.boot.ApplicationRunner;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.spec.GCMParameterSpec;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Arrays;
|
||||
import java.util.Base64;
|
||||
|
||||
/**
|
||||
* admin 비밀번호 암호화 재시드(UIMS 방식 + 암호화 저장). [GUARDiA-FA]
|
||||
*
|
||||
* <p>기동 시 멱등 재시드:
|
||||
* <pre>
|
||||
* ADMIN_KEY_FILE(hex 32바이트 키 파일) 로드 → ADMIN_PASSWORD_ENC(base64(nonce12 + ct + tag), AES-256-GCM) 복호
|
||||
* → admin 계정 BCrypt 해시 갱신(하드코딩 admin123 시드를 env 값으로 덮어씀).
|
||||
* </pre>
|
||||
*
|
||||
* <p>안전 규칙:
|
||||
* <ul>
|
||||
* <li>env 미설정 / 키 로드 실패 / 복호 실패 시 <b>재시드 스킵</b>(기동 계속). WARN 로그에 <b>값(평문/키/시크릿) 미기록</b>.</li>
|
||||
* <li>새 admin 비밀번호 값은 서버 env 에만 존재 — 코드/DB/로그/응답에 평문 미기재(보안 불변규칙).</li>
|
||||
* <li>키 값은 별도 파일(root 600)에만 — env/코드/git 에 키 미기재. 여기서는 파일 경로만 읽는다.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>스키마 init(schema.sql: admin/admin123 시드) 이후 실행되어 기존 해시를 덮어쓴다(멱등: 매 기동 동일 결과).
|
||||
* env 는 서버 {@code /opt/guardia-fa/guardia-ai.env}(systemd EnvironmentFile)로만 주입.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@Order(Integer.MIN_VALUE + 10)
|
||||
@RequiredArgsConstructor
|
||||
public class AdminPasswordSeeder implements ApplicationRunner {
|
||||
|
||||
private static final String ADMIN_USERNAME = "admin";
|
||||
private static final int GCM_TAG_BITS = 128;
|
||||
private static final int NONCE_LEN = 12;
|
||||
|
||||
@Value("${ADMIN_PASSWORD_ENC:}")
|
||||
private String adminPasswordEnc;
|
||||
|
||||
@Value("${ADMIN_KEY_FILE:}")
|
||||
private String adminKeyFile;
|
||||
|
||||
private final FaUserMapper userMapper;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
|
||||
@Override
|
||||
public void run(ApplicationArguments args) {
|
||||
if (isBlank(adminPasswordEnc) || isBlank(adminKeyFile)) {
|
||||
log.info("[admin-reseed] ADMIN_PASSWORD_ENC/ADMIN_KEY_FILE 미설정 — 재시드 스킵(기동 계속)");
|
||||
return;
|
||||
}
|
||||
char[] plain = null;
|
||||
byte[] key = null;
|
||||
try {
|
||||
key = loadHexKey(adminKeyFile); // hex 32바이트 → 32B (AES-256)
|
||||
plain = decrypt(adminPasswordEnc, key); // base64(nonce12+ct+tag) → 평문
|
||||
String hash = passwordEncoder.encode(new String(plain));
|
||||
int updated = userMapper.updatePasswordByUsername(ADMIN_USERNAME, hash);
|
||||
if (updated > 0) {
|
||||
log.info("[admin-reseed] admin 비밀번호 env 값으로 재시드 완료(멱등)");
|
||||
} else {
|
||||
log.warn("[admin-reseed] admin 계정 미존재 — 재시드 스킵");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// 값 미기록: 예외 클래스명만(메시지에 평문/키가 섞일 여지 차단)
|
||||
log.warn("[admin-reseed] 복호/재시드 실패 — 스킵(기동 계속). cause={}", e.getClass().getSimpleName());
|
||||
} finally {
|
||||
if (plain != null) Arrays.fill(plain, '\0');
|
||||
if (key != null) Arrays.fill(key, (byte) 0);
|
||||
}
|
||||
}
|
||||
|
||||
/** 키 파일(hex 문자열, 공백/개행 무시)을 32바이트 키로 로드. 32바이트가 아니면 예외. */
|
||||
private static byte[] loadHexKey(String path) throws Exception {
|
||||
String hex = Files.readString(Path.of(path), StandardCharsets.UTF_8)
|
||||
.replaceAll("\\s", "");
|
||||
byte[] key = hexToBytes(hex);
|
||||
if (key.length != 32) {
|
||||
throw new IllegalStateException("key length != 32 bytes");
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
/** base64(nonce12 + ciphertext + tag) → AES-256-GCM 복호 평문(char[]). */
|
||||
private static char[] decrypt(String encBase64, byte[] key) throws Exception {
|
||||
byte[] blob = Base64.getDecoder().decode(encBase64.trim());
|
||||
if (blob.length <= NONCE_LEN) {
|
||||
throw new IllegalStateException("cipher blob too short");
|
||||
}
|
||||
byte[] nonce = Arrays.copyOfRange(blob, 0, NONCE_LEN);
|
||||
byte[] ct = Arrays.copyOfRange(blob, NONCE_LEN, blob.length);
|
||||
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
|
||||
cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(key, "AES"),
|
||||
new GCMParameterSpec(GCM_TAG_BITS, nonce));
|
||||
byte[] out = cipher.doFinal(ct);
|
||||
char[] chars = new String(out, StandardCharsets.UTF_8).toCharArray();
|
||||
Arrays.fill(out, (byte) 0);
|
||||
return chars;
|
||||
}
|
||||
|
||||
private static byte[] hexToBytes(String hex) {
|
||||
int len = hex.length();
|
||||
if (len % 2 != 0) {
|
||||
throw new IllegalStateException("odd hex length");
|
||||
}
|
||||
byte[] out = new byte[len / 2];
|
||||
for (int i = 0; i < len; i += 2) {
|
||||
out[i / 2] = (byte) ((Character.digit(hex.charAt(i), 16) << 4)
|
||||
+ Character.digit(hex.charAt(i + 1), 16));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static boolean isBlank(String s) {
|
||||
return s == null || s.isBlank();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,61 @@
|
||||
package com.zioinfo.fa.controller;
|
||||
|
||||
import com.zioinfo.fa.auth.OtpAuthService;
|
||||
import com.zioinfo.fa.common.ApiResponse;
|
||||
import com.zioinfo.fa.common.audit.AuditService;
|
||||
import com.zioinfo.fa.domain.FaUser;
|
||||
import com.zioinfo.fa.mapper.FaUserMapper;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 관리자 사용자 관리 — OTP 초기화 / 계정 잠금 해제. [GUARDiA-FA]
|
||||
*
|
||||
* <p>SecurityConfig {@code /api/admin/** hasRole(ADMIN)} 게이트(별도 설정 변경 불요).
|
||||
* verify-token 은 role 미포함 → ROLE_ADMIN 부재로 자동 차단. 응답에 otp_secret/비번 해시 미노출.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/users")
|
||||
public class AdminUserController {
|
||||
|
||||
private final FaUserMapper userMapper;
|
||||
private final OtpAuthService otpAuthService;
|
||||
private final AuditService auditService;
|
||||
|
||||
public AdminUserController(FaUserMapper userMapper, OtpAuthService otpAuthService, AuditService auditService) {
|
||||
this.userMapper = userMapper;
|
||||
this.otpAuthService = otpAuthService;
|
||||
this.auditService = auditService;
|
||||
}
|
||||
|
||||
/** 사용자 목록(관리 화면). findAll 은 otp_secret/password_hash 를 SELECT 하지 않는다(미노출). */
|
||||
@GetMapping
|
||||
public ApiResponse<List<FaUser>> list() {
|
||||
return ApiResponse.ok(userMapper.findAll());
|
||||
}
|
||||
|
||||
/** 관리자 OTP 초기화 — OTP_SECRET NULL → 다음 로그인 시 QR 재등록 유도. */
|
||||
@PostMapping("/{id}/otp-reset")
|
||||
public ApiResponse<Map<String, String>> otpReset(@PathVariable Long id) {
|
||||
FaUser user = userMapper.findById(id);
|
||||
if (user == null) {
|
||||
throw new IllegalArgumentException("사용자를 찾을 수 없습니다.");
|
||||
}
|
||||
otpAuthService.adminReset(user.getUsername());
|
||||
return ApiResponse.ok(Map.of("result", "ok"));
|
||||
}
|
||||
|
||||
/** 관리자 계정 잠금 해제(실패 카운트/잠금 초기화). */
|
||||
@PostMapping("/{id}/unlock")
|
||||
public ApiResponse<Map<String, String>> unlock(@PathVariable Long id) {
|
||||
FaUser user = userMapper.findById(id);
|
||||
if (user == null) {
|
||||
throw new IllegalArgumentException("사용자를 찾을 수 없습니다.");
|
||||
}
|
||||
userMapper.unlock(user.getUsername());
|
||||
auditService.log("USER_UNLOCK", user.getUsername(), "관리자 계정 잠금 해제");
|
||||
return ApiResponse.ok(Map.of("result", "ok"));
|
||||
}
|
||||
}
|
||||
@ -1,27 +1,131 @@
|
||||
package com.zioinfo.fa.controller;
|
||||
|
||||
import com.zioinfo.fa.auth.JwtUtil;
|
||||
import com.zioinfo.fa.auth.OtpAuthService;
|
||||
import com.zioinfo.fa.auth.dto.ChangePasswordRequest;
|
||||
import com.zioinfo.fa.auth.dto.OtpConfirmRequest;
|
||||
import com.zioinfo.fa.auth.dto.OtpSetupResponse;
|
||||
import com.zioinfo.fa.auth.dto.OtpVerifyRequest;
|
||||
import com.zioinfo.fa.service.AuthService;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* FA 인증 컨트롤러. [GUARDiA-FA]
|
||||
* <ul>
|
||||
* <li>/login: OTP off → { twofa:"false", token, ... }, OTP on → { twofa:"true", verifyToken, verifyMethod, ... }.</li>
|
||||
* <li>/verify-otp: verify-token + 6자리 코드 → access 발급(최초 로그인이면 등록 확정).</li>
|
||||
* <li>마이페이지(access 토큰 필요): /otp/setup·/otp/confirm·/otp/disable·/change-password.</li>
|
||||
* </ul>
|
||||
* 기존 클라이언트(OTP off) 응답 token 보존 → 회귀 0. /api/fa/auth/** 는 permitAll(access 토큰은 여기서 명시 검증).
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/fa/auth")
|
||||
public class AuthController {
|
||||
private final AuthService authService;
|
||||
|
||||
public AuthController(AuthService authService) {
|
||||
private final AuthService authService;
|
||||
private final OtpAuthService otpAuthService;
|
||||
private final JwtUtil jwtUtil;
|
||||
|
||||
public AuthController(AuthService authService, OtpAuthService otpAuthService, JwtUtil jwtUtil) {
|
||||
this.authService = authService;
|
||||
this.otpAuthService = otpAuthService;
|
||||
this.jwtUtil = jwtUtil;
|
||||
}
|
||||
|
||||
@PostMapping("/login")
|
||||
public ResponseEntity<Map<String, Object>> login(@RequestBody Map<String, String> req) {
|
||||
try {
|
||||
Map<String, Object> result = authService.login(req.get("username"), req.get("password"));
|
||||
return ResponseEntity.ok(result);
|
||||
return ResponseEntity.ok(authService.login(req.get("username"), req.get("password")));
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.status(401).body(Map.of("error", e.getMessage()));
|
||||
return ResponseEntity.status(401).body(Map.of("error", safe(e)));
|
||||
}
|
||||
}
|
||||
|
||||
/** 로그인 2단계 TOTP 검증 → access 발급. */
|
||||
@PostMapping("/verify-otp")
|
||||
public ResponseEntity<Map<String, Object>> verifyOtp(@RequestBody OtpVerifyRequest req) {
|
||||
try {
|
||||
return ResponseEntity.ok(otpAuthService.verifyOtp(req.verifyToken(), req.code()));
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.status(401).body(Map.of("error", safe(e)));
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/me")
|
||||
public ResponseEntity<Map<String, Object>> me(@RequestHeader("Authorization") String header) {
|
||||
String token = header == null ? "" : header.replace("Bearer ", "").trim();
|
||||
return ResponseEntity.ok(authService.me(token));
|
||||
}
|
||||
|
||||
// ── 마이페이지: OTP 등록/재설정/해제 + 비밀번호 변경 (본인, access 토큰 필요) ──────────
|
||||
|
||||
/** 마이페이지 OTP 등록/재설정 시작 → { secret, otpAuthUri, qrImage }(이 응답에서만 시크릿/QR 노출). */
|
||||
@PostMapping("/otp/setup")
|
||||
public ResponseEntity<?> otpSetup(@RequestHeader(value = "Authorization", required = false) String header) {
|
||||
try {
|
||||
OtpSetupResponse resp = otpAuthService.setup(requireUser(header));
|
||||
return ResponseEntity.ok(resp);
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.status(400).body(Map.of("error", safe(e)));
|
||||
}
|
||||
}
|
||||
|
||||
/** 마이페이지 OTP 확인·활성화(앱 6자리 코드). */
|
||||
@PostMapping("/otp/confirm")
|
||||
public ResponseEntity<Map<String, String>> otpConfirm(
|
||||
@RequestHeader(value = "Authorization", required = false) String header,
|
||||
@RequestBody OtpConfirmRequest req) {
|
||||
try {
|
||||
otpAuthService.confirm(requireUser(header), req.code());
|
||||
return ResponseEntity.ok(Map.of("result", "ok"));
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.status(400).body(Map.of("error", safe(e)));
|
||||
}
|
||||
}
|
||||
|
||||
/** 마이페이지 OTP 해제. */
|
||||
@PostMapping("/otp/disable")
|
||||
public ResponseEntity<Map<String, String>> otpDisable(
|
||||
@RequestHeader(value = "Authorization", required = false) String header) {
|
||||
try {
|
||||
otpAuthService.disable(requireUser(header));
|
||||
return ResponseEntity.ok(Map.of("result", "ok"));
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.status(400).body(Map.of("error", safe(e)));
|
||||
}
|
||||
}
|
||||
|
||||
/** 마이페이지 비밀번호 변경(현재 비번 검증 + BCrypt). 새 비번은 응답/로그 미포함. */
|
||||
@PostMapping("/change-password")
|
||||
public ResponseEntity<Map<String, String>> changePassword(
|
||||
@RequestHeader(value = "Authorization", required = false) String header,
|
||||
@RequestBody ChangePasswordRequest req) {
|
||||
try {
|
||||
authService.changePassword(requireUser(header), req.currentPassword(), req.newPassword());
|
||||
return ResponseEntity.ok(Map.of("result", "ok"));
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.status(400).body(Map.of("error", safe(e)));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorization 헤더에서 인증 사용자를 해석한다. verify-token(2fa 단계용)은 거부.
|
||||
* (/api/fa/auth/** 는 permitAll 이라 필터 인증에 의존하지 않고 여기서 access 토큰을 명시 검증.)
|
||||
*/
|
||||
private String requireUser(String header) {
|
||||
String token = header == null ? "" : header.replace("Bearer ", "").trim();
|
||||
if (token.isEmpty() || jwtUtil.isVerifyToken(token) || !jwtUtil.isValid(token)) {
|
||||
throw new RuntimeException("ERR-AUTH-UNAUTHORIZED: 인증이 필요합니다.");
|
||||
}
|
||||
return jwtUtil.extractUsername(token);
|
||||
}
|
||||
|
||||
/** 예외 메시지 안전화 — 스택트레이스/자격증명 미노출(요약 메시지만). */
|
||||
private static String safe(Exception e) {
|
||||
String m = e.getMessage();
|
||||
return (m == null || m.isBlank()) ? "요청 처리 중 오류가 발생했습니다." : m;
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,4 +13,14 @@ public class FaUser {
|
||||
private Boolean active;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
// ── 인증 강화(admin 재시드 + OTP 2FA) 이식 컬럼 (fa_users ALTER, db/93_auth_otp.sql) ──
|
||||
/** TOTP 시크릿(등록 확정 전 보류 시크릿도 여기 저장). API 응답·로그에 절대 미노출. */
|
||||
private String otpSecret;
|
||||
/** OTP 등록 확정 여부(기본 false). 최초 로그인 verify 성공 시 true 로 확정(멱등). */
|
||||
private Boolean otpEnabled;
|
||||
/** 로그인 실패 누적 횟수(기본 0). max-login-fail 도달 시 locked. */
|
||||
private Integer loginFailCount;
|
||||
/** 계정 잠금 여부(기본 false). 관리자 해제 필요. */
|
||||
private Boolean locked;
|
||||
}
|
||||
|
||||
@ -2,6 +2,8 @@ package com.zioinfo.fa.mapper;
|
||||
|
||||
import com.zioinfo.fa.domain.FaUser;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@ -14,4 +16,45 @@ public interface FaUserMapper {
|
||||
int update(FaUser u);
|
||||
int delete(Long id);
|
||||
int toggleActive(Long id, Boolean active);
|
||||
|
||||
// ── 인증 강화 이식: 비밀번호 재시드/변경 + 로그인 실패 잠금 + OTP (멱등 UPDATE) ──────
|
||||
// 어노테이션 정의(신규 statement id) — XML(FaUserMapper.xml)과 id 충돌 없음.
|
||||
|
||||
/** admin 재시드/비번변경/초기화용 BCrypt 해시 저장 + 잠금·실패카운트 해제. 평문 미저장. */
|
||||
@Update("""
|
||||
UPDATE fa_users
|
||||
SET password_hash = #{passwordHash}, locked = false, login_fail_count = 0, updated_at = NOW()
|
||||
WHERE username = #{username}
|
||||
""")
|
||||
int updatePasswordByUsername(@Param("username") String username,
|
||||
@Param("passwordHash") String passwordHash);
|
||||
|
||||
/** 로그인 성공 시 실패 카운트 초기화. */
|
||||
@Update("UPDATE fa_users SET login_fail_count = 0 WHERE username = #{username}")
|
||||
int resetLoginFail(@Param("username") String username);
|
||||
|
||||
/** 로그인/OTP 실패 누적(+1) 및 임계 도달 시 잠금. */
|
||||
@Update("""
|
||||
UPDATE fa_users
|
||||
SET login_fail_count = COALESCE(login_fail_count, 0) + 1,
|
||||
locked = (COALESCE(login_fail_count, 0) + 1 >= #{maxFail})
|
||||
WHERE username = #{username}
|
||||
""")
|
||||
int incrementLoginFail(@Param("username") String username, @Param("maxFail") int maxFail);
|
||||
|
||||
/** 관리자 잠금 해제(실패 카운트/잠금 초기화). */
|
||||
@Update("UPDATE fa_users SET locked = false, login_fail_count = 0 WHERE username = #{username}")
|
||||
int unlock(@Param("username") String username);
|
||||
|
||||
/** 보류 시크릿 저장(등록 확정 전). otp_enabled 은 verify/confirm 에서 확정. */
|
||||
@Update("UPDATE fa_users SET otp_secret = #{secret} WHERE username = #{username}")
|
||||
int updateOtpSecret(@Param("username") String username, @Param("secret") String secret);
|
||||
|
||||
/** 등록 확정: otp_enabled=true (시크릿 유지). */
|
||||
@Update("UPDATE fa_users SET otp_enabled = true WHERE username = #{username}")
|
||||
int enableOtp(@Param("username") String username);
|
||||
|
||||
/** 해제/초기화: 시크릿 폐기 + otp_enabled=false (마이페이지 해제 / 관리자 초기화). */
|
||||
@Update("UPDATE fa_users SET otp_secret = NULL, otp_enabled = false WHERE username = #{username}")
|
||||
int disableOtp(@Param("username") String username);
|
||||
}
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
package com.zioinfo.fa.service;
|
||||
|
||||
import com.zioinfo.fa.auth.JwtUtil;
|
||||
import com.zioinfo.fa.auth.OtpAuthService;
|
||||
import com.zioinfo.fa.common.audit.AuditService;
|
||||
import com.zioinfo.fa.domain.FaUser;
|
||||
import com.zioinfo.fa.mapper.FaUserMapper;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
@ -9,32 +11,103 @@ import org.springframework.stereotype.Service;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* FA 인증 서비스. [GUARDiA-FA]
|
||||
* <ul>
|
||||
* <li>기존 단일 JWT 로그인 보존(OTP off 시 회귀 0 — token/username/role/workstationCode 그대로).</li>
|
||||
* <li>OTP on(기본) 이면 1차 통과 후 verify-token + (미등록 시 QR) 분기 → {@code /verify-otp} 로 2단계.</li>
|
||||
* <li>비밀번호 실패 누적 max-login-fail 회 시 계정 잠금(관리자 해제).</li>
|
||||
* </ul>
|
||||
* 기존 JWT/RBAC 엔진 교체 없음 — 2FA·잠금 레이어만 추가.
|
||||
*/
|
||||
@Service
|
||||
public class AuthService {
|
||||
private final FaUserMapper userMapper;
|
||||
private final JwtUtil jwtUtil;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final OtpAuthService otpAuthService;
|
||||
private final AuditService auditService;
|
||||
|
||||
public AuthService(FaUserMapper userMapper, JwtUtil jwtUtil, PasswordEncoder passwordEncoder) {
|
||||
public AuthService(FaUserMapper userMapper, JwtUtil jwtUtil, PasswordEncoder passwordEncoder,
|
||||
OtpAuthService otpAuthService, AuditService auditService) {
|
||||
this.userMapper = userMapper;
|
||||
this.jwtUtil = jwtUtil;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
this.otpAuthService = otpAuthService;
|
||||
this.auditService = auditService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1차 로그인. OTP 활성 시 verify-token 흐름으로 분기, 비활성 시 기존처럼 access 토큰 즉시 발급.
|
||||
* @return OTP off: { twofa:"false", token, username, role, workstationCode }
|
||||
* OTP on : { twofa:"true", verifyToken, verifyMethod:(OTP|OTP_SETUP), secret?, otpAuthUri?, qrImage? }
|
||||
*/
|
||||
public Map<String, Object> login(String username, String password) {
|
||||
FaUser user = userMapper.findByUsername(username);
|
||||
|
||||
// 잠금 우선 차단(존재하는 계정에 한해 — 존재 여부 누설 최소화)
|
||||
if (otpAuthService.isLocked(user)) {
|
||||
throw new RuntimeException("ERR-AUTH-LOCKED: 계정이 잠금 상태입니다. 관리자에게 문의하세요.");
|
||||
}
|
||||
if (user == null || !Boolean.TRUE.equals(user.getActive())) {
|
||||
throw new RuntimeException("사용자를 찾을 수 없거나 비활성 상태입니다.");
|
||||
}
|
||||
if (!passwordEncoder.matches(password, user.getPasswordHash())) {
|
||||
// OTP(강화) 활성 시 실패 누적/잠금. 비활성 시 기존 동작(메시지만) 유지.
|
||||
if (otpAuthService.isEnabled()) {
|
||||
userMapper.incrementLoginFail(username, otpAuthService.getMaxLoginFail());
|
||||
FaUser after = userMapper.findByUsername(username);
|
||||
if (after != null && Boolean.TRUE.equals(after.getLocked())) {
|
||||
throw new RuntimeException("ERR-AUTH-LOCKED: 계정이 잠금 상태입니다. 관리자에게 문의하세요.");
|
||||
}
|
||||
}
|
||||
throw new RuntimeException("비밀번호가 일치하지 않습니다.");
|
||||
}
|
||||
|
||||
// 비밀번호 통과 — OTP 활성 시 2단계, 비활성 시 단일 로그인(회귀 0)
|
||||
if (otpAuthService.isEnabled()) {
|
||||
return new HashMap<>(otpAuthService.beginOtp(user));
|
||||
}
|
||||
|
||||
userMapper.resetLoginFail(username);
|
||||
String token = jwtUtil.generateToken(username, user.getRole());
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("twofa", "false");
|
||||
result.put("token", token);
|
||||
result.put("username", username);
|
||||
result.put("role", user.getRole());
|
||||
result.put("workstationCode", user.getWorkstationCode());
|
||||
return result;
|
||||
}
|
||||
|
||||
public Map<String, Object> me(String token) {
|
||||
String username = jwtUtil.extractUsername(token);
|
||||
String role = jwtUtil.extractRole(token);
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("username", username);
|
||||
result.put("role", role);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 마이페이지 비밀번호 변경(현재 비밀번호 검증 + BCrypt 저장).
|
||||
* 현재 비번 불일치/기존과 동일 시 거부. 새 비밀번호 평문은 로그/응답/감사에 절대 기록하지 않는다.
|
||||
*/
|
||||
public void changePassword(String username, String currentPassword, String newPassword) {
|
||||
if (currentPassword == null || newPassword == null || newPassword.isBlank()) {
|
||||
throw new RuntimeException("현재/새 비밀번호는 필수입니다.");
|
||||
}
|
||||
FaUser user = userMapper.findByUsername(username);
|
||||
if (user == null) {
|
||||
throw new RuntimeException("사용자를 찾을 수 없습니다.");
|
||||
}
|
||||
if (!passwordEncoder.matches(currentPassword, user.getPasswordHash())) {
|
||||
throw new RuntimeException("현재 비밀번호가 일치하지 않습니다.");
|
||||
}
|
||||
if (passwordEncoder.matches(newPassword, user.getPasswordHash())) {
|
||||
throw new RuntimeException("새 비밀번호가 기존과 동일합니다.");
|
||||
}
|
||||
userMapper.updatePasswordByUsername(username, passwordEncoder.encode(newPassword));
|
||||
auditService.log(username, "PASSWORD_CHANGE", username, "본인 비밀번호 변경");
|
||||
}
|
||||
}
|
||||
|
||||
@ -31,6 +31,12 @@ guardia:
|
||||
fa:
|
||||
learning:
|
||||
duckdb-path: /opt/guardia-fa/data/fa_learning.duckdb # 로컬 AI 학습·추론 저장소(솔루션 격리)
|
||||
auth:
|
||||
otp-enabled: true # OTP 2차 인증(로그인 2단계). false 면 기존 단일 JWT 로그인(회귀 0)
|
||||
verify-token-validity-seconds: 300 # 1차 통과 후 verify-token 유효기간(초)
|
||||
max-login-fail: 5 # 로그인/OTP 연속 실패 임계(도달 시 계정 잠금 — 관리자 해제)
|
||||
# admin 비밀번호 재시드(ADMIN_PASSWORD_ENC/ADMIN_KEY_FILE)와 ANTHROPIC_API_KEY 는
|
||||
# 서버 환경변수(systemd EnvironmentFile: /opt/guardia-fa/guardia-ai.env)로만 주입 — 여기에 절대 기재 금지.
|
||||
# ANTHROPIC_API_KEY 는 서버 환경변수(systemd EnvironmentFile)로만 주입 — 여기에 절대 기재 금지.
|
||||
logging:
|
||||
level:
|
||||
|
||||
16
backend/src/main/resources/db/93_auth_otp.sql
Normal file
16
backend/src/main/resources/db/93_auth_otp.sql
Normal file
@ -0,0 +1,16 @@
|
||||
-- =====================================================================
|
||||
-- 인증 강화(OTP 2차 인증 + 로그인 실패 잠금) — fa_users 멱등 ALTER [GUARDiA-FA]
|
||||
-- =====================================================================
|
||||
-- 기존 배포된 fa_db 에 컬럼을 추가한다(신규 설치는 schema.sql 에 이미 포함).
|
||||
-- 운영 적용(1회): psql -U fa_user -d fa_db -f db/93_auth_otp.sql
|
||||
-- 멱등: ADD COLUMN IF NOT EXISTS — 여러 번 실행 안전.
|
||||
|
||||
ALTER TABLE fa_users ADD COLUMN IF NOT EXISTS otp_secret VARCHAR(255);
|
||||
ALTER TABLE fa_users ADD COLUMN IF NOT EXISTS otp_enabled BOOLEAN DEFAULT FALSE;
|
||||
ALTER TABLE fa_users ADD COLUMN IF NOT EXISTS login_fail_count INT DEFAULT 0;
|
||||
ALTER TABLE fa_users ADD COLUMN IF NOT EXISTS locked BOOLEAN DEFAULT FALSE;
|
||||
|
||||
COMMENT ON COLUMN fa_users.otp_secret IS 'TOTP 시크릿(보류/확정 공용). API 응답·로그 미노출.';
|
||||
COMMENT ON COLUMN fa_users.otp_enabled IS 'OTP 등록 확정 여부(최초 로그인 verify 성공 시 true).';
|
||||
COMMENT ON COLUMN fa_users.login_fail_count IS '로그인/OTP 연속 실패 누적(임계 도달 시 locked).';
|
||||
COMMENT ON COLUMN fa_users.locked IS '계정 잠금 여부(관리자 해제 필요).';
|
||||
15
backend/src/main/resources/db/ops_otp_reset_all.sql
Normal file
15
backend/src/main/resources/db/ops_otp_reset_all.sql
Normal file
@ -0,0 +1,15 @@
|
||||
-- =====================================================================
|
||||
-- [1회 운영 작업] 전 사용자 OTP 초기화 — 멱등 [GUARDiA-FA]
|
||||
-- =====================================================================
|
||||
-- 목적: 인증 강화 전환 시점에 기존 사용자 OTP 를 초기화한다.
|
||||
-- 다음 로그인부터 OTP_SETUP(QR 재등록) 플로우를 타게 한다(UIMS 방식).
|
||||
--
|
||||
-- ★ 이 파일은 자동 적용하지 않는다(운영자가 서버에서 1회 수동 실행):
|
||||
-- psql -U fa_user -d fa_db -f db/ops_otp_reset_all.sql
|
||||
--
|
||||
-- 멱등: 여러 번 실행해도 결과 동일(모두 NULL/false).
|
||||
|
||||
UPDATE fa_users
|
||||
SET otp_secret = NULL,
|
||||
otp_enabled = false
|
||||
WHERE otp_secret IS NOT NULL OR otp_enabled IS DISTINCT FROM false;
|
||||
@ -217,6 +217,11 @@ CREATE TABLE IF NOT EXISTS fa_users (
|
||||
role VARCHAR(30) DEFAULT 'OPERATOR',
|
||||
workstation_code VARCHAR(50),
|
||||
active BOOLEAN DEFAULT TRUE,
|
||||
-- 인증 강화(OTP 2FA + 로그인 실패 잠금) 컬럼. API 응답·로그에 otp_secret 미노출.
|
||||
otp_secret VARCHAR(255),
|
||||
otp_enabled BOOLEAN DEFAULT FALSE,
|
||||
login_fail_count INT DEFAULT 0,
|
||||
locked BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
File diff suppressed because one or more lines are too long
351
backend/src/main/resources/static/assets/index-DGCZGcqo.js
Normal file
351
backend/src/main/resources/static/assets/index-DGCZGcqo.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
BIN
backend/src/main/resources/static/favicon.ico
Normal file
BIN
backend/src/main/resources/static/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
@ -3,9 +3,10 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<title>GUARDiA FA — Factory Automation Platform</title>
|
||||
<script type="module" crossorigin src="/assets/index-aXX4xDhy.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DqQ9Pbce.css">
|
||||
<script type="module" crossorigin src="/assets/index-DGCZGcqo.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Bw_KpBbg.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<title>GUARDiA FA — Factory Automation Platform</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
BIN
frontend/public/favicon.ico
Normal file
BIN
frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
@ -2,7 +2,8 @@ import React, { useState } from 'react'
|
||||
import { BrowserRouter, Routes, Route, Navigate, NavLink } from 'react-router-dom'
|
||||
import {
|
||||
LayoutDashboard, Map, Monitor, QrCode, Package,
|
||||
AlertTriangle, ShieldCheck, Wrench, Boxes, Brain, LogOut, Cpu
|
||||
AlertTriangle, ShieldCheck, Wrench, Boxes, Brain, LogOut, Cpu,
|
||||
Users, UserCircle
|
||||
} from 'lucide-react'
|
||||
import Login from './pages/Login'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
@ -15,6 +16,8 @@ import QualityControl from './pages/QualityControl'
|
||||
import EquipmentStatus from './pages/EquipmentStatus'
|
||||
import AiFactory from './pages/AiFactory'
|
||||
import AiPlatformSettings from './pages/AiPlatformSettings'
|
||||
import MyPage from './pages/MyPage'
|
||||
import UserManagement from './pages/UserManagement'
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ to: '/dashboard', icon: LayoutDashboard, label: '대시보드' },
|
||||
@ -32,6 +35,11 @@ const NAV_ITEMS = [
|
||||
|
||||
function Sidebar() {
|
||||
const user = JSON.parse(localStorage.getItem('fa_user') || '{}')
|
||||
const isAdmin = user.role === 'ADMIN'
|
||||
const navCls = ({ isActive }: { isActive: boolean }) =>
|
||||
`flex items-center gap-3 px-4 py-2.5 text-sm transition-colors ${
|
||||
isActive ? 'bg-blue-600 text-white' : 'text-slate-300 hover:bg-slate-800 hover:text-white'
|
||||
}`
|
||||
return (
|
||||
<aside className="w-60 bg-slate-900 border-r border-slate-700 flex flex-col h-screen fixed">
|
||||
<div className="p-4 border-b border-slate-700">
|
||||
@ -40,24 +48,22 @@ function Sidebar() {
|
||||
</div>
|
||||
<nav className="flex-1 overflow-y-auto py-2">
|
||||
{NAV_ITEMS.map(({ to, icon: Icon, label }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 px-4 py-2.5 text-sm transition-colors ${
|
||||
isActive
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'text-slate-300 hover:bg-slate-800 hover:text-white'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<NavLink key={to} to={to} className={navCls}>
|
||||
<Icon size={16} />
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
{isAdmin && (
|
||||
<NavLink to="/admin/users" className={navCls}>
|
||||
<Users size={16} />
|
||||
사용자 관리
|
||||
</NavLink>
|
||||
)}
|
||||
</nav>
|
||||
<div className="p-4 border-t border-slate-700">
|
||||
<div className="text-xs text-slate-400 mb-2">{user.username} ({user.role})</div>
|
||||
<NavLink to="/mypage" className="flex items-center gap-2 text-xs text-slate-300 hover:text-blue-400 mb-2 transition-colors">
|
||||
<UserCircle size={14} /> {user.username || '내 계정'} {user.role ? `(${user.role})` : ''}
|
||||
</NavLink>
|
||||
<button
|
||||
onClick={() => { localStorage.clear(); window.location.href = '/login' }}
|
||||
className="flex items-center gap-2 text-xs text-slate-400 hover:text-red-400 transition-colors"
|
||||
@ -104,6 +110,8 @@ export default function App() {
|
||||
<Route path="/inventory" element={<AiFactory />} />
|
||||
<Route path="/ai" element={<AiFactory />} />
|
||||
<Route path="/ai-settings" element={<AiPlatformSettings />} />
|
||||
<Route path="/mypage" element={<MyPage />} />
|
||||
<Route path="/admin/users" element={<UserManagement />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
</RequireAuth>
|
||||
|
||||
37
frontend/src/api/adminUsers.ts
Normal file
37
frontend/src/api/adminUsers.ts
Normal file
@ -0,0 +1,37 @@
|
||||
// 관리자 사용자 관리 API — /api/admin/users (ADMIN 전용). [GUARDiA-FA]
|
||||
// OTP 초기화(재등록 유도) + 계정 잠금 해제. 목록은 otp_secret/password_hash 미포함.
|
||||
// FA 공용 client(baseURL '/api/fa')와 달리 admin 은 루트 경로 → 전용 인스턴스(adminAiConfig 패턴 미러).
|
||||
// 레퍼런스: guardia-ocr adminOtpReset(client.ts) — FA 는 루트 경로 + fa_token 헤더 + ApiResponse 봉투.
|
||||
import axios from 'axios'
|
||||
|
||||
const adminApi = axios.create({ timeout: 30000 })
|
||||
adminApi.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('fa_token')
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`
|
||||
return config
|
||||
})
|
||||
|
||||
/** 관리 화면 사용자 행 — 민감정보(otp_secret/password_hash) 미포함. */
|
||||
export interface FaUserRow {
|
||||
id: number
|
||||
username: string
|
||||
role: string
|
||||
workstationCode?: string | null
|
||||
active?: boolean | null
|
||||
otpEnabled?: boolean | null
|
||||
locked?: boolean | null
|
||||
loginFailCount?: number | null
|
||||
createdAt?: string | null
|
||||
}
|
||||
|
||||
/** GET /api/admin/users — 사용자 목록(ApiResponse 봉투 → data). */
|
||||
export const listUsers = () =>
|
||||
adminApi.get('/api/admin/users').then(r => (r.data?.data ?? []) as FaUserRow[])
|
||||
|
||||
/** POST /api/admin/users/{id}/otp-reset — OTP 초기화(다음 로그인 시 QR 재등록). */
|
||||
export const adminOtpReset = (id: number) =>
|
||||
adminApi.post(`/api/admin/users/${id}/otp-reset`).then(r => r.data)
|
||||
|
||||
/** POST /api/admin/users/{id}/unlock — 로그인 실패 잠금 해제. */
|
||||
export const adminUnlock = (id: number) =>
|
||||
adminApi.post(`/api/admin/users/${id}/unlock`).then(r => r.data)
|
||||
@ -26,3 +26,25 @@ client.interceptors.response.use(
|
||||
)
|
||||
|
||||
export default client
|
||||
|
||||
// ── 인증 / 2FA(OTP) — /api/fa/auth (client baseURL '/api/fa' → 상대 '/auth/...') ──────────
|
||||
// 응답은 봉투 없이 flat( res.data ). login: OTP off → { twofa:"false", token, username, role, workstationCode },
|
||||
// OTP on → { twofa:"true", verifyToken, verifyMethod:(OTP|OTP_SETUP), secret?, otpAuthUri?, qrImage? }.
|
||||
// 보안 불변: setup/OTP_SETUP 응답의 secret/qrImage 는 화면 표시용만 — 로그/저장 절대 금지.
|
||||
export const login = (username: string, password: string) =>
|
||||
client.post('/auth/login', { username, password })
|
||||
|
||||
// 로그인 2단계: verify-token + Authenticator 6자리 → access 발급.
|
||||
export const verifyOtp = (verifyToken: string, code: string) =>
|
||||
client.post('/auth/verify-otp', { verifyToken, code })
|
||||
|
||||
// 내 정보(access 토큰). flat { username, role }.
|
||||
export const getMe = () => client.get('/auth/me')
|
||||
|
||||
// 마이페이지 OTP 등록/재설정/해제 + 비밀번호 변경(access 토큰 필요).
|
||||
// otpSetup 응답(flat OtpSetupResponse: { secret, otpAuthUri, qrImage })은 등록 순간에만 노출.
|
||||
export const otpSetup = () => client.post('/auth/otp/setup')
|
||||
export const otpConfirm = (code: string) => client.post('/auth/otp/confirm', { code })
|
||||
export const otpDisable = () => client.post('/auth/otp/disable')
|
||||
export const changePassword = (currentPassword: string, newPassword: string) =>
|
||||
client.post('/auth/change-password', { currentPassword, newPassword })
|
||||
|
||||
@ -1,34 +1,94 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import axios from 'axios'
|
||||
import { login, verifyOtp } from '../api/client'
|
||||
|
||||
/**
|
||||
* FA 로그인 — UIWS/OCR 2FA(OTP) 레이어 이식. [GUARDiA-FA]
|
||||
* 1단계: 아이디/비밀번호 → /api/fa/auth/login (flat 응답).
|
||||
* - twofa="false": 즉시 { token, username, role, workstationCode } 발급(기존 단일 로그인 회귀 0).
|
||||
* - twofa="true": verifyMethod 로 2차 분기.
|
||||
* · OTP : Authenticator 6자리 → /verify-otp
|
||||
* · OTP_SETUP : QR(qrImage)+수동키(secret) 등록 후 6자리 → /verify-otp
|
||||
* 2단계: verify-token + 코드 → access 발급 → /dashboard.
|
||||
* 보안: OTP secret/QR·인증코드는 화면 표시용만 — 로그/저장 절대 금지.
|
||||
*/
|
||||
type VerifyMethod = 'OTP' | 'OTP_SETUP'
|
||||
|
||||
export default function Login() {
|
||||
const [step, setStep] = useState<'login' | 'verify'>('login')
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [code, setCode] = useState('')
|
||||
const [verifyToken, setVerifyToken] = useState('')
|
||||
const [verifyMethod, setVerifyMethod] = useState<VerifyMethod>('OTP')
|
||||
const [qrImage, setQrImage] = useState('') // OTP_SETUP 등록 순간만 존재
|
||||
const [secret, setSecret] = useState('') // OTP_SETUP 등록 순간만 존재
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
const isSetup = verifyMethod === 'OTP_SETUP'
|
||||
|
||||
// access 발급 후 세션 저장(기존 fa_token / fa_user 스키마 보존 — 회귀 0).
|
||||
const finish = (d: any) => {
|
||||
localStorage.setItem('fa_token', d.token)
|
||||
localStorage.setItem('fa_user', JSON.stringify({
|
||||
username: d.username,
|
||||
role: d.role,
|
||||
workstationCode: d.workstationCode,
|
||||
}))
|
||||
navigate('/dashboard')
|
||||
}
|
||||
|
||||
const submitLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const res = await axios.post('/api/fa/auth/login', { username, password })
|
||||
localStorage.setItem('fa_token', res.data.token)
|
||||
localStorage.setItem('fa_user', JSON.stringify({
|
||||
username: res.data.username,
|
||||
role: res.data.role,
|
||||
workstationCode: res.data.workstationCode
|
||||
}))
|
||||
navigate('/dashboard')
|
||||
const res = await login(username, password)
|
||||
const d = res.data || {}
|
||||
if (d.twofa === 'true') {
|
||||
setVerifyToken(d.verifyToken || '')
|
||||
setVerifyMethod((d.verifyMethod as VerifyMethod) || 'OTP')
|
||||
setQrImage(d.qrImage || '')
|
||||
setSecret(d.secret || '')
|
||||
setCode('')
|
||||
setStep('verify')
|
||||
return
|
||||
}
|
||||
if (!d.token) throw new Error('no token')
|
||||
finish(d)
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error || '로그인 실패')
|
||||
setError(err.response?.data?.error || err.response?.data?.message || '로그인 실패')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const submitVerify = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const res = await verifyOtp(verifyToken, code)
|
||||
const d = res.data || {}
|
||||
if (!d.token) throw new Error('no token')
|
||||
finish(d)
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error || err.response?.data?.message || '인증 실패 — 코드를 확인하세요.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const backToLogin = () => {
|
||||
setStep('login'); setCode(''); setError('')
|
||||
setQrImage(''); setSecret('') // 시크릿 잔존 방지
|
||||
}
|
||||
|
||||
const inputCls =
|
||||
'w-full bg-slate-700 border border-slate-600 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-blue-500'
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-900 flex items-center justify-center">
|
||||
<div className="bg-slate-800 rounded-xl p-8 w-96 border border-slate-700">
|
||||
@ -37,41 +97,109 @@ export default function Login() {
|
||||
<div className="text-slate-400 text-sm">Factory Automation Platform</div>
|
||||
<div className="text-slate-500 text-xs mt-1">e-Paper · QR WIP · MES 통합 플랫폼</div>
|
||||
</div>
|
||||
<form onSubmit={handleLogin} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-slate-300 mb-1">사용자명</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
className="w-full bg-slate-700 border border-slate-600 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-blue-500"
|
||||
placeholder="username"
|
||||
required
|
||||
/>
|
||||
|
||||
{step === 'login' ? (
|
||||
<form onSubmit={submitLogin} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-slate-300 mb-1">사용자명</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
className={inputCls}
|
||||
placeholder="username"
|
||||
autoComplete="username"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-slate-300 mb-1">비밀번호</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
className={inputCls}
|
||||
placeholder="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{error && <div className="text-red-400 text-sm">{error}</div>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white rounded-lg py-2.5 text-sm font-medium transition-colors"
|
||||
>
|
||||
{loading ? '로그인 중...' : '로그인'}
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={submitVerify} className="space-y-4">
|
||||
<div className="text-center">
|
||||
<div className="text-white text-lg font-semibold mb-1">2차 인증</div>
|
||||
{isSetup ? (
|
||||
<p className="text-slate-400 text-xs">
|
||||
최초 로그인입니다. 아래 QR을 Authenticator 앱(Google·Microsoft)으로 스캔해
|
||||
등록한 뒤 6자리 코드를 입력하세요.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-slate-400 text-xs">
|
||||
Authenticator 앱에 표시된 6자리 코드를 입력하세요.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isSetup && qrImage && (
|
||||
<div className="flex justify-center">
|
||||
<img src={qrImage} alt="OTP QR" width={176} height={176}
|
||||
style={{ background: '#fff', padding: 8, borderRadius: 8 }} />
|
||||
</div>
|
||||
)}
|
||||
{isSetup && secret && (
|
||||
<div>
|
||||
<div className="text-[11px] text-slate-500 mb-1">QR 스캔이 안 되면 수동 입력 키</div>
|
||||
<code className="block text-xs text-blue-300 bg-slate-900 border border-slate-700 rounded-md px-2 py-1.5 break-all select-all">
|
||||
{secret}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-slate-300 mb-1">인증 코드</label>
|
||||
<input
|
||||
value={code}
|
||||
onChange={e => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
maxLength={6}
|
||||
autoFocus
|
||||
placeholder="000000"
|
||||
className={inputCls + ' tracking-[0.4em] text-center'}
|
||||
/>
|
||||
</div>
|
||||
{error && <div className="text-red-400 text-sm">{error}</div>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || code.length !== 6}
|
||||
className="w-full bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white rounded-lg py-2.5 text-sm font-medium transition-colors"
|
||||
>
|
||||
{loading ? '인증 중...' : isSetup ? '등록하고 로그인' : '로그인'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={backToLogin}
|
||||
className="w-full border border-slate-600 text-slate-300 hover:bg-slate-700 rounded-lg py-2 text-sm transition-colors"
|
||||
>
|
||||
이전 단계로
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{step === 'login' && (
|
||||
<div className="mt-4 text-xs text-slate-500 text-center">
|
||||
기본 계정: admin / admin123
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-slate-300 mb-1">비밀번호</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
className="w-full bg-slate-700 border border-slate-600 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-blue-500"
|
||||
placeholder="password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{error && <div className="text-red-400 text-sm">{error}</div>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white rounded-lg py-2.5 text-sm font-medium transition-colors"
|
||||
>
|
||||
{loading ? '로그인 중...' : '로그인'}
|
||||
</button>
|
||||
</form>
|
||||
<div className="mt-4 text-xs text-slate-500 text-center">
|
||||
기본 계정: admin / admin123
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
247
frontend/src/pages/MyPage.tsx
Normal file
247
frontend/src/pages/MyPage.tsx
Normal file
@ -0,0 +1,247 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
ShieldCheck, QrCode, Lock, Eye, EyeOff, CheckCircle2, AlertTriangle,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
getMe, otpSetup, otpConfirm, otpDisable, changePassword,
|
||||
} from '../api/client'
|
||||
|
||||
/**
|
||||
* 마이페이지 — OTP 2차 인증(등록/재설정/해제) + 비밀번호 변경. [GUARDiA-FA]
|
||||
* FA auth 응답은 flat( res.data ). getMe → { username, role }, otpSetup → { secret, otpAuthUri, qrImage }.
|
||||
* me() 는 otpEnabled 를 내려주지 않으므로 상태는 중립(null) — 등록/재설정과 해제를 모두 노출.
|
||||
* 보안: setup 응답의 secret/qrImage 는 화면 표시용만 — 로그/저장 절대 금지.
|
||||
* 새 비밀번호는 콘솔/응답에 출력 금지, 검증 4대(필수·8자·일치·현재와 다름).
|
||||
*/
|
||||
const MIN_PW = 8
|
||||
|
||||
type OtpPhase = 'idle' | 'setup' | 'done'
|
||||
|
||||
function errMsg(e: any, fallback: string): string {
|
||||
if (e?.response?.status === 403) return '권한이 없습니다.'
|
||||
return e?.response?.data?.error || e?.response?.data?.message || fallback
|
||||
}
|
||||
|
||||
export default function MyPage() {
|
||||
const [username, setUsername] = useState('')
|
||||
|
||||
// ── OTP 상태머신 ──────────────────────────────────────────────────────
|
||||
const [phase, setPhase] = useState<OtpPhase>('idle')
|
||||
const [qrImage, setQrImage] = useState('')
|
||||
const [secret, setSecret] = useState('')
|
||||
const [otpCode, setOtpCode] = useState('')
|
||||
const [otpBusy, setOtpBusy] = useState(false)
|
||||
const [otpMsg, setOtpMsg] = useState<{ ok: boolean; text: string } | null>(null)
|
||||
|
||||
// ── 비밀번호 변경 ─────────────────────────────────────────────────────
|
||||
const [curPw, setCurPw] = useState('')
|
||||
const [newPw, setNewPw] = useState('')
|
||||
const [newPw2, setNewPw2] = useState('')
|
||||
const [showPw, setShowPw] = useState(false)
|
||||
const [pwBusy, setPwBusy] = useState(false)
|
||||
const [pwMsg, setPwMsg] = useState<{ ok: boolean; text: string } | null>(null)
|
||||
|
||||
const loadMe = () =>
|
||||
getMe().then(r => {
|
||||
const me = r.data || {}
|
||||
let stored = ''
|
||||
try { stored = JSON.parse(localStorage.getItem('fa_user') || '{}').username || '' } catch { /* noop */ }
|
||||
setUsername(me.username || stored || '')
|
||||
}).catch(() => {})
|
||||
|
||||
useEffect(() => { loadMe() }, [])
|
||||
|
||||
const startSetup = async () => {
|
||||
setOtpMsg(null); setOtpBusy(true)
|
||||
try {
|
||||
const res = await otpSetup()
|
||||
const d = res.data || {}
|
||||
setQrImage(d.qrImage || '')
|
||||
setSecret(d.secret || '')
|
||||
setOtpCode('')
|
||||
setPhase('setup')
|
||||
} catch (e) {
|
||||
setOtpMsg({ ok: false, text: errMsg(e, 'OTP 등록을 시작하지 못했습니다.') })
|
||||
} finally { setOtpBusy(false) }
|
||||
}
|
||||
|
||||
const confirmOtp = async () => {
|
||||
setOtpMsg(null); setOtpBusy(true)
|
||||
try {
|
||||
await otpConfirm(otpCode)
|
||||
setSecret(''); setQrImage(''); setOtpCode('') // 시크릿 잔존 방지
|
||||
setPhase('done')
|
||||
setOtpMsg({ ok: true, text: '2차 인증이 활성화되었습니다.' })
|
||||
} catch (e) {
|
||||
setOtpMsg({ ok: false, text: errMsg(e, '코드가 일치하지 않거나 만료되었습니다.') })
|
||||
} finally { setOtpBusy(false) }
|
||||
}
|
||||
|
||||
const cancelSetup = () => {
|
||||
setPhase('idle'); setSecret(''); setQrImage(''); setOtpCode(''); setOtpMsg(null)
|
||||
}
|
||||
|
||||
const disableOtp = async () => {
|
||||
if (!window.confirm('Authenticator 2차 인증을 해제하시겠습니까?')) return
|
||||
setOtpMsg(null); setOtpBusy(true)
|
||||
try {
|
||||
await otpDisable()
|
||||
setPhase('idle'); setSecret(''); setQrImage('')
|
||||
setOtpMsg({ ok: true, text: '2차 인증이 해제되었습니다.' })
|
||||
} catch (e) {
|
||||
setOtpMsg({ ok: false, text: errMsg(e, 'OTP 해제에 실패했습니다.') })
|
||||
} finally { setOtpBusy(false) }
|
||||
}
|
||||
|
||||
const submitPw = async () => {
|
||||
setPwMsg(null)
|
||||
if (!curPw) { setPwMsg({ ok: false, text: '현재 비밀번호를 입력하세요.' }); return }
|
||||
if (newPw.length < MIN_PW) { setPwMsg({ ok: false, text: `새 비밀번호는 최소 ${MIN_PW}자 이상이어야 합니다.` }); return }
|
||||
if (newPw !== newPw2) { setPwMsg({ ok: false, text: '새 비밀번호가 일치하지 않습니다.' }); return }
|
||||
if (newPw === curPw) { setPwMsg({ ok: false, text: '새 비밀번호는 현재 비밀번호와 달라야 합니다.' }); return }
|
||||
setPwBusy(true)
|
||||
try {
|
||||
await changePassword(curPw, newPw)
|
||||
setCurPw(''); setNewPw(''); setNewPw2('')
|
||||
setPwMsg({ ok: true, text: '비밀번호가 변경되었습니다.' })
|
||||
} catch (e) {
|
||||
setPwMsg({ ok: false, text: errMsg(e, '비밀번호 변경에 실패했습니다.') })
|
||||
} finally { setPwBusy(false) }
|
||||
}
|
||||
|
||||
const inputCls =
|
||||
'w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 text-white text-sm focus:border-blue-500 outline-none'
|
||||
const codeCls = inputCls + ' tracking-[0.4em] text-center text-lg'
|
||||
|
||||
const StatusMsg = ({ m }: { m: { ok: boolean; text: string } | null }) =>
|
||||
m ? (
|
||||
<div className={`flex items-center gap-2 text-sm rounded-lg px-3 py-2 mb-3 border ${
|
||||
m.ok
|
||||
? 'bg-emerald-500/10 border-emerald-500/40 text-emerald-300'
|
||||
: 'bg-rose-500/10 border-rose-500/40 text-rose-300'
|
||||
}`}>
|
||||
{m.ok ? <CheckCircle2 size={16} /> : <AlertTriangle size={16} />}
|
||||
{m.text}
|
||||
</div>
|
||||
) : null
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl">
|
||||
<h1 className="text-xl font-bold text-white mb-1">마이페이지</h1>
|
||||
<p className="text-sm text-slate-400 mb-6">
|
||||
{username && <>계정: <span className="text-slate-200">{username}</span></>}
|
||||
</p>
|
||||
|
||||
{/* ── OTP 2차 인증 ──────────────────────────────────────────────── */}
|
||||
<section className="bg-slate-800 border border-slate-700 rounded-xl p-5 mb-6">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<ShieldCheck size={18} className="text-blue-400" />
|
||||
<h2 className="font-semibold text-slate-100">2차 인증 — Authenticator(OTP)</h2>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 mb-4">
|
||||
Google·Microsoft Authenticator 앱으로 6자리 코드를 사용하는 2차 인증을 설정합니다.
|
||||
</p>
|
||||
|
||||
<StatusMsg m={otpMsg} />
|
||||
|
||||
{phase === 'idle' && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button onClick={startSetup} disabled={otpBusy}
|
||||
className="flex items-center gap-1.5 px-3 py-2 rounded-lg bg-blue-600 hover:bg-blue-500 text-white text-sm font-semibold disabled:opacity-60 transition-colors">
|
||||
<QrCode size={16} /> {otpBusy ? '발급 중…' : 'OTP 등록 / 재설정 시작'}
|
||||
</button>
|
||||
<button onClick={disableOtp} disabled={otpBusy}
|
||||
className="px-3 py-2 rounded-lg border border-rose-500/50 text-rose-300 text-sm hover:bg-rose-500/10 disabled:opacity-60 transition-colors">
|
||||
Authenticator 해제
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === 'setup' && (
|
||||
<div>
|
||||
<ol className="list-decimal list-inside text-sm text-slate-300 leading-7 mb-3">
|
||||
<li>Authenticator 앱에서 아래 QR을 스캔하세요.</li>
|
||||
<li>스캔이 안 되면 수동 키를 직접 입력하세요.</li>
|
||||
<li>앱에 표시된 6자리 코드를 입력해 확인하세요.</li>
|
||||
</ol>
|
||||
{qrImage && (
|
||||
<div className="flex justify-center mb-3">
|
||||
<img src={qrImage} alt="OTP QR" width={200} height={200}
|
||||
style={{ background: '#fff', padding: 8, borderRadius: 8 }} />
|
||||
</div>
|
||||
)}
|
||||
{secret && (
|
||||
<div className="mb-4">
|
||||
<div className="text-[11px] text-slate-500 mb-1">수동 입력 키</div>
|
||||
<code className="block text-xs text-blue-300 bg-slate-900 border border-slate-700 rounded-md px-2 py-1.5 break-all select-all">
|
||||
{secret}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
<label className="block text-xs text-slate-400 mb-1">앱에 표시된 6자리 코드</label>
|
||||
<input value={otpCode} onChange={e => setOtpCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
inputMode="numeric" autoComplete="one-time-code" maxLength={6} placeholder="000000"
|
||||
className={codeCls + ' mb-3'} />
|
||||
<div className="flex gap-2">
|
||||
<button onClick={confirmOtp} disabled={otpBusy || otpCode.length !== 6}
|
||||
className="px-4 py-2 rounded-lg bg-blue-600 hover:bg-blue-500 text-white text-sm font-semibold disabled:opacity-60 transition-colors">
|
||||
{otpBusy ? '확인 중…' : '코드 확인 · 활성화'}
|
||||
</button>
|
||||
<button onClick={cancelSetup} disabled={otpBusy}
|
||||
className="px-4 py-2 rounded-lg border border-slate-600 text-sm text-slate-300 hover:bg-slate-700 transition-colors">
|
||||
취소
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === 'done' && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button onClick={disableOtp} disabled={otpBusy}
|
||||
className="px-3 py-2 rounded-lg border border-rose-500/50 text-rose-300 text-sm hover:bg-rose-500/10 disabled:opacity-60 transition-colors">
|
||||
Authenticator 해제
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ── 비밀번호 변경 ─────────────────────────────────────────────── */}
|
||||
<section className="bg-slate-800 border border-slate-700 rounded-xl p-5">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Lock size={18} className="text-blue-400" />
|
||||
<h2 className="font-semibold text-slate-100">비밀번호 변경</h2>
|
||||
</div>
|
||||
|
||||
<StatusMsg m={pwMsg} />
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs text-slate-400 mb-1">현재 비밀번호</label>
|
||||
<input type={showPw ? 'text' : 'password'} value={curPw} autoComplete="current-password"
|
||||
onChange={e => setCurPw(e.target.value)} className={inputCls} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-slate-400 mb-1">새 비밀번호 (최소 {MIN_PW}자)</label>
|
||||
<input type={showPw ? 'text' : 'password'} value={newPw} autoComplete="new-password"
|
||||
onChange={e => setNewPw(e.target.value)} className={inputCls} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-slate-400 mb-1">새 비밀번호 확인</label>
|
||||
<input type={showPw ? 'text' : 'password'} value={newPw2} autoComplete="new-password"
|
||||
onChange={e => setNewPw2(e.target.value)} className={inputCls} />
|
||||
</div>
|
||||
<label className="flex items-center gap-1.5 text-xs text-slate-400 cursor-pointer select-none">
|
||||
<button type="button" onClick={() => setShowPw(!showPw)} className="text-slate-400 hover:text-blue-400">
|
||||
{showPw ? <EyeOff size={15} /> : <Eye size={15} />}
|
||||
</button>
|
||||
비밀번호 표시
|
||||
</label>
|
||||
<button onClick={submitPw} disabled={pwBusy}
|
||||
className="px-4 py-2 rounded-lg bg-blue-600 hover:bg-blue-500 text-white text-sm font-semibold disabled:opacity-60 transition-colors">
|
||||
{pwBusy ? '변경 중…' : '비밀번호 변경'}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
125
frontend/src/pages/UserManagement.tsx
Normal file
125
frontend/src/pages/UserManagement.tsx
Normal file
@ -0,0 +1,125 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Users, RotateCcw, Unlock, ShieldCheck, ShieldOff, RefreshCw } from 'lucide-react'
|
||||
import { listUsers, adminOtpReset, adminUnlock, type FaUserRow } from '../api/adminUsers'
|
||||
|
||||
/**
|
||||
* 사용자 관리(ADMIN) — OTP 초기화 / 계정 잠금 해제. [GUARDiA-FA]
|
||||
* 백엔드 /api/admin/** 는 hasRole(ADMIN) 게이트 — 비관리자는 403(목록 로드 실패 안내).
|
||||
* 목록 응답에 otp_secret/password_hash 미포함(백엔드 findAll 에서 미조회).
|
||||
* OTP 초기화 → 대상 사용자 다음 로그인 시 QR 재등록. 시크릿은 조회/노출하지 않는다.
|
||||
*/
|
||||
function errMsg(e: any, fallback: string): string {
|
||||
if (e?.response?.status === 403) return '권한이 없습니다 (사용자 관리는 ADMIN 전용).'
|
||||
return e?.response?.data?.error || e?.response?.data?.message || fallback
|
||||
}
|
||||
|
||||
export default function UserManagement() {
|
||||
const [rows, setRows] = useState<FaUserRow[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [err, setErr] = useState('')
|
||||
const [busyId, setBusyId] = useState<number | null>(null)
|
||||
const [msg, setMsg] = useState('')
|
||||
|
||||
const load = () => {
|
||||
setLoading(true); setErr('')
|
||||
listUsers()
|
||||
.then(setRows)
|
||||
.catch(e => setErr(errMsg(e, '사용자 목록을 불러오지 못했습니다.')))
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
|
||||
useEffect(() => { load() }, [])
|
||||
|
||||
const doOtpReset = async (u: FaUserRow) => {
|
||||
if (!window.confirm(`${u.username} 사용자의 OTP를 초기화하시겠습니까? (다음 로그인 시 재등록)`)) return
|
||||
setBusyId(u.id); setMsg(''); setErr('')
|
||||
try {
|
||||
await adminOtpReset(u.id)
|
||||
setMsg(`${u.username} OTP를 초기화했습니다.`)
|
||||
load()
|
||||
} catch (e) {
|
||||
setErr(errMsg(e, 'OTP 초기화에 실패했습니다.'))
|
||||
} finally { setBusyId(null) }
|
||||
}
|
||||
|
||||
const doUnlock = async (u: FaUserRow) => {
|
||||
setBusyId(u.id); setMsg(''); setErr('')
|
||||
try {
|
||||
await adminUnlock(u.id)
|
||||
setMsg(`${u.username} 계정 잠금을 해제했습니다.`)
|
||||
load()
|
||||
} catch (e) {
|
||||
setErr(errMsg(e, '잠금 해제에 실패했습니다.'))
|
||||
} finally { setBusyId(null) }
|
||||
}
|
||||
|
||||
const th = 'text-left text-xs font-semibold text-slate-400 px-3 py-2'
|
||||
const td = 'text-sm text-slate-200 px-3 py-2 border-t border-slate-700'
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-white flex items-center gap-2">
|
||||
<Users size={20} className="text-blue-400" /> 사용자 관리
|
||||
</h1>
|
||||
<p className="text-sm text-slate-400 mt-1">OTP 초기화 · 계정 잠금 해제 (ADMIN 전용)</p>
|
||||
</div>
|
||||
<button onClick={load} disabled={loading}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-slate-800 border border-slate-700 text-sm text-slate-200 disabled:opacity-40">
|
||||
<RefreshCw size={14} /> 새로고침
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{err && <div className="bg-rose-500/10 border border-rose-500/40 text-rose-300 text-sm rounded-lg px-4 py-2.5">{err}</div>}
|
||||
{msg && <div className="bg-emerald-500/10 border border-emerald-500/40 text-emerald-300 text-sm rounded-lg px-4 py-2.5">{msg}</div>}
|
||||
|
||||
<div className="bg-slate-800 border border-slate-700 rounded-xl overflow-x-auto">
|
||||
<table className="w-full min-w-[720px]">
|
||||
<thead>
|
||||
<tr className="bg-slate-900/60">
|
||||
<th className={th}>사용자명</th>
|
||||
<th className={th}>역할</th>
|
||||
<th className={th}>워크스테이션</th>
|
||||
<th className={th}>OTP</th>
|
||||
<th className={th}>상태</th>
|
||||
<th className={th + ' text-right'}>작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map(u => (
|
||||
<tr key={u.id}>
|
||||
<td className={td + ' font-medium'}>{u.username}</td>
|
||||
<td className={td}>{u.role}</td>
|
||||
<td className={td}>{u.workstationCode || '-'}</td>
|
||||
<td className={td}>
|
||||
{u.otpEnabled
|
||||
? <span className="inline-flex items-center gap-1 text-emerald-300"><ShieldCheck size={14} /> 사용</span>
|
||||
: <span className="inline-flex items-center gap-1 text-slate-400"><ShieldOff size={14} /> 미설정</span>}
|
||||
</td>
|
||||
<td className={td}>
|
||||
{u.locked
|
||||
? <span className="text-rose-300">잠금</span>
|
||||
: <span className="text-slate-400">정상</span>}
|
||||
</td>
|
||||
<td className={td + ' text-right whitespace-nowrap'}>
|
||||
<button onClick={() => doOtpReset(u)} disabled={busyId === u.id}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1.5 mr-2 rounded-lg border border-slate-600 text-xs text-slate-200 hover:border-blue-500/50 disabled:opacity-50 transition-colors">
|
||||
<RotateCcw size={13} /> OTP 초기화
|
||||
</button>
|
||||
<button onClick={() => doUnlock(u)} disabled={busyId === u.id || !u.locked}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg border border-slate-600 text-xs text-slate-200 hover:border-blue-500/50 disabled:opacity-30 transition-colors">
|
||||
<Unlock size={13} /> 잠금 해제
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{!loading && rows.length === 0 && !err && (
|
||||
<tr><td className={td + ' text-center text-slate-500'} colSpan={6}>사용자가 없습니다.</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user