feat(auth): OTP 2FA 백+프론트

This commit is contained in:
GUARDiA 2026-07-04 08:34:52 +09:00
parent 81e3b6ebef
commit c9c5d43c49
32 changed files with 1785 additions and 373 deletions

View File

@ -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>

View File

@ -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())

View 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 초기화(재등록 유도)");
}
}

View 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();
}
}

View File

@ -0,0 +1,5 @@
package com.zioinfo.fa.auth.dto;
/** 마이페이지 비밀번호 변경 요청 — { currentPassword, newPassword }. 평문은 응답/로그 미기록. [GUARDiA-FA] */
public record ChangePasswordRequest(String currentPassword, String newPassword) {
}

View File

@ -0,0 +1,5 @@
package com.zioinfo.fa.auth.dto;
/** Authenticator(TOTP) 등록 확인 요청 — 앱에 표시된 6자리 코드. [GUARDiA-FA] */
public record OtpConfirmRequest(String code) {
}

View File

@ -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 이미지, &lt;img src&gt; 표시)</li>
* </ul>
* 보안 불변: 응답(등록 순간)에서만 시크릿/QR 노출. 조회/목록/재조회 응답에 재노출 금지·로그 미기록.
*/
public record OtpSetupResponse(String secret, String otpAuthUri, String qrImage) {
}

View File

@ -0,0 +1,5 @@
package com.zioinfo.fa.auth.dto;
/** 로그인 2단계 TOTP 검증 요청 — { verifyToken, code }. [GUARDiA-FA] */
public record OtpVerifyRequest(String verifyToken, String code) {
}

View File

@ -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();
}
}

View File

@ -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"));
}
}

View File

@ -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;
}
}

View File

@ -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;
}

View File

@ -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);
}

View File

@ -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, "본인 비밀번호 변경");
}
}

View File

@ -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:

View 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 '계정 잠금 여부(관리자 해제 필요).';

View 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;

View File

@ -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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@ -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>

View File

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@ -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>

View 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)

View File

@ -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 })

View File

@ -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,15 +97,18 @@ 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">
{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="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"
className={inputCls}
placeholder="username"
autoComplete="username"
required
/>
</div>
@ -55,8 +118,9 @@ export default function Login() {
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"
className={inputCls}
placeholder="password"
autoComplete="current-password"
required
/>
</div>
@ -69,9 +133,73 @@ export default function Login() {
{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>
</div>
)

View 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>
)
}

View 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>
)
}