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

This commit is contained in:
GUARDiA 2026-07-04 09:01:32 +09:00
parent 0a4cb67c44
commit 4098ccbe84
35 changed files with 1483 additions and 34 deletions

View File

@ -52,6 +52,13 @@
<version>1.1.3</version> <version>1.1.3</version>
</dependency> </dependency>
<!-- TOTP(OTP 2차 인증) — RFC 6238, QR(otpauth) 생성. UIWS 동일 좌표. -->
<dependency>
<groupId>dev.samstevens.totp</groupId>
<artifactId>totp</artifactId>
<version>1.7.1</version>
</dependency>
<!-- JWT --> <!-- JWT -->
<dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-api</artifactId><version>${jjwt.version}</version></dependency> <dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-api</artifactId><version>${jjwt.version}</version></dependency>
<dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-impl</artifactId><version>${jjwt.version}</version><scope>runtime</scope></dependency> <dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-impl</artifactId><version>${jjwt.version}</version><scope>runtime</scope></dependency>

View File

@ -1,7 +1,11 @@
package com.zioinfo.esn.auth; package com.zioinfo.esn.auth;
import com.zioinfo.esn.common.ApiResponse; import com.zioinfo.esn.common.ApiResponse;
import com.zioinfo.esn.uiws.auth.OtpAuthService;
import com.zioinfo.esn.uiws.auth.TwoFactorService; import com.zioinfo.esn.uiws.auth.TwoFactorService;
import com.zioinfo.esn.uiws.common.UiwsApiException;
import com.zioinfo.esn.uiws.common.UiwsErrorCode;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@ -9,8 +13,10 @@ import java.util.Map;
/** /**
* ESN 인증 컨트롤러. * ESN 인증 컨트롤러.
* - /login: 2FA off { twofa:"false", token, type }, 2FA on 이면 { twofa:"true", verifyToken, step, maskedEmail }. * - /login: 2FA off { twofa:"false", token, type }, 2FA on 이면 { twofa:"true", verifyToken, ... }.
* - /verify: (UIWS 2FA 이식) verify-token + 인증코드 access 토큰 발급. * - /verify: (UIWS 이메일 2FA 이식) verify-token + 인증코드 access 토큰 발급.
* - /verify-otp: (TOTP 이식) verify-token + 6자리 코드 access 토큰 발급(최초 로그인이면 등록 확정).
* - /otp/*, /change-password: 마이페이지(본인, access 토큰 필요).
* 기존 클라이언트는 응답에 token 필드가 그대로 존재(2FA off ) 회귀 0. * 기존 클라이언트는 응답에 token 필드가 그대로 존재(2FA off ) 회귀 0.
*/ */
@RestController @RestController
@ -20,18 +26,26 @@ public class AuthController {
private final AuthService authService; private final AuthService authService;
private final TwoFactorService twoFactorService; private final TwoFactorService twoFactorService;
private final OtpAuthService otpAuthService;
private final JwtUtil jwtUtil;
@PostMapping("/login") @PostMapping("/login")
public ApiResponse<Map<String, String>> login(@RequestBody LoginRequest req) { public ApiResponse<Map<String, String>> login(@RequestBody LoginRequest req) {
return ApiResponse.ok(authService.login(req.username(), req.password())); return ApiResponse.ok(authService.login(req.username(), req.password()));
} }
/** UIWS 2FA 이식: 2차 인증 코드 검증 → access 토큰 발급. */ /** UIWS 이메일 2FA 이식: 2차 인증 코드 검증 → access 토큰 발급. */
@PostMapping("/verify") @PostMapping("/verify")
public ApiResponse<Map<String, String>> verify(@RequestBody VerifyRequest req) { public ApiResponse<Map<String, String>> verify(@RequestBody VerifyRequest req) {
return ApiResponse.ok(twoFactorService.verify(req.verifyToken(), req.code())); return ApiResponse.ok(twoFactorService.verify(req.verifyToken(), req.code()));
} }
/** TOTP 이식: 로그인 2단계 6자리 검증 → access 토큰 발급(최초 로그인이면 등록 확정). */
@PostMapping("/verify-otp")
public ApiResponse<Map<String, String>> verifyOtp(@Valid @RequestBody OtpVerifyRequest req) {
return ApiResponse.ok(otpAuthService.verifyOtp(req.verifyToken(), req.code()));
}
@GetMapping("/me") @GetMapping("/me")
public ApiResponse<Map<String, String>> me(@RequestHeader("Authorization") String header) { public ApiResponse<Map<String, String>> me(@RequestHeader("Authorization") String header) {
String token = header.replace("Bearer ", ""); String token = header.replace("Bearer ", "");
@ -44,6 +58,49 @@ public class AuthController {
return ApiResponse.ok("로그아웃 성공", null); return ApiResponse.ok("로그아웃 성공", null);
} }
// 마이페이지: OTP 등록/재설정/해제 + 비밀번호 변경 (본인, access 토큰 필요)
/** 마이페이지 OTP 등록/재설정 시작 → { secret, otpAuthUri, qrImage }(이 응답에서만 시크릿/QR 노출). */
@PostMapping("/otp/setup")
public ApiResponse<OtpSetupResponse> otpSetup(@RequestHeader("Authorization") String header) {
return ApiResponse.ok(otpAuthService.setup(requireUser(header)));
}
/** 마이페이지 OTP 확인·활성화(앱 6자리 코드). */
@PostMapping("/otp/confirm")
public ApiResponse<Map<String, String>> otpConfirm(@RequestHeader("Authorization") String header,
@Valid @RequestBody OtpConfirmRequest req) {
otpAuthService.confirm(requireUser(header), req.code());
return ApiResponse.ok(Map.of("result", "ok"));
}
/** 마이페이지 OTP 해제. */
@PostMapping("/otp/disable")
public ApiResponse<Map<String, String>> otpDisable(@RequestHeader("Authorization") String header) {
otpAuthService.disable(requireUser(header));
return ApiResponse.ok(Map.of("result", "ok"));
}
/** 마이페이지 비밀번호 변경(현재 비번 검증 + BCrypt). 새 비번은 응답/로그 미포함. */
@PostMapping("/change-password")
public ApiResponse<Map<String, String>> changePassword(@RequestHeader("Authorization") String header,
@Valid @RequestBody ChangePasswordRequest req) {
authService.changePassword(requireUser(header), req);
return ApiResponse.ok(Map.of("result", "ok"));
}
/**
* Authorization 헤더에서 인증 사용자를 해석한다. verify-token(2fa 단계용) 거부.
* (/api/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 UiwsApiException(UiwsErrorCode.UNAUTHORIZED);
}
return jwtUtil.getUsername(token);
}
record LoginRequest(String username, String password) {} record LoginRequest(String username, String password) {}
record VerifyRequest(String verifyToken, String code) {} record VerifyRequest(String verifyToken, String code) {}

View File

@ -1,19 +1,22 @@
package com.zioinfo.esn.auth; package com.zioinfo.esn.auth;
import com.zioinfo.esn.admin.AuditService;
import com.zioinfo.esn.auth.mapper.UserAuthMapper; import com.zioinfo.esn.auth.mapper.UserAuthMapper;
import com.zioinfo.esn.uiws.auth.OtpAuthService;
import com.zioinfo.esn.uiws.auth.TwoFactorService; import com.zioinfo.esn.uiws.auth.TwoFactorService;
import com.zioinfo.esn.uiws.common.UiwsApiException; import com.zioinfo.esn.uiws.common.UiwsApiException;
import com.zioinfo.esn.uiws.common.UiwsErrorCode; import com.zioinfo.esn.uiws.common.UiwsErrorCode;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Map; import java.util.Map;
/** /**
* ESN 인증 서비스. * ESN 인증 서비스.
* - 기존 단일 JWT 로그인 보존(2FA off 또는 미설정 회귀 0). * - 기존 단일 JWT 로그인 보존(2FA off 또는 미설정 회귀 0).
* - UIWS 2FA 레이어(이메일 코드) 추가: 2FA on 이면 1차 통과 verify-token + 이메일코드 발급. * - UIWS 2FA 레이어 추가. 로그인 2단계 우선순위: OTP(TOTP) > 이메일코드 > 단일 로그인.
* 실패 누적 max-login-fail 계정 잠금. * 실패 누적 max-login-fail 계정 잠금.
*/ */
@Service @Service
@ -24,6 +27,8 @@ public class AuthService {
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
private final JwtUtil jwtUtil; private final JwtUtil jwtUtil;
private final TwoFactorService twoFactorService; private final TwoFactorService twoFactorService;
private final OtpAuthService otpAuthService;
private final AuditService auditService;
/** /**
* 1차 로그인. 2FA 활성 verify-token + 이메일코드 흐름으로 분기, * 1차 로그인. 2FA 활성 verify-token + 이메일코드 흐름으로 분기,
@ -43,8 +48,8 @@ public class AuthService {
throw new RuntimeException("ERR-AUTH-001: 존재하지 않거나 비활성 계정"); throw new RuntimeException("ERR-AUTH-001: 존재하지 않거나 비활성 계정");
} }
if (!passwordEncoder.matches(password, user.getPasswordHash())) { if (!passwordEncoder.matches(password, user.getPasswordHash())) {
// 2FA 활성 실패 누적/잠금. 비활성 기존 동작(메시지만) 유지. // 2FA(OTP 또는 이메일) 활성 실패 누적/잠금. 비활성 기존 동작(메시지만) 유지.
if (twoFactorService.isEnabled()) { if (otpAuthService.isEnabled() || twoFactorService.isEnabled()) {
twoFactorService.recordLoginFailure(username); twoFactorService.recordLoginFailure(username);
EsnUser after = userMapper.findByUsername(username); EsnUser after = userMapper.findByUsername(username);
if (after != null && Boolean.TRUE.equals(after.getLocked())) { if (after != null && Boolean.TRUE.equals(after.getLocked())) {
@ -54,7 +59,11 @@ public class AuthService {
throw new RuntimeException("ERR-AUTH-002: 비밀번호 불일치"); throw new RuntimeException("ERR-AUTH-002: 비밀번호 불일치");
} }
// 비밀번호 검증 통과 // 비밀번호 검증 통과 2단계 우선순위: OTP > 이메일코드 > 단일 로그인
if (otpAuthService.isEnabled()) {
// { twofa:true, verifyToken, verifyMethod:(OTP|OTP_SETUP), secret?, otpAuthUri?, qrImage? }
return otpAuthService.beginOtp(user);
}
if (twoFactorService.isEnabled()) { if (twoFactorService.isEnabled()) {
Map<String, String> step1 = twoFactorService.beginTwoFactor(user); Map<String, String> step1 = twoFactorService.beginTwoFactor(user);
return Map.of( return Map.of(
@ -78,4 +87,25 @@ public class AuthService {
"tenant", jwtUtil.getTenant(token) != null ? jwtUtil.getTenant(token) : "" "tenant", jwtUtil.getTenant(token) != null ? jwtUtil.getTenant(token) : ""
); );
} }
/**
* 마이페이지 비밀번호 변경(현재 비밀번호 검증 + BCrypt 저장). UIWS changePassword 미러.
* 현재 비번 불일치 PASSWORD_MISMATCH, 기존과 동일 PASSWORD_SAME_AS_OLD.
* 비밀번호 평문은 로그/응답/감사에 절대 기록하지 않는다(보안 불변규칙).
*/
@Transactional
public void changePassword(String username, ChangePasswordRequest req) {
EsnUser user = userMapper.findByUsername(username);
if (user == null) {
throw new UiwsApiException(UiwsErrorCode.USER_NOT_FOUND);
}
if (!passwordEncoder.matches(req.currentPassword(), user.getPasswordHash())) {
throw new UiwsApiException(UiwsErrorCode.PASSWORD_MISMATCH);
}
if (passwordEncoder.matches(req.newPassword(), user.getPasswordHash())) {
throw new UiwsApiException(UiwsErrorCode.PASSWORD_SAME_AS_OLD);
}
userMapper.updatePasswordHash(username, passwordEncoder.encode(req.newPassword()));
auditService.log("PASSWORD_CHANGE", "USER", username, "본인 비밀번호 변경");
}
} }

View File

@ -0,0 +1,12 @@
package com.zioinfo.esn.auth;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
/** 마이페이지 비밀번호 변경 요청 — 현재 비밀번호 검증 + 새 비밀번호(BCrypt 저장). */
public record ChangePasswordRequest(
@NotBlank(message = "현재 비밀번호는 필수입니다.") String currentPassword,
@NotBlank(message = "새 비밀번호는 필수입니다.")
@Size(min = 8, message = "새 비밀번호는 최소 8자입니다.") String newPassword
) {
}

View File

@ -35,7 +35,9 @@ public class EsnUser {
private Integer loginFailCount; private Integer loginFailCount;
/** 계정 잠금 여부(기본 false). */ /** 계정 잠금 여부(기본 false). */
private Boolean locked; private Boolean locked;
/** TOTP 시크릿(UIWS OTP 경로 대비, 현재 이메일 흐름에서는 미사용). */ /** TOTP 시크릿(OTP 2차 인증). API 응답·로그 미노출(불변규칙). */
@JsonIgnore @JsonIgnore
private String otpSecret; private String otpSecret;
/** OTP 등록 확정 여부(최초 로그인 verify 성공/마이페이지 confirm 시 true). */
private Boolean otpEnabled;
} }

View File

@ -0,0 +1,7 @@
package com.zioinfo.esn.auth;
import jakarta.validation.constraints.NotBlank;
/** Authenticator(TOTP) 등록 확인 요청 — 앱에 표시된 6자리 코드. */
public record OtpConfirmRequest(@NotBlank String code) {
}

View File

@ -0,0 +1,13 @@
package com.zioinfo.esn.auth;
/**
* Authenticator(TOTP) 등록 셋업 응답 UIWS OtpSetupResponse 미러.
* <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,10 @@
package com.zioinfo.esn.auth;
import jakarta.validation.constraints.NotBlank;
/** 로그인 2단계 TOTP 검증 요청 — { verifyToken, code }. */
public record OtpVerifyRequest(
@NotBlank(message = "verifyToken은 필수입니다.") String verifyToken,
@NotBlank(message = "code는 필수입니다.") String code
) {
}

View File

@ -30,6 +30,17 @@ public interface UserAuthMapper {
/** 관리자 잠금 해제(실패 카운트/잠금 초기화). */ /** 관리자 잠금 해제(실패 카운트/잠금 초기화). */
int unlock(@Param("username") String username); int unlock(@Param("username") String username);
// TOTP(OTP 2차 인증) 이식: 시크릿/등록 확정 갱신 (멱등 UPDATE)
/** 보류 시크릿 저장(등록 확정 전). otp_enabled 은 건드리지 않는다(verify/confirm 에서 확정). */
int updateOtpSecret(@Param("username") String username, @Param("secret") String secret);
/** 등록 확정: otp_enabled=true (시크릿은 유지). */
int enableOtp(@Param("username") String username);
/** 해제/초기화: 시크릿 폐기 + otp_enabled=false. (마이페이지 해제) */
int disableOtp(@Param("username") String username);
// 로그인 보조 이식: 회원가입(승인대기) / 아이디찾기 / 비밀번호 초기화 // 로그인 보조 이식: 회원가입(승인대기) / 아이디찾기 / 비밀번호 초기화
/** 아이디 중복 확인. */ /** 아이디 중복 확인. */

View File

@ -0,0 +1,130 @@
package com.zioinfo.esn.config;
import com.zioinfo.esn.auth.mapper.UserAuthMapper;
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 방식 + 암호화 저장).
*
* <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>schema.sql init(admin 시드) 이후 실행되어 기존 해시를 덮어쓴다(멱등: 기동 동일 결과).
*/
@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 UserAuthMapper 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.updatePasswordHash(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

@ -70,6 +70,16 @@ public class UserController {
return ApiResponse.ok(active ? "활성화 완료" : "비활성화 완료", u); return ApiResponse.ok(active ? "활성화 완료" : "비활성화 완료", u);
} }
/** 관리자 OTP 초기화(사용자 관리 화면 버튼) — OTP 시크릿 폐기 → 다음 로그인 재등록 유도. */
@PostMapping("/{id}/otp-reset")
public ApiResponse<EsnUser> resetOtp(@PathVariable Long id) {
EsnUser u = service.resetOtp(id);
// 보안: OTP 시크릿 값은 기록하지 않는다(대상 id/username 요약만).
audit.log(u != null ? u.getTenantCode() : null, "USER_OTP_RESET", "USER",
String.valueOf(id), "OTP 초기화(다음 로그인 재등록)", true);
return ApiResponse.ok("OTP 초기화 완료", u);
}
@DeleteMapping("/{id}") @DeleteMapping("/{id}")
public ApiResponse<Void> delete(@PathVariable Long id) { public ApiResponse<Void> delete(@PathVariable Long id) {
service.delete(id); service.delete(id);

View File

@ -15,4 +15,7 @@ public interface UserMapper {
int update(EsnUser user); int update(EsnUser user);
int updatePassword(@Param("id") Long id, @Param("passwordHash") String passwordHash); int updatePassword(@Param("id") Long id, @Param("passwordHash") String passwordHash);
int delete(@Param("id") Long id); int delete(@Param("id") Long id);
/** 관리자 OTP 초기화: 대상 사용자의 OTP 시크릿 폐기 + 등록 해제(다음 로그인 재등록 유도). */
int resetOtpById(@Param("id") Long id);
} }

View File

@ -44,4 +44,13 @@ public class UserService {
} }
public void delete(Long id) { mapper.delete(id); } public void delete(Long id) { mapper.delete(id); }
/**
* 관리자 OTP 초기화(사용자 관리 화면 버튼). 대상 사용자의 OTP 시크릿을 폐기하고 등록을 해제한다.
* 사용자는 다음 로그인 OTP_SETUP(재등록) 플로우를 탄다. 시크릿은 응답/로그에 노출하지 않는다.
*/
public EsnUser resetOtp(Long id) {
mapper.resetOtpById(id);
return mapper.findById(id);
}
} }

View File

@ -0,0 +1,167 @@
package com.zioinfo.esn.uiws.auth;
import com.zioinfo.esn.admin.AuditService;
import com.zioinfo.esn.auth.EsnUser;
import com.zioinfo.esn.auth.JwtUtil;
import com.zioinfo.esn.auth.OtpSetupResponse;
import com.zioinfo.esn.auth.mapper.UserAuthMapper;
import com.zioinfo.esn.uiws.common.UiwsApiException;
import com.zioinfo.esn.uiws.common.UiwsErrorCode;
import com.zioinfo.esn.uiws.config.UiwsProperties;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* TOTP(OTP 2차 인증) 오케스트레이션 레이어 UIWS AuthService OTP 경로 미러.
*
* <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}.</li>
* </ol>
*
* <p>보안 불변: 시크릿·QR·otpauth URI setup/OTP_SETUP 응답에서만 노출. 로그·감사에 시크릿/코드 미기록.
* 기존 auth(JWT·RBAC) 엔진 교체 없음 TOTP 레이어만 추가. access ESN JwtUtil(3-클레임) 정책 재사용.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class OtpAuthService {
private final UserAuthMapper userMapper;
private final JwtUtil jwtUtil;
private final TotpService totpService;
private final UiwsProperties properties;
private final AuditService auditService;
/** 로그인 2단계에 OTP 경로를 사용할지. */
public boolean isEnabled() {
return properties.getAuth().isOtpEnabled();
}
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(EsnUser user) {
long tokenValidity = properties.getAuth().getVerifyTokenValiditySeconds();
String verifyToken = jwtUtil.generateVerifyToken(user.getUsername(), tokenValidity);
// 성공 실패카운트 초기화(잠금 회복)
userMapper.resetLoginFail(user.getUsername());
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 { token, type, username, role, tenant } (ESN 2FA verify 응답 shape 동일)
*/
@Transactional
public Map<String, String> verifyOtp(String verifyToken, String code) {
String username = jwtUtil.parseVerifyTokenUsername(verifyToken);
if (username == null) {
throw new UiwsApiException(UiwsErrorCode.VERIFY_TOKEN_INVALID);
}
EsnUser user = userMapper.findByUsername(username);
if (user == null) {
throw new UiwsApiException(UiwsErrorCode.VERIFY_TOKEN_INVALID);
}
if (Boolean.TRUE.equals(user.getLocked())) {
throw new UiwsApiException(UiwsErrorCode.ACCOUNT_LOCKED);
}
if (!totpService.verify(user.getOtpSecret(), code)) {
// OTP 오입력도 실패 카운트에 합산(정책) 임계 도달 잠금.
userMapper.incrementLoginFail(username, properties.getAuth().getMaxLoginFail());
throw new UiwsApiException(UiwsErrorCode.VERIFY_CODE_INVALID);
}
// 최초 로그인(보류 시크릿) 등록 확정(멱등)
if (!Boolean.TRUE.equals(user.getOtpEnabled())) {
userMapper.enableOtp(username);
auditService.log("OTP_ENROLL", "USER", username, "최초 로그인 OTP 등록 확정");
}
userMapper.resetLoginFail(username);
userMapper.updateLastLogin(username);
String access = jwtUtil.generate(user.getUsername(), user.getRole(), user.getTenantCode());
Map<String, String> resp = new LinkedHashMap<>();
resp.put("token", access);
resp.put("type", "Bearer");
resp.put("username", user.getUsername());
resp.put("role", user.getRole() == null ? "" : user.getRole());
resp.put("tenant", user.getTenantCode() == null ? "" : user.getTenantCode());
return resp;
}
/** 마이페이지 OTP 등록/재설정 시작: 새 보류 시크릿 발급(기존 시크릿 무효화) + QR 반환. */
@Transactional
public OtpSetupResponse setup(String username) {
EsnUser user = userMapper.findByUsername(username);
if (user == null) {
throw new UiwsApiException(UiwsErrorCode.USER_NOT_FOUND);
}
String secret = totpService.generateSecret();
userMapper.updateOtpSecret(username, secret); // 확정 전엔 otp_enabled 유지(confirm 에서 재확정)
auditService.log("OTP_SETUP", "USER", 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) {
EsnUser user = userMapper.findByUsername(username);
if (user == null) {
throw new UiwsApiException(UiwsErrorCode.USER_NOT_FOUND);
}
if (blank(user.getOtpSecret())) {
throw new UiwsApiException(UiwsErrorCode.OTP_NOT_REGISTERED);
}
if (!totpService.verify(user.getOtpSecret(), code)) {
throw new UiwsApiException(UiwsErrorCode.VERIFY_CODE_INVALID);
}
userMapper.enableOtp(username);
auditService.log("OTP_ENABLE", "USER", username, "OTP 2차 인증 활성화");
}
/** 마이페이지 OTP 해제: 시크릿 폐기 + otp_enabled=false. */
@Transactional
public void disable(String username) {
EsnUser user = userMapper.findByUsername(username);
if (user == null) {
throw new UiwsApiException(UiwsErrorCode.USER_NOT_FOUND);
}
userMapper.disableOtp(username);
auditService.log("OTP_DISABLE", "USER", username, "OTP 2차 인증 해제");
}
}

View File

@ -0,0 +1,81 @@
package com.zioinfo.esn.uiws.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 윈도우) 코덱 UIWS TotpService 미러.
* 시크릿(otp_secret) esn_user 저장되어 있다고 가정. issuer GUARDiA-ESN 교체.
*
* <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-ESN";
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

@ -12,6 +12,7 @@ public enum UiwsErrorCode {
// 공통 // 공통
INVALID_REQUEST("ERR-UIWS-400", "요청이 올바르지 않습니다."), INVALID_REQUEST("ERR-UIWS-400", "요청이 올바르지 않습니다."),
UNAUTHORIZED("ERR-UIWS-401", "인증이 필요합니다."),
FORBIDDEN("ERR-UIWS-403", "접근 권한이 없습니다."), FORBIDDEN("ERR-UIWS-403", "접근 권한이 없습니다."),
NOT_FOUND("ERR-UIWS-404", "대상을 찾을 수 없습니다."), NOT_FOUND("ERR-UIWS-404", "대상을 찾을 수 없습니다."),
@ -37,6 +38,11 @@ public enum UiwsErrorCode {
VERIFY_TOKEN_INVALID("ERR-UIWS-2FA-401", "2차 검증 토큰이 유효하지 않거나 만료되었습니다."), VERIFY_TOKEN_INVALID("ERR-UIWS-2FA-401", "2차 검증 토큰이 유효하지 않거나 만료되었습니다."),
VERIFY_CODE_INVALID("ERR-UIWS-2FA-401C", "인증 코드가 올바르지 않거나 만료되었습니다."), VERIFY_CODE_INVALID("ERR-UIWS-2FA-401C", "인증 코드가 올바르지 않거나 만료되었습니다."),
ACCOUNT_LOCKED("ERR-UIWS-2FA-LOCK", "로그인 실패 횟수 초과로 계정이 잠겼습니다. 관리자에게 문의하세요."), ACCOUNT_LOCKED("ERR-UIWS-2FA-LOCK", "로그인 실패 횟수 초과로 계정이 잠겼습니다. 관리자에게 문의하세요."),
OTP_NOT_REGISTERED("ERR-UIWS-2FA-OTP404", "OTP가 등록되어 있지 않습니다. 먼저 등록을 진행하세요."),
// 비밀번호 변경(마이페이지)
PASSWORD_MISMATCH("ERR-UIWS-PW-401", "현재 비밀번호가 일치하지 않습니다."),
PASSWORD_SAME_AS_OLD("ERR-UIWS-PW-422", "새 비밀번호가 기존 비밀번호와 동일합니다."),
// system(권한관리) UIWS system 모듈 이식 // system(권한관리) UIWS system 모듈 이식
DUPLICATE_KEY("ERR-UIWS-SYS-409D", "이미 존재하는 항목입니다."), DUPLICATE_KEY("ERR-UIWS-SYS-409D", "이미 존재하는 항목입니다."),

View File

@ -24,6 +24,11 @@ public class UiwsProperties {
public static class Auth { public static class Auth {
/** 2FA 레이어 on/off. 기본 on(켜짐). off 시 기존 단일 JWT 로그인 흐름 유지(회귀 0). */ /** 2FA 레이어 on/off. 기본 on(켜짐). off 시 기존 단일 JWT 로그인 흐름 유지(회귀 0). */
private boolean twofaEnabled = true; private boolean twofaEnabled = true;
/**
* OTP(TOTP) 2단계 on/off. 기본 on. 로그인 2단계 우선순위:
* otpEnabled OTP, (off 이고 twofaEnabled) 이메일코드, ( off) 단일 로그인.
*/
private boolean otpEnabled = true;
/** 1차 통과 후 verify-token 유효시간(초). 계획서 300(5분). */ /** 1차 통과 후 verify-token 유효시간(초). 계획서 300(5분). */
private long verifyTokenValiditySeconds = 300; private long verifyTokenValiditySeconds = 300;
/** 이메일 인증코드 유효시간(초). 계획서 300(5분). */ /** 이메일 인증코드 유효시간(초). 계획서 300(5분). */

View File

@ -30,6 +30,8 @@ spring:
- classpath:db/93_esn_login_helper.sql - classpath:db/93_esn_login_helper.sql
# AI 플랫폼(Claude 전환) provider/모델 설정 시드 — esn_setting ai.* (멱등) # AI 플랫폼(Claude 전환) provider/모델 설정 시드 — esn_setting ai.* (멱등)
- classpath:db/94_seed_ai_config.sql - classpath:db/94_seed_ai_config.sql
# TOTP(OTP 2차 인증) — esn_user otp_enabled ALTER (멱등, otp_secret 은 91 에서 이미 추가)
- classpath:db/95_auth_otp.sql
web: web:
resources: resources:
static-locations: classpath:/static/ static-locations: classpath:/static/
@ -63,6 +65,19 @@ guardia:
secret: ${JWT_SECRET:ZioInfoEsnJwtSecret2026VeryLongSecretKeyForHS256AlgorithmAtLeast256Bits} secret: ${JWT_SECRET:ZioInfoEsnJwtSecret2026VeryLongSecretKeyForHS256AlgorithmAtLeast256Bits}
expiration: 86400000 expiration: 86400000
# UIWS 이식 인증 레이어(2FA/OTP). 미설정이어도 UiwsProperties 안전 기본값으로 동작.
# 로그인 2단계 우선순위: otp-enabled → OTP, (off 이고 twofa-enabled) → 이메일코드, (둘 다 off) → 단일 로그인.
esn:
uiws:
auth:
# OTP(TOTP) 2단계 on/off. off 로 두면 기존 이메일 2FA/단일 로그인 흐름 유지(회귀 0).
otp-enabled: ${ESN_OTP_ENABLED:true}
# 이메일 코드 2FA on/off. OTP on 이면 OTP 가 우선(이메일 미사용).
twofa-enabled: ${ESN_TWOFA_ENABLED:true}
verify-token-validity-seconds: ${ESN_VERIFY_TOKEN_TTL:300}
email-code-validity-seconds: ${ESN_EMAIL_CODE_TTL:300}
max-login-fail: ${ESN_MAX_LOGIN_FAIL:5}
management: management:
endpoints: endpoints:
web: web:

View File

@ -0,0 +1,13 @@
-- =====================================================================
-- TOTP(OTP 2차 인증) 이식 — esn_user 멱등 ALTER
-- =====================================================================
-- otp_secret 은 db/91_uiws_port.sql 에서 이미 ADD(VARCHAR(255)).
-- 여기서는 등록 확정 플래그 otp_enabled 만 추가한다.
-- schema-locations 마지막에 등재(mode=always + continue-on-error) → 멱등 재실행 안전.
ALTER TABLE esn_user ADD COLUMN IF NOT EXISTS otp_enabled BOOLEAN DEFAULT false;
COMMENT ON COLUMN esn_user.otp_secret IS 'TOTP 시크릿(보류/확정 공용). API 응답·로그 미노출.';
COMMENT ON COLUMN esn_user.otp_enabled IS 'OTP 등록 확정 여부(최초 로그인 verify 성공 시 true).';
-- end 95_auth_otp.sql

View File

@ -0,0 +1,18 @@
-- =====================================================================
-- [1회 운영 작업] 전 사용자 OTP 초기화 — 멱등
-- =====================================================================
-- 목적: 인증 강화 전환 시점에 기존 사용자 OTP 를 초기화한다.
-- 다음 로그인부터 OTP_SETUP(QR 재등록) 플로우를 타게 한다(UIMS 방식).
--
-- ★ 이 파일은 schema-locations 에 등재하지 않는다(자동 재실행 금지).
-- 운영자가 서버에서 1회 수동 적용:
-- psql -U guardia_esn_user -d guardia_esn_db -f db/ops_otp_reset_all.sql
--
-- 멱등: 여러 번 실행해도 결과 동일(모두 NULL/false).
UPDATE esn_user
SET otp_secret = NULL,
otp_enabled = false
WHERE otp_secret IS NOT NULL OR otp_enabled IS DISTINCT FROM false;
-- end ops_otp_reset_all.sql

View File

@ -23,12 +23,13 @@
<result property="loginFailCount" column="login_fail_count"/> <result property="loginFailCount" column="login_fail_count"/>
<result property="locked" column="locked"/> <result property="locked" column="locked"/>
<result property="otpSecret" column="otp_secret"/> <result property="otpSecret" column="otp_secret"/>
<result property="otpEnabled" column="otp_enabled"/>
</resultMap> </resultMap>
<select id="findByUsername" resultMap="userMap"> <select id="findByUsername" resultMap="userMap">
SELECT id, tenant_code, username, password_hash, role, email, phone, SELECT id, tenant_code, username, password_hash, role, email, phone,
is_active, last_login_at, created_at, name, approval_status, is_active, last_login_at, created_at, name, approval_status,
email_verify_code, email_verify_expire, login_fail_count, locked, otp_secret email_verify_code, email_verify_expire, login_fail_count, locked, otp_secret, otp_enabled
FROM esn_user FROM esn_user
WHERE username = #{username} WHERE username = #{username}
</select> </select>
@ -86,7 +87,7 @@
<select id="findByUsernameNameEmail" resultMap="userMap"> <select id="findByUsernameNameEmail" resultMap="userMap">
SELECT id, tenant_code, username, password_hash, role, email, phone, SELECT id, tenant_code, username, password_hash, role, email, phone,
is_active, last_login_at, created_at, name, approval_status, is_active, last_login_at, created_at, name, approval_status,
email_verify_code, email_verify_expire, login_fail_count, locked, otp_secret email_verify_code, email_verify_expire, login_fail_count, locked, otp_secret, otp_enabled
FROM esn_user FROM esn_user
WHERE username = #{username} AND name = #{name} AND email = #{email} WHERE username = #{username} AND name = #{name} AND email = #{email}
</select> </select>
@ -95,4 +96,18 @@
UPDATE esn_user SET password_hash = #{passwordHash} WHERE username = #{username} UPDATE esn_user SET password_hash = #{passwordHash} WHERE username = #{username}
</update> </update>
<!-- ── TOTP(OTP 2차 인증) 이식: 시크릿/등록 확정 갱신 (멱등 UPDATE) ───────────────── -->
<update id="updateOtpSecret">
UPDATE esn_user SET otp_secret = #{secret} WHERE username = #{username}
</update>
<update id="enableOtp">
UPDATE esn_user SET otp_enabled = true WHERE username = #{username}
</update>
<update id="disableOtp">
UPDATE esn_user SET otp_secret = NULL, otp_enabled = false WHERE username = #{username}
</update>
</mapper> </mapper>

View File

@ -58,4 +58,9 @@
<delete id="delete">DELETE FROM esn_user WHERE id = #{id}</delete> <delete id="delete">DELETE FROM esn_user WHERE id = #{id}</delete>
<!-- 관리자 OTP 초기화: 시크릿 폐기 + 등록 해제(다음 로그인 시 OTP_SETUP 재등록). -->
<update id="resetOtpById">
UPDATE esn_user SET otp_secret = NULL, otp_enabled = false WHERE id = #{id}
</update>
</mapper> </mapper>

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

@ -4,9 +4,9 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>zioinfo-esn — ESL 통합 관리 플랫폼</title> <title>zioinfo-esn — ESL 통합 관리 플랫폼</title>
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/x-icon" href="/favicon.ico" />
<script type="module" crossorigin src="/assets/index-Brirvf7y.js"></script> <script type="module" crossorigin src="/assets/index-VsUCSXRR.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D3eowNUw.css"> <link rel="stylesheet" crossorigin href="/assets/index-D38igG4E.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>zioinfo-esn — ESL 통합 관리 플랫폼</title> <title>zioinfo-esn — ESL 통합 관리 플랫폼</title>
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/x-icon" href="/favicon.ico" />
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@ -21,6 +21,7 @@ import UpdateQueueList from './pages/UpdateQueueList'
import AuditLog from './pages/AuditLog' import AuditLog from './pages/AuditLog'
import SystemSettings from './pages/SystemSettings' import SystemSettings from './pages/SystemSettings'
import MobileApp from './pages/MobileApp' import MobileApp from './pages/MobileApp'
import MyPage from './pages/MyPage'
import WorklogList from './pages/uiws/WorklogList' import WorklogList from './pages/uiws/WorklogList'
import ScheduleCalendar from './pages/uiws/ScheduleCalendar' import ScheduleCalendar from './pages/uiws/ScheduleCalendar'
import MessageBox from './pages/uiws/MessageBox' import MessageBox from './pages/uiws/MessageBox'
@ -42,6 +43,7 @@ export default function App() {
<Route path="/login" element={<Login />} /> <Route path="/login" element={<Login />} />
<Route element={<Layout />}> <Route element={<Layout />}>
<Route path="/dashboard" element={<Dashboard />} /> <Route path="/dashboard" element={<Dashboard />} />
<Route path="/mypage" element={<MyPage />} />
<Route path="/stores" element={<StoreList />} /> <Route path="/stores" element={<StoreList />} />
<Route path="/templates" element={<TemplateList />} /> <Route path="/templates" element={<TemplateList />} />
<Route path="/pos-cvt" element={<PosCvtList />} /> <Route path="/pos-cvt" element={<PosCvtList />} />

View File

@ -29,6 +29,16 @@ export const login = (username: string, password: string) =>
export const getMe = () => api.get('/api/auth/me') export const getMe = () => api.get('/api/auth/me')
export const logout = () => api.post('/api/auth/logout') export const logout = () => api.post('/api/auth/logout')
// ── 2FA / OTP / 계정 보안 (마이페이지, access 토큰 필요) ─────────────────────
// 보안 불변: setup 응답(secret/qrImage)은 화면 표시용만 — 로그/저장 금지. raw 응답 반환(호출부 res.data.data).
export const otpSetup = () => api.post('/api/auth/otp/setup')
export const otpConfirm = (code: string) => api.post('/api/auth/otp/confirm', { code })
export const otpDisable = () => api.post('/api/auth/otp/disable')
export const changePassword = (currentPassword: string, newPassword: string) =>
api.post('/api/auth/change-password', { currentPassword, newPassword })
// 관리자 OTP 초기화 — 대상 사용자 OTP 해제(다음 로그인 시 재등록). 시크릿 미조회.
export const adminOtpReset = (id: number) => unwrap(api.post(`/api/users/${id}/otp-reset`))
// ── Auth: 로그인 보조 3종 (공개 — 회원가입 승인대기 / 아이디찾기 / 비밀번호 초기화) ── // ── Auth: 로그인 보조 3종 (공개 — 회원가입 승인대기 / 아이디찾기 / 비밀번호 초기화) ──
export interface SignupReq { export interface SignupReq {
username: string; password: string; name: string; username: string; password: string; name: string;

View File

@ -6,9 +6,13 @@ import api from './client'
* : { success, message, data }. res.data.data . * : { success, message, data }. res.data.data .
*/ */
// ── 2FA // ── 2FA (ESN auth prefix /api/auth)
// verify2fa: 이메일 인증코드 경로(하위호환). verifyMethod=EMAIL 일 때 사용.
export const verify2fa = (verifyToken: string, code: string) => export const verify2fa = (verifyToken: string, code: string) =>
api.post('/api/auth/verify', { verifyToken, code }) api.post('/api/auth/verify', { verifyToken, code })
// verifyOtp: Authenticator(TOTP) 경로. verifyMethod=OTP | OTP_SETUP 일 때 사용.
export const verifyOtp = (verifyToken: string, code: string) =>
api.post('/api/auth/verify-otp', { verifyToken, code })
// ── 쪽지(message) // ── 쪽지(message)
export const sendMessage = (body: object) => api.post('/api/messages', body) export const sendMessage = (body: object) => api.post('/api/messages', body)

View File

@ -1,4 +1,4 @@
import { useNavigate } from 'react-router-dom' import { useNavigate, Link } from 'react-router-dom'
import { LogOut, User } from 'lucide-react' import { LogOut, User } from 'lucide-react'
export default function Header() { export default function Header() {
@ -20,11 +20,11 @@ export default function Header() {
<div className="flex-1" /> <div className="flex-1" />
<span className="text-xs text-gray-400">: <span className="text-brand">{tenant}</span></span> <span className="text-xs text-gray-400">: <span className="text-brand">{tenant}</span></span>
<span className="text-xs text-gray-400">|</span> <span className="text-xs text-gray-400">|</span>
<div className="flex items-center gap-2 text-sm text-gray-300"> <Link to="/mypage" className="flex items-center gap-2 text-sm text-gray-300 hover:text-brand transition-colors" title="마이페이지">
<User size={14} /> <User size={14} />
<span>{user}</span> <span>{user}</span>
<span className="text-xs text-brand bg-brand/10 px-2 py-0.5 rounded">{role}</span> <span className="text-xs text-brand bg-brand/10 px-2 py-0.5 rounded">{role}</span>
</div> </Link>
<button onClick={handleLogout} className="flex items-center gap-1.5 text-gray-400 hover:text-white text-sm"> <button onClick={handleLogout} className="flex items-center gap-1.5 text-gray-400 hover:text-white text-sm">
<LogOut size={14} /> <LogOut size={14} />

View File

@ -1,15 +1,20 @@
import { useState } from 'react' import { useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { login, signup, findId, resetPassword } from '../api/client' import { login, signup, findId, resetPassword } from '../api/client'
import { verify2fa } from '../api/uiws' import { verify2fa, verifyOtp } from '../api/uiws'
type HelperMode = null | 'signup' | 'findId' | 'resetPw' type HelperMode = null | 'signup' | 'findId' | 'resetPw'
type VerifyMethod = 'OTP' | 'OTP_SETUP' | 'EMAIL'
/** /**
* . UIWS 2FA : * . UIWS 2FA + Authenticator(OTP) :
* - 2FA off ({ twofa:"false", token }) ( 0). * - 2FA off ({ twofa:"false", token }) ( 0).
* - 2FA on ({ twofa:"true", verifyToken, maskedEmail }) . * - 2FA on ({ twofa:"true", verifyToken, verifyMethod, ... }) 2 .
* · OTP : Authenticator 6 /verify-otp
* · OTP_SETUP : QR(qrImage)+(secret) 6 /verify-otp
* · EMAIL(): 6 /verify ()
* ESN Tailwind (bg-card/text-brand/border-edge) . * ESN Tailwind (bg-card/text-brand/border-edge) .
* 보안: OTP secret/QR· / .
*/ */
export default function Login() { export default function Login() {
const navigate = useNavigate() const navigate = useNavigate()
@ -19,12 +24,18 @@ export default function Login() {
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [step, setStep] = useState<'login' | 'verify'>('login') const [step, setStep] = useState<'login' | 'verify'>('login')
const [verifyToken, setVerifyToken] = useState('') const [verifyToken, setVerifyToken] = useState('')
const [verifyMethod, setVerifyMethod] = useState<VerifyMethod>('EMAIL')
const [maskedEmail, setMaskedEmail] = useState('') const [maskedEmail, setMaskedEmail] = useState('')
const [qrImage, setQrImage] = useState('') // OTP_SETUP 등록 순간만 존재
const [secret, setSecret] = useState('') // OTP_SETUP 등록 순간만 존재
const [code, setCode] = useState('') const [code, setCode] = useState('')
// ── 로그인 보조 3종 (회원가입/아이디찾기/비밀번호 재설정) 모달 ────────────────── // ── 로그인 보조 3종 (회원가입/아이디찾기/비밀번호 재설정) 모달 ──────────────────
const [helper, setHelper] = useState<HelperMode>(null) const [helper, setHelper] = useState<HelperMode>(null)
const isOtp = verifyMethod === 'OTP' || verifyMethod === 'OTP_SETUP'
const isSetup = verifyMethod === 'OTP_SETUP'
function finishLogin(token: string) { function finishLogin(token: string) {
localStorage.setItem('esn_token', token) localStorage.setItem('esn_token', token)
localStorage.setItem('esn_user', username) localStorage.setItem('esn_user', username)
@ -40,7 +51,11 @@ export default function Login() {
const data = res.data?.data const data = res.data?.data
if (data?.twofa === 'true') { if (data?.twofa === 'true') {
setVerifyToken(data.verifyToken) setVerifyToken(data.verifyToken)
setVerifyMethod((data.verifyMethod as VerifyMethod) || 'EMAIL')
setMaskedEmail(data.maskedEmail || '') setMaskedEmail(data.maskedEmail || '')
setQrImage(data.qrImage || '')
setSecret(data.secret || '')
setCode('')
setStep('verify') setStep('verify')
} else { } else {
if (!data?.token) throw new Error('토큰 없음') if (!data?.token) throw new Error('토큰 없음')
@ -58,8 +73,13 @@ export default function Login() {
setLoading(true) setLoading(true)
setError('') setError('')
try { try {
const res = await verify2fa(verifyToken, code) // OTP·OTP_SETUP 은 /verify-otp, 이메일은 /verify(하위호환)
finishLogin(res.data?.data?.token) const res = isOtp
? await verifyOtp(verifyToken, code)
: await verify2fa(verifyToken, code)
const token = res.data?.data?.token
if (!token) throw new Error('토큰 없음')
finishLogin(token)
} catch { } catch {
setError('인증 코드가 올바르지 않거나 만료되었습니다.') setError('인증 코드가 올바르지 않거나 만료되었습니다.')
} finally { } finally {
@ -67,6 +87,11 @@ export default function Login() {
} }
} }
function backToLogin() {
setStep('login'); setCode(''); setError('')
setQrImage(''); setSecret('') // 시크릿 잔존 방지
}
return ( return (
<div className="min-h-screen bg-ink flex items-center justify-center"> <div className="min-h-screen bg-ink flex items-center justify-center">
<div className="w-full max-w-sm"> <div className="w-full max-w-sm">
@ -119,31 +144,62 @@ export default function Login() {
</form> </form>
) : ( ) : (
<form onSubmit={handleVerify} className="bg-card border border-edge rounded-lg p-6 space-y-4"> <form onSubmit={handleVerify} className="bg-card border border-edge rounded-lg p-6 space-y-4">
{isOtp ? (
isSetup ? (
<>
<p className="text-xs text-gray-400">
. QR을 Authenticator (Google·Microsoft)
6 .
</p>
{qrImage && (
<div className="flex justify-center">
<img src={qrImage} alt="OTP QR" width={176} height={176}
style={{ background: '#fff', padding: 8, borderRadius: 8 }} />
</div>
)}
{secret && (
<div>
<div className="text-[11px] text-gray-500 mb-1">QR </div>
<code className="block text-xs text-accent bg-panel border border-edge rounded px-2 py-1.5 break-all select-all">
{secret}
</code>
</div>
)}
</>
) : (
<p className="text-xs text-gray-400">
Authenticator 6 .
</p>
)
) : (
<p className="text-xs text-gray-400"> <p className="text-xs text-gray-400">
{maskedEmail ? `${maskedEmail} 로 발송된 인증코드를 입력하세요.` : '발송된 인증코드를 입력하세요.'} {maskedEmail ? `${maskedEmail} 로 발송된 인증코드를 입력하세요.` : '발송된 인증코드를 입력하세요.'}
</p> </p>
)}
<div> <div>
<label className="block text-xs text-gray-400 mb-1"> 6</label> <label className="block text-xs text-gray-400 mb-1"> 6</label>
<input <input
inputMode="numeric" inputMode="numeric"
autoComplete="one-time-code"
maxLength={6} maxLength={6}
value={code} value={code}
onChange={e => setCode(e.target.value)} onChange={e => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
placeholder="000000" placeholder="000000"
autoFocus
className="w-full bg-panel border border-edge rounded px-3 py-2 text-sm text-white text-center tracking-[0.4em] placeholder-gray-600 focus:border-brand focus:outline-none" className="w-full bg-panel border border-edge rounded px-3 py-2 text-sm text-white text-center tracking-[0.4em] placeholder-gray-600 focus:border-brand focus:outline-none"
/> />
</div> </div>
{error && <p className="text-red-400 text-xs">{error}</p>} {error && <p className="text-red-400 text-xs">{error}</p>}
<button <button
type="submit" type="submit"
disabled={loading} disabled={loading || code.length !== 6}
className="w-full bg-brand hover:bg-brand2 text-white py-2 rounded text-sm font-medium transition-colors disabled:opacity-50" className="w-full bg-brand hover:bg-brand2 text-white py-2 rounded text-sm font-medium transition-colors disabled:opacity-50"
> >
{loading ? '인증 중...' : '인증하고 로그인'} {loading ? '인증 중...' : isSetup ? '등록하고 로그인' : '인증하고 로그인'}
</button> </button>
<button <button
type="button" type="button"
onClick={() => { setStep('login'); setCode(''); setError('') }} onClick={backToLogin}
className="w-full border border-edge text-gray-400 hover:text-white py-2 rounded text-sm transition-colors" className="w-full border border-edge text-gray-400 hover:text-white py-2 rounded text-sm transition-colors"
> >

View File

@ -0,0 +1,256 @@
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 (//) + .
* ESN (bg-card/bg-panel/border-edge/text-brand/accent) .
* 보안: 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?.message || fallback
}
export default function MyPage() {
const [username, setUsername] = useState('')
const [otpEnabled, setOtpEnabled] = useState<boolean | null>(null)
// ── 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?.data || {}
setUsername(me.username || localStorage.getItem('esn_user') || '')
// 백엔드가 상태를 내려주면 반영, 없으면 null(중립 표시)
if (typeof me.otpEnabled === 'boolean') setOtpEnabled(me.otpEnabled)
else if (typeof me.verifyMethod === 'string') setOtpEnabled(me.verifyMethod === 'OTP')
}).catch(() => {})
useEffect(() => { loadMe() }, [])
const startSetup = async () => {
setOtpMsg(null); setOtpBusy(true)
try {
const res = await otpSetup()
const d = res.data?.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'); setOtpEnabled(true)
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(''); setOtpEnabled(false)
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 bg-panel border border-edge text-sm text-white focus:border-brand 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 px-3 py-2 mb-3 border ${
m.ok
? 'bg-accent/10 border-accent/40 text-accent'
: 'bg-red-500/10 border-red-500/40 text-red-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-gray-400 mb-6">{username && <>: <span className="text-gray-200">{username}</span></>}</p>
{/* ── OTP 2차 인증 ──────────────────────────────────────────────── */}
<section className="bg-card border border-edge rounded-lg p-5 mb-6">
<div className="flex items-center gap-2 mb-1">
<ShieldCheck size={18} className="text-brand" />
<h2 className="font-semibold text-white">2 Authenticator(OTP)</h2>
</div>
<p className="text-xs text-gray-400 mb-4">
Google·Microsoft Authenticator 6 2 .
{otpEnabled !== null && (
<span className="ml-2">
:{' '}
<span className={otpEnabled ? 'text-accent' : 'text-gray-300'}>
{otpEnabled ? 'OTP 사용 중' : '미설정'}
</span>
</span>
)}
</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 bg-brand hover:bg-brand2 text-white text-sm font-semibold transition-colors disabled:opacity-60">
<QrCode size={16} /> {otpBusy ? '발급 중…' : otpEnabled ? 'OTP 재설정 시작' : 'OTP 등록 시작'}
</button>
{otpEnabled && (
<button onClick={disableOtp} disabled={otpBusy}
className="px-3 py-2 rounded border border-red-500/50 text-red-300 text-sm hover:bg-red-500/10 disabled:opacity-60">
Authenticator
</button>
)}
</div>
)}
{phase === 'setup' && (
<div>
<ol className="list-decimal list-inside text-sm text-gray-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-gray-500 mb-1"> </div>
<code className="block text-xs text-accent bg-panel border border-edge rounded px-2 py-1.5 break-all select-all">
{secret}
</code>
</div>
)}
<label className="block text-xs text-gray-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 bg-brand hover:bg-brand2 text-white text-sm font-semibold transition-colors disabled:opacity-60">
{otpBusy ? '확인 중…' : '코드 확인 · 활성화'}
</button>
<button onClick={cancelSetup} disabled={otpBusy}
className="px-4 py-2 rounded border border-edge text-sm text-gray-300 hover:text-white">
</button>
</div>
</div>
)}
{phase === 'done' && (
<div className="flex flex-wrap gap-2">
<button onClick={disableOtp} disabled={otpBusy}
className="px-3 py-2 rounded border border-red-500/50 text-red-300 text-sm hover:bg-red-500/10 disabled:opacity-60">
Authenticator
</button>
</div>
)}
</section>
{/* ── 비밀번호 변경 ─────────────────────────────────────────────── */}
<section className="bg-card border border-edge rounded-lg p-5">
<div className="flex items-center gap-2 mb-4">
<Lock size={18} className="text-brand" />
<h2 className="font-semibold text-white"> </h2>
</div>
<StatusMsg m={pwMsg} />
<div className="space-y-3">
<div>
<label className="block text-xs text-gray-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-gray-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-gray-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-gray-400 cursor-pointer select-none">
<button type="button" onClick={() => setShowPw(!showPw)} className="text-gray-400 hover:text-brand">
{showPw ? <EyeOff size={15} /> : <Eye size={15} />}
</button>
</label>
<button onClick={submitPw} disabled={pwBusy}
className="px-4 py-2 rounded bg-brand hover:bg-brand2 text-white text-sm font-semibold transition-colors disabled:opacity-60">
{pwBusy ? '변경 중…' : '비밀번호 변경'}
</button>
</div>
</section>
</div>
)
}

View File

@ -1,7 +1,7 @@
import { useState } from 'react' import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Plus, Edit, Trash2, X } from 'lucide-react' import { Plus, Edit, Trash2, X, RefreshCw } from 'lucide-react'
import { getUsers, createUser, updateUser, deleteUser } from '../api/client' import { getUsers, createUser, updateUser, deleteUser, adminOtpReset } from '../api/client'
export default function UserList() { export default function UserList() {
const qc = useQueryClient() const qc = useQueryClient()
@ -22,6 +22,12 @@ export default function UserList() {
onSuccess: () => qc.invalidateQueries({ queryKey: ['users'] }), onSuccess: () => qc.invalidateQueries({ queryKey: ['users'] }),
}) })
// 관리자 OTP 초기화 — 대상 사용자 OTP 해제(다음 로그인 재등록). 시크릿 미조회.
const otpResetMut = useMutation({
mutationFn: adminOtpReset,
onSuccess: () => qc.invalidateQueries({ queryKey: ['users'] }),
})
if (isLoading) return <div className="text-gray-400 p-8"> ...</div> if (isLoading) return <div className="text-gray-400 p-8"> ...</div>
return ( return (
@ -66,9 +72,14 @@ export default function UserList() {
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<div className="flex gap-2"> <div className="flex gap-2">
<button onClick={() => setModal(u)} className="text-brand hover:text-blue-300"><Edit size={14} /></button> <button onClick={() => setModal(u)} title="수정" className="text-brand hover:text-blue-300"><Edit size={14} /></button>
<button onClick={() => {
if (confirm(`'${u.username}' 사용자의 OTP를 초기화하시겠습니까?\n초기화하면 다음 로그인 시 재등록해야 합니다.`))
otpResetMut.mutate(u.id)
}}
title="OTP 초기화" className="text-gray-400 hover:text-brand"><RefreshCw size={14} /></button>
<button onClick={() => { if(confirm('삭제?')) deleteMut.mutate(u.id) }} <button onClick={() => { if(confirm('삭제?')) deleteMut.mutate(u.id) }}
className="text-red-400 hover:text-red-300"><Trash2 size={14} /></button> title="삭제" className="text-red-400 hover:text-red-300"><Trash2 size={14} /></button>
</div> </div>
</td> </td>
</tr> </tr>