feat: Phase 3 - 자체 인증 + 트리 기반 RBAC + 감사로그 + 데모 계정

Phase 3-1~10: 백엔드
- pom.xml에 spring-boot-starter-aop 추가
- JPA 엔티티 12종 + Repository 9종
  (User/LoginHistory/Role/UserRole/PermTree/Perm/AuditLog/AccessLog 등)
- PermResolver: wing 프로젝트의 permResolver.ts를 Java로 이식
  - 트리 BFS + 부모 READ 게이팅 + 다중 역할 OR 합집합 + 부모 fallback
- PermissionService: Caffeine 캐싱 (TTL 10분)
- JwtService + JwtAuthFilter (HttpOnly 쿠키 + Authorization 헤더 fallback)
- AuthProvider 인터페이스 + PasswordAuthProvider (BCrypt + 5회 잠금)
  - REQUIRES_NEW + noRollbackFor로 fail_cnt 증가 보존
- AuthService + LoginAuditWriter (REQUIRES_NEW로 실패 기록 보존)
- AuthController: /api/auth/login, /logout, /me
- @RequirePermission 어노테이션 + PermissionAspect (메서드 권한 체크)
- @Auditable 어노테이션 + AuditAspect (의사결정 자동 기록)
- AccessLogFilter: 모든 HTTP 요청 비동기 기록 (BlockingQueue)
- SecurityConfig 본격 도입 (CORS + JWT 필터 + 401/403 핸들러)

Phase 3-10: 데모 계정
- V006__demo_accounts.sql: 5개 데모 계정 (admin/operator/analyst/field/viewer)
  + 역할 매핑 (PLACEHOLDER 해시)
- AccountSeeder.java: 시동 시 BCrypt 해시 시드 (PLACEHOLDER만 갱신)
- 데모 계정도 실제 권한, 로그인 이력, 감사로그 기록 대상

Phase 3-11: 백엔드 검증 완료
- admin/operator/viewer 로그인 성공
- 권한 매트릭스: ADMIN(49), OPERATOR(40), VIEWER(35)
- 트리 상속 검증: detection READ → 자식 4개 자동 상속
- 잘못된 비밀번호 → fail_cnt 증가 + login_hist FAILED 기록
- 정상 로그인 → fail_cnt 0 초기화
- 모든 요청 access_log에 비동기 기록

V001/V002: CHAR(1) → VARCHAR(1) 변경 (Hibernate validate 호환성)

Phase 3-12: 프론트엔드 연동
- services/authApi.ts: 백엔드 호출 클라이언트 (login/logout/me)
- AuthContext.tsx: 백엔드 API 통합 + 트리 기반 hasPermission
  + 부모 fallback (예: detection:gear-detection 미등록 시 detection 검사)
  + 30분 세션 타임아웃 유지
- DemoQuickLogin.tsx: 데모 퀵로그인 컴포넌트 분리
  + isDemoLoginEnabled() = VITE_SHOW_DEMO_LOGIN === 'true'
  + 데모 클릭 시에도 정상 백엔드 인증 플로우 사용
- LoginPage.tsx: 백엔드 인증 호출 + DemoQuickLogin 통합
  + 에러 메시지 한국어 변환 (WRONG_PASSWORD:N, ACCOUNT_LOCKED 등)
  + GPKI/SSO 탭은 disabled (Phase 9 도입 예정)
- frontend/.env.development: VITE_SHOW_DEMO_LOGIN=true
- frontend/.env.production: VITE_SHOW_DEMO_LOGIN=true (현재 단계)
- .gitignore에 frontend/.env.{development,production} 예외 추가

설계 핵심:
- 데모 계정은 백엔드 DB에 실제 권한 부여 + 로그인/감사 기록 대상
- DemoQuickLogin 컴포넌트는 환경변수로 토글 가능하도록 구조 분리
- 향후 운영 배포 시 .env.production만 false로 변경하면 데모 영역 숨김

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
htlee 2026-04-07 09:29:52 +09:00
부모 04dfdf2d36
커밋 b0c9a9fffb
47개의 변경된 파일2279개의 추가작업 그리고 250개의 파일을 삭제

3
.gitignore vendored
파일 보기

@ -22,6 +22,9 @@ Thumbs.db
.env
.env.*
!.env.example
# 프론트엔드 환경별 설정 (Vite VITE_* 변수, 배포 빌드에 필요)
!frontend/.env.development
!frontend/.env.production
secrets/
# === Debug ===

파일 보기

@ -54,6 +54,10 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>

파일 보기

@ -0,0 +1,60 @@
package gc.mda.kcg.audit;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import java.time.OffsetDateTime;
import java.util.UUID;
@Entity
@Table(name = "auth_access_log", schema = "kcg")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class AccessLog {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "access_sn")
private Long accessSn;
@JdbcTypeCode(SqlTypes.UUID)
@Column(name = "user_id")
private UUID userId;
@Column(name = "user_acnt", length = 50)
private String userAcnt;
@Column(name = "http_method", length = 10)
private String httpMethod;
@Column(name = "request_path", length = 500)
private String requestPath;
@Column(name = "query_string", columnDefinition = "text")
private String queryString;
@Column(name = "status_code")
private Integer statusCode;
@Column(name = "duration_ms")
private Integer durationMs;
@Column(name = "ip_address", length = 45)
private String ipAddress;
@Column(name = "user_agent", columnDefinition = "text")
private String userAgent;
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt;
@PrePersist
void prePersist() {
if (createdAt == null) createdAt = OffsetDateTime.now();
}
}

파일 보기

@ -0,0 +1,106 @@
package gc.mda.kcg.audit;
import gc.mda.kcg.auth.AuthPrincipal;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.annotation.Order;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.Executors;
/**
* 모든 HTTP 요청을 auth_access_log에 기록.
* 비동기 기반 요청 처리 지연 최소화.
*/
@Slf4j
@Component
@Order(100) // JwtAuthFilter(Spring 기본 -100) 이후 실행
@RequiredArgsConstructor
public class AccessLogFilter extends OncePerRequestFilter {
private final AccessLogRepository accessLogRepository;
private static final BlockingQueue<AccessLog> QUEUE = new ArrayBlockingQueue<>(10000);
private static volatile boolean workerStarted = false;
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
throws ServletException, IOException {
long start = System.currentTimeMillis();
try {
chain.doFilter(req, res);
} finally {
ensureWorkerStarted();
try {
AuthPrincipal principal = currentPrincipal();
AccessLog log = AccessLog.builder()
.userId(principal != null ? principal.getUserId() : null)
.userAcnt(principal != null ? principal.getUserAcnt() : null)
.httpMethod(req.getMethod())
.requestPath(req.getRequestURI())
.queryString(req.getQueryString())
.statusCode(res.getStatus())
.durationMs((int) (System.currentTimeMillis() - start))
.ipAddress(extractIp(req))
.userAgent(req.getHeader("User-Agent"))
.build();
QUEUE.offer(log);
} catch (Exception ignored) {
// 접근 로그 실패가 응답을 막지 않도록
}
}
}
@Override
protected boolean shouldNotFilter(HttpServletRequest req) {
String path = req.getRequestURI();
return path.startsWith("/actuator/health") || path.startsWith("/error") || path.equals("/favicon.ico");
}
private void ensureWorkerStarted() {
if (workerStarted) return;
synchronized (AccessLogFilter.class) {
if (workerStarted) return;
workerStarted = true;
Executors.newSingleThreadExecutor(r -> {
Thread t = new Thread(r, "access-log-writer");
t.setDaemon(true);
return t;
}).submit(() -> {
while (true) {
try {
AccessLog log = QUEUE.take();
accessLogRepository.save(log);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
} catch (Exception e) {
AccessLogFilter.log.error("AccessLog 저장 실패", e);
}
}
});
}
}
private AuthPrincipal currentPrincipal() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.getPrincipal() instanceof AuthPrincipal p) return p;
return null;
}
private String extractIp(HttpServletRequest req) {
String fwd = req.getHeader("X-Forwarded-For");
if (fwd != null && !fwd.isBlank()) return fwd.split(",")[0].trim();
return req.getRemoteAddr();
}
}

파일 보기

@ -0,0 +1,9 @@
package gc.mda.kcg.audit;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
public interface AccessLogRepository extends JpaRepository<AccessLog, Long> {
Page<AccessLog> findAllByOrderByCreatedAtDesc(Pageable pageable);
}

파일 보기

@ -0,0 +1,62 @@
package gc.mda.kcg.audit;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import java.time.OffsetDateTime;
import java.util.Map;
import java.util.UUID;
@Entity
@Table(name = "auth_audit_log", schema = "kcg")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class AuditLog {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "audit_sn")
private Long auditSn;
@JdbcTypeCode(SqlTypes.UUID)
@Column(name = "user_id")
private UUID userId;
@Column(name = "user_acnt", length = 50)
private String userAcnt;
@Column(name = "action_cd", nullable = false, length = 50)
private String actionCd;
@Column(name = "resource_type", length = 50)
private String resourceType;
@Column(name = "resource_id", length = 100)
private String resourceId;
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "detail", columnDefinition = "jsonb")
private Map<String, Object> detail;
@Column(name = "ip_address", length = 45)
private String ipAddress;
@Column(name = "result", length = 20)
private String result; // SUCCESS / FAILED
@Column(name = "fail_reason", columnDefinition = "text")
private String failReason;
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt;
@PrePersist
void prePersist() {
if (createdAt == null) createdAt = OffsetDateTime.now();
}
}

파일 보기

@ -0,0 +1,13 @@
package gc.mda.kcg.audit;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.UUID;
public interface AuditLogRepository extends JpaRepository<AuditLog, Long> {
Page<AuditLog> findAllByOrderByCreatedAtDesc(Pageable pageable);
Page<AuditLog> findByUserIdOrderByCreatedAtDesc(UUID userId, Pageable pageable);
Page<AuditLog> findByActionCdOrderByCreatedAtDesc(String actionCd, Pageable pageable);
}

파일 보기

@ -0,0 +1,104 @@
package gc.mda.kcg.audit.annotation;
import gc.mda.kcg.audit.AuditLog;
import gc.mda.kcg.audit.AuditLogRepository;
import gc.mda.kcg.auth.AuthPrincipal;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import java.util.HashMap;
import java.util.Map;
/**
* @Auditable 어노테이션 AOP가 메서드 실행 전후 auth_audit_log 기록.
* 성공/실패 모두 기록.
*/
@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class AuditAspect {
private final AuditLogRepository auditLogRepository;
@Around("@annotation(auditable)")
public Object audit(ProceedingJoinPoint pjp, Auditable auditable) throws Throwable {
AuthPrincipal principal = currentPrincipal();
String ipAddress = currentIp();
Map<String, Object> detail = new HashMap<>();
detail.put("method", ((MethodSignature) pjp.getSignature()).getMethod().getName());
// 파라미터 이름은 컴파일 옵션 -parameters 필요 - 여기서는 단순 인덱스로 기록
Object[] args = pjp.getArgs();
if (args != null) {
Map<String, Object> argMap = new HashMap<>();
for (int i = 0; i < args.length; i++) {
Object a = args[i];
if (a == null) continue;
if (a instanceof CharSequence || a instanceof Number || a instanceof Boolean) {
argMap.put("arg" + i, a.toString());
}
}
if (!argMap.isEmpty()) detail.put("args", argMap);
}
try {
Object result = pjp.proceed();
saveLog(principal, auditable, detail, ipAddress, "SUCCESS", null);
return result;
} catch (Throwable e) {
detail.put("exception", e.getClass().getSimpleName());
saveLog(principal, auditable, detail, ipAddress, "FAILED", e.getMessage());
throw e;
}
}
private void saveLog(AuthPrincipal principal, Auditable ann, Map<String, Object> detail,
String ipAddress, String result, String failReason) {
try {
AuditLog log = AuditLog.builder()
.userId(principal != null ? principal.getUserId() : null)
.userAcnt(principal != null ? principal.getUserAcnt() : null)
.actionCd(ann.action())
.resourceType(ann.resourceType())
.ipAddress(ipAddress)
.detail(detail)
.result(result)
.failReason(failReason)
.build();
auditLogRepository.save(log);
} catch (Exception ex) {
// 감사 기록 실패가 비즈니스를 막지 않도록
AuditAspect.log.error("감사로그 기록 실패", ex);
}
}
private AuthPrincipal currentPrincipal() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.getPrincipal() instanceof AuthPrincipal p) return p;
return null;
}
private String currentIp() {
try {
ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attrs == null) return null;
HttpServletRequest req = attrs.getRequest();
String fwd = req.getHeader("X-Forwarded-For");
if (fwd != null && !fwd.isBlank()) return fwd.split(",")[0].trim();
return req.getRemoteAddr();
} catch (Exception e) {
return null;
}
}
}

파일 보기

@ -0,0 +1,23 @@
package gc.mda.kcg.audit.annotation;
import java.lang.annotation.*;
/**
* 메서드 실행 감사로그 자동 기록.
*
* 사용 :
* <pre>
* @Auditable(action = "CONFIRM_PARENT", resourceType = "GEAR_GROUP")
* public void confirmParent(String groupKey, ...) { ... }
* </pre>
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Auditable {
/** 액션 코드 (예: CONFIRM_PARENT, REJECT_PARENT, USER_CREATE, ROLE_GRANT, PERM_UPDATE) */
String action();
/** 리소스 타입 (예: VESSEL, GROUP, USER, ROLE, SYSTEM) */
String resourceType() default "SYSTEM";
}

파일 보기

@ -0,0 +1,63 @@
package gc.mda.kcg.auth;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.ApplicationRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.password.PasswordEncoder;
import java.util.Map;
/**
* 데모 계정 5종의 BCrypt 해시 시드/갱신 (시동 1회).
* V006이 PLACEHOLDER로 계정을 만들었고, Runner가 실제 해시를 채워넣음.
*
* 데모 계정 비밀번호 (LoginPage의 DEMO_ACCOUNTS와 동일):
* admin / admin1234!
* operator / oper12345!
* analyst / anal12345!
* field / field1234!
* viewer / view12345!
*
* 기존 해시가 PLACEHOLDER가 아니면 갱신하지 않음 (운영 비밀번호 변경 보존).
*/
@Slf4j
@Configuration
@RequiredArgsConstructor
public class AccountSeeder {
private static final String PLACEHOLDER = "PLACEHOLDER_TO_BE_SEEDED";
private static final Map<String, String> DEMO_PASSWORDS = Map.of(
"admin", "admin1234!",
"operator", "oper12345!",
"analyst", "anal12345!",
"field", "field1234!",
"viewer", "view12345!"
);
@Bean
public ApplicationRunner seedDemoAccounts(UserRepository userRepository, PasswordEncoder passwordEncoder) {
return args -> {
int updated = 0;
for (Map.Entry<String, String> e : DEMO_PASSWORDS.entrySet()) {
String acnt = e.getKey();
String rawPw = e.getValue();
userRepository.findByUserAcnt(acnt).ifPresent(user -> {
if (PLACEHOLDER.equals(user.getPswdHash())) {
user.setPswdHash(passwordEncoder.encode(rawPw));
userRepository.save(user);
log.info("데모 계정 BCrypt 해시 시드: {}", acnt);
}
});
if (userRepository.findByUserAcnt(acnt)
.map(u -> u.getPswdHash() != null && !PLACEHOLDER.equals(u.getPswdHash()))
.orElse(false)) {
updated++;
}
}
log.info("AccountSeeder 완료: {}개 데모 계정 활성", updated);
};
}
}

파일 보기

@ -0,0 +1,107 @@
package gc.mda.kcg.auth;
import gc.mda.kcg.auth.dto.LoginRequest;
import gc.mda.kcg.auth.dto.UserInfoResponse;
import gc.mda.kcg.auth.provider.AuthProvider;
import gc.mda.kcg.config.AppProperties;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@Slf4j
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthService authService;
private final JwtService jwtService;
private final AppProperties appProperties;
@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody LoginRequest req,
HttpServletRequest http,
HttpServletResponse res) {
String ip = extractIp(http);
String ua = http.getHeader("User-Agent");
try {
AuthService.AuthResult result = authService.login(req.account(), req.password(), ip, ua);
User user = result.user();
var roles = authService.getUserInfo(user.getUserId()).roles();
String token = jwtService.generateToken(user.getUserId(), user.getUserAcnt(), user.getUserNm(), roles);
Cookie cookie = new Cookie(JwtAuthFilter.COOKIE_NAME, token);
cookie.setHttpOnly(true);
cookie.setPath("/");
cookie.setMaxAge((int) (jwtService.getExpirationMs() / 1000));
// Production에서는 secure=true 권장 (HTTPS)
cookie.setSecure(false);
res.addCookie(cookie);
return ResponseEntity.ok(toUserInfo(user.getUserId()));
} catch (AuthProvider.AuthenticationException e) {
log.warn("Login failed for {}: {}", req.account(), e.getReason());
return ResponseEntity.status(401).body(Map.of(
"error", "LOGIN_FAILED",
"reason", e.getReason()
));
}
}
@PostMapping("/logout")
public ResponseEntity<?> logout(HttpServletRequest http, HttpServletResponse res) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.getPrincipal() instanceof AuthPrincipal principal) {
authService.logout(principal.getUserId(), principal.getUserAcnt(), extractIp(http));
}
Cookie cookie = new Cookie(JwtAuthFilter.COOKIE_NAME, "");
cookie.setHttpOnly(true);
cookie.setPath("/");
cookie.setMaxAge(0);
res.addCookie(cookie);
return ResponseEntity.ok(Map.of("ok", true));
}
@GetMapping("/me")
public ResponseEntity<?> me() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !(auth.getPrincipal() instanceof AuthPrincipal principal)) {
return ResponseEntity.status(401).body(Map.of("error", "UNAUTHENTICATED"));
}
return ResponseEntity.ok(toUserInfo(principal.getUserId()));
}
private UserInfoResponse toUserInfo(java.util.UUID userId) {
AuthService.UserInfo info = authService.getUserInfo(userId);
User u = info.user();
return new UserInfoResponse(
u.getUserId().toString(),
u.getUserAcnt(),
u.getUserNm(),
u.getRnkpNm(),
u.getEmail(),
u.getUserSttsCd(),
u.getAuthProvider(),
info.roles(),
info.permissions()
);
}
private String extractIp(HttpServletRequest req) {
String fwd = req.getHeader("X-Forwarded-For");
if (fwd != null && !fwd.isBlank()) return fwd.split(",")[0].trim();
return req.getRemoteAddr();
}
}

파일 보기

@ -0,0 +1,19 @@
package gc.mda.kcg.auth;
import lombok.Builder;
import lombok.Getter;
import java.util.List;
import java.util.UUID;
/**
* 인증된 사용자 컨텍스트 (SecurityContextHolder의 principal 객체).
*/
@Getter
@Builder
public class AuthPrincipal {
private final UUID userId;
private final String userAcnt;
private final String userNm;
private final List<String> roles;
}

파일 보기

@ -0,0 +1,80 @@
package gc.mda.kcg.auth;
import gc.mda.kcg.audit.AuditLog;
import gc.mda.kcg.audit.AuditLogRepository;
import gc.mda.kcg.auth.provider.AuthProvider;
import gc.mda.kcg.auth.provider.PasswordAuthProvider;
import gc.mda.kcg.permission.PermissionService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* 인증 + 로그인 이력/감사 기록.
* 로그인 이력 기록은 LoginAuditWriter (REQUIRES_NEW 트랜잭션) 위임 실패 시에도 기록 보존.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AuthService {
private final PasswordAuthProvider passwordAuthProvider;
private final UserRepository userRepository;
private final AuditLogRepository auditLogRepository;
private final PermissionService permissionService;
private final LoginAuditWriter loginAuditWriter;
/**
* ID/PW 로그인.
* 트랜잭션을 별도 분리: 인증 실패가 외부 호출자(Controller)에서 catch되더라도
* LoginAuditWriter는 REQUIRES_NEW로 별도 커밋되어 기록이 남는다.
*/
public AuthResult login(String userAcnt, String password, String ipAddress, String userAgent) {
AuthProvider.AuthRequest req = new AuthProvider.AuthRequest(userAcnt, password, ipAddress, userAgent);
try {
User user = passwordAuthProvider.authenticate(req);
loginAuditWriter.recordSuccess(user.getUserId(), user.getUserAcnt(), ipAddress, userAgent);
return AuthResult.success(user);
} catch (AuthProvider.AuthenticationException e) {
loginAuditWriter.recordFailure(userAcnt, ipAddress, userAgent, e.getReason());
throw e;
}
}
/**
* 로그아웃 - 감사로그만 기록.
*/
@Transactional
public void logout(UUID userId, String userAcnt, String ipAddress) {
auditLogRepository.save(AuditLog.builder()
.userId(userId)
.userAcnt(userAcnt)
.actionCd("LOGOUT")
.resourceType("SYSTEM")
.resourceId("auth")
.ipAddress(ipAddress)
.result("SUCCESS")
.build());
}
@Transactional(readOnly = true)
public UserInfo getUserInfo(UUID userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new IllegalStateException("User not found: " + userId));
List<String> roles = permissionService.getRoleCodesByUserId(userId);
Map<String, List<String>> perms = permissionService.getResolvedPermissionsByUserId(userId);
return new UserInfo(user, roles, perms);
}
public record AuthResult(User user) {
public static AuthResult success(User user) { return new AuthResult(user); }
}
public record UserInfo(User user, List<String> roles, Map<String, List<String>> permissions) {}
}

파일 보기

@ -0,0 +1,82 @@
package gc.mda.kcg.auth;
import io.jsonwebtoken.Claims;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.List;
import java.util.UUID;
@Slf4j
@Component
@RequiredArgsConstructor
public class JwtAuthFilter extends OncePerRequestFilter {
public static final String COOKIE_NAME = "kcg_token";
public static final String AUTH_HEADER = "Authorization";
public static final String BEARER_PREFIX = "Bearer ";
private final JwtService jwtService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
String token = extractToken(request);
if (token != null && jwtService.isValid(token)) {
try {
Claims claims = jwtService.parseToken(token);
UUID userId = UUID.fromString(claims.getSubject());
String userAcnt = claims.get("acnt", String.class);
String userNm = claims.get("name", String.class);
@SuppressWarnings("unchecked")
List<String> roles = claims.get("roles", List.class);
AuthPrincipal principal = AuthPrincipal.builder()
.userId(userId)
.userAcnt(userAcnt)
.userNm(userNm)
.roles(roles)
.build();
List<SimpleGrantedAuthority> authorities = roles == null ? List.of() :
roles.stream().map(r -> new SimpleGrantedAuthority("ROLE_" + r)).toList();
UsernamePasswordAuthenticationToken auth =
new UsernamePasswordAuthenticationToken(principal, null, authorities);
auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(auth);
} catch (Exception e) {
log.debug("JWT processing failed: {}", e.getMessage());
}
}
chain.doFilter(request, response);
}
private String extractToken(HttpServletRequest req) {
// 1. Cookie 우선
if (req.getCookies() != null) {
for (Cookie c : req.getCookies()) {
if (COOKIE_NAME.equals(c.getName())) return c.getValue();
}
}
// 2. Authorization 헤더 fallback
String header = req.getHeader(AUTH_HEADER);
if (header != null && header.startsWith(BEARER_PREFIX)) {
return header.substring(BEARER_PREFIX.length());
}
return null;
}
}

파일 보기

@ -0,0 +1,74 @@
package gc.mda.kcg.auth;
import gc.mda.kcg.config.AppProperties;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Date;
import java.util.List;
import java.util.UUID;
@Slf4j
@Service
@RequiredArgsConstructor
public class JwtService {
private final AppProperties appProperties;
private SecretKey signingKey;
private SecretKey getSigningKey() {
if (signingKey == null) {
byte[] keyBytes = appProperties.getJwt().getSecret().getBytes(StandardCharsets.UTF_8);
signingKey = Keys.hmacShaKeyFor(keyBytes);
}
return signingKey;
}
public String generateToken(UUID userId, String userAcnt, String userNm, List<String> roles) {
Instant now = Instant.now();
Instant exp = now.plusMillis(appProperties.getJwt().getExpirationMs());
return Jwts.builder()
.subject(userId.toString())
.claim("acnt", userAcnt)
.claim("name", userNm)
.claim("roles", roles)
.issuedAt(Date.from(now))
.expiration(Date.from(exp))
.signWith(getSigningKey())
.compact();
}
public Claims parseToken(String token) {
return Jwts.parser()
.verifyWith(getSigningKey())
.build()
.parseSignedClaims(token)
.getPayload();
}
public UUID extractUserId(String token) {
return UUID.fromString(parseToken(token).getSubject());
}
public boolean isValid(String token) {
try {
parseToken(token);
return true;
} catch (Exception e) {
log.debug("Invalid JWT: {}", e.getMessage());
return false;
}
}
public long getExpirationMs() {
return appProperties.getJwt().getExpirationMs();
}
}

파일 보기

@ -0,0 +1,68 @@
package gc.mda.kcg.auth;
import gc.mda.kcg.audit.AuditLog;
import gc.mda.kcg.audit.AuditLogRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.util.UUID;
/**
* 로그인 이력 + 감사 로그 기록 전용 컴포넌트.
* REQUIRES_NEW 트랜잭션으로 분리 인증 실패로 외부 트랜잭션이 롤백되어도 기록 보존.
*/
@Component
@RequiredArgsConstructor
public class LoginAuditWriter {
private final LoginHistoryRepository loginHistoryRepository;
private final AuditLogRepository auditLogRepository;
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void recordSuccess(UUID userId, String userAcnt, String ipAddress, String userAgent) {
loginHistoryRepository.save(LoginHistory.builder()
.userId(userId)
.userAcnt(userAcnt)
.loginIp(ipAddress)
.userAgent(userAgent)
.result("SUCCESS")
.authProvider("PASSWORD")
.build());
auditLogRepository.save(AuditLog.builder()
.userId(userId)
.userAcnt(userAcnt)
.actionCd("LOGIN")
.resourceType("SYSTEM")
.resourceId("auth")
.ipAddress(ipAddress)
.result("SUCCESS")
.build());
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void recordFailure(String userAcnt, String ipAddress, String userAgent, String failReason) {
String result = failReason != null && failReason.startsWith("MAX_FAIL") ? "LOCKED" : "FAILED";
loginHistoryRepository.save(LoginHistory.builder()
.userAcnt(userAcnt)
.loginIp(ipAddress)
.userAgent(userAgent)
.result(result)
.failReason(failReason)
.authProvider("PASSWORD")
.build());
auditLogRepository.save(AuditLog.builder()
.userAcnt(userAcnt)
.actionCd("LOGIN")
.resourceType("SYSTEM")
.resourceId("auth")
.ipAddress(ipAddress)
.result("FAILED")
.failReason(failReason)
.build());
}
}

파일 보기

@ -0,0 +1,54 @@
package gc.mda.kcg.auth;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import java.time.OffsetDateTime;
import java.util.UUID;
@Entity
@Table(name = "auth_login_hist", schema = "kcg")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class LoginHistory {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "hist_sn")
private Long histSn;
@JdbcTypeCode(SqlTypes.UUID)
@Column(name = "user_id")
private UUID userId;
@Column(name = "user_acnt", length = 50)
private String userAcnt;
@Column(name = "login_dtm", nullable = false)
private OffsetDateTime loginDtm;
@Column(name = "login_ip", length = 45)
private String loginIp;
@Column(name = "user_agent", columnDefinition = "text")
private String userAgent;
@Column(name = "result", nullable = false, length = 20)
private String result; // SUCCESS, FAILED, LOCKED
@Column(name = "fail_reason", length = 255)
private String failReason;
@Column(name = "auth_provider", length = 20)
private String authProvider;
@PrePersist
void prePersist() {
if (loginDtm == null) loginDtm = OffsetDateTime.now();
}
}

파일 보기

@ -0,0 +1,12 @@
package gc.mda.kcg.auth;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.UUID;
public interface LoginHistoryRepository extends JpaRepository<LoginHistory, Long> {
Page<LoginHistory> findByUserIdOrderByLoginDtmDesc(UUID userId, Pageable pageable);
Page<LoginHistory> findAllByOrderByLoginDtmDesc(Pageable pageable);
}

파일 보기

@ -0,0 +1,87 @@
package gc.mda.kcg.auth;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import java.time.OffsetDateTime;
import java.util.UUID;
@Entity
@Table(name = "auth_user", schema = "kcg")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class User {
@Id
@JdbcTypeCode(SqlTypes.UUID)
@Column(name = "user_id", updatable = false, nullable = false)
private UUID userId;
@Column(name = "user_acnt", nullable = false, unique = true, length = 50)
private String userAcnt;
@Column(name = "pswd_hash", length = 255)
private String pswdHash;
@Column(name = "user_nm", nullable = false, length = 100)
private String userNm;
@Column(name = "rnkp_nm", length = 50)
private String rnkpNm;
@Column(name = "email", length = 255)
private String email;
@Column(name = "org_sn")
private Long orgSn;
@Column(name = "user_stts_cd", nullable = false, length = 20)
private String userSttsCd; // PENDING/ACTIVE/LOCKED/INACTIVE/REJECTED
@Column(name = "fail_cnt", nullable = false)
private Integer failCnt;
@Column(name = "last_login_dtm")
private OffsetDateTime lastLoginDtm;
@Column(name = "auth_provider", nullable = false, length = 20)
private String authProvider; // PASSWORD/GPKI/SSO
@Column(name = "provider_sub", length = 255)
private String providerSub;
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private OffsetDateTime updatedAt;
@PrePersist
void prePersist() {
if (userId == null) userId = UUID.randomUUID();
OffsetDateTime now = OffsetDateTime.now();
if (createdAt == null) createdAt = now;
if (updatedAt == null) updatedAt = now;
if (failCnt == null) failCnt = 0;
if (userSttsCd == null) userSttsCd = "PENDING";
if (authProvider == null) authProvider = "PASSWORD";
}
@PreUpdate
void preUpdate() {
updatedAt = OffsetDateTime.now();
}
public boolean isActive() {
return "ACTIVE".equals(userSttsCd);
}
public boolean isLocked() {
return "LOCKED".equals(userSttsCd);
}
}

파일 보기

@ -0,0 +1,11 @@
package gc.mda.kcg.auth;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
import java.util.UUID;
public interface UserRepository extends JpaRepository<User, UUID> {
Optional<User> findByUserAcnt(String userAcnt);
boolean existsByUserAcnt(String userAcnt);
}

파일 보기

@ -0,0 +1,8 @@
package gc.mda.kcg.auth.dto;
import jakarta.validation.constraints.NotBlank;
public record LoginRequest(
@NotBlank String account,
@NotBlank String password
) {}

파일 보기

@ -0,0 +1,16 @@
package gc.mda.kcg.auth.dto;
import java.util.List;
import java.util.Map;
public record UserInfoResponse(
String id,
String account,
String name,
String rank,
String email,
String status,
String authProvider,
List<String> roles,
Map<String, List<String>> permissions
) {}

파일 보기

@ -0,0 +1,39 @@
package gc.mda.kcg.auth.provider;
import gc.mda.kcg.auth.User;
/**
* 인증 방식 확장 포인트.
* Phase 3: PASSWORD만 구현.
* Phase 9 (TODO): GPKI(공무원 인증서), SSO(SAML/OIDC) 추가.
*/
public interface AuthProvider {
/**
* 인증 방식 식별자: PASSWORD / GPKI / SSO
*/
String getProviderType();
/**
* 인증 수행. 성공 User 반환, 실패 AuthenticationException 발생.
*/
User authenticate(AuthRequest request) throws AuthenticationException;
record AuthRequest(
String userAcnt,
String credential, // 비밀번호 또는 인증서/SSO 토큰
String ipAddress,
String userAgent
) {}
class AuthenticationException extends RuntimeException {
private final String reason;
public AuthenticationException(String reason) {
super(reason);
this.reason = reason;
}
public String getReason() { return reason; }
}
}

파일 보기

@ -0,0 +1,71 @@
package gc.mda.kcg.auth.provider;
import gc.mda.kcg.auth.User;
import gc.mda.kcg.auth.UserRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.time.OffsetDateTime;
/**
* 자체 ID/PW 인증 (BCrypt).
* Phase 1 인증 방식 Phase 9에서 GPKI/SSO 추가 예정.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class PasswordAuthProvider implements AuthProvider {
private static final int MAX_FAIL_ATTEMPTS = 5;
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
@Override
public String getProviderType() {
return "PASSWORD";
}
@Override
@Transactional(propagation = Propagation.REQUIRES_NEW, noRollbackFor = AuthenticationException.class)
public User authenticate(AuthRequest request) {
User user = userRepository.findByUserAcnt(request.userAcnt())
.orElseThrow(() -> new AuthenticationException("USER_NOT_FOUND"));
// 상태 검증
if (user.isLocked()) {
throw new AuthenticationException("ACCOUNT_LOCKED");
}
if (!user.isActive()) {
throw new AuthenticationException("ACCOUNT_NOT_ACTIVE:" + user.getUserSttsCd());
}
// PASSWORD provider만 처리
if (!"PASSWORD".equals(user.getAuthProvider())) {
throw new AuthenticationException("WRONG_PROVIDER:" + user.getAuthProvider());
}
// BCrypt 비교
if (user.getPswdHash() == null || !passwordEncoder.matches(request.credential(), user.getPswdHash())) {
int newFailCnt = user.getFailCnt() + 1;
user.setFailCnt(newFailCnt);
if (newFailCnt >= MAX_FAIL_ATTEMPTS) {
user.setUserSttsCd("LOCKED");
userRepository.save(user);
throw new AuthenticationException("MAX_FAIL_LOCKED");
}
userRepository.save(user);
throw new AuthenticationException("WRONG_PASSWORD:" + newFailCnt);
}
// 로그인 성공: 실패 카운터 초기화 + 마지막 로그인 시각 갱신
user.setFailCnt(0);
user.setLastLoginDtm(OffsetDateTime.now());
userRepository.save(user);
return user;
}
}

파일 보기

@ -0,0 +1,39 @@
package gc.mda.kcg.config;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
@Configuration
@ConfigurationProperties(prefix = "app")
@Getter
@Setter
public class AppProperties {
private Prediction prediction = new Prediction();
private IranBackend iranBackend = new IranBackend();
private Cors cors = new Cors();
private Jwt jwt = new Jwt();
@Getter @Setter
public static class Prediction {
private String baseUrl;
}
@Getter @Setter
public static class IranBackend {
private String baseUrl;
}
@Getter @Setter
public static class Cors {
private String allowedOrigins;
}
@Getter @Setter
public static class Jwt {
private String secret;
private long expirationMs;
}
}

파일 보기

@ -1,25 +1,83 @@
package gc.mda.kcg.config;
import gc.mda.kcg.auth.JwtAuthFilter;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.Arrays;
import java.util.List;
/**
* Phase 2 임시 SecurityConfig.
* Phase 3에서 JWT 필터 + 권한 체계 본격 도입 확장.
* Phase 3: JWT 기반 인증 + 트리 RBAC 권한 체계.
*
* - JwtAuthFilter가 토큰 파싱 SecurityContext에 AuthPrincipal 주입
* - 권한 체크는 @RequirePermission 어노테이션 (PermissionAspect) 담당
* - 세션 STATELESS
*/
@Configuration
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthFilter jwtAuthFilter;
private final AppProperties appProperties;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
String origins = appProperties.getCors().getAllowedOrigins();
if (origins != null && !origins.isBlank()) {
config.setAllowedOrigins(Arrays.asList(origins.split(",")));
}
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
config.setAllowedHeaders(List.of("*"));
config.setAllowCredentials(true);
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/**").permitAll()
.anyRequest().permitAll() // Phase 2: 모두 허용. Phase 3에서 인증 필수로 전환
.requestMatchers("/actuator/health", "/actuator/info").permitAll()
.requestMatchers("/api/auth/login", "/api/auth/logout").permitAll()
.requestMatchers("/error").permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
.exceptionHandling(eh -> eh
.authenticationEntryPoint((req, res, ex) -> {
res.setStatus(401);
res.setContentType("application/json");
res.getWriter().write("{\"error\":\"UNAUTHENTICATED\",\"message\":\"" + ex.getMessage() + "\"}");
})
.accessDeniedHandler((req, res, ex) -> {
res.setStatus(403);
res.setContentType("application/json");
res.getWriter().write("{\"error\":\"FORBIDDEN\",\"message\":\"" + ex.getMessage() + "\"}");
})
);
return http.build();
}

파일 보기

@ -0,0 +1,50 @@
package gc.mda.kcg.permission;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import java.time.OffsetDateTime;
import java.util.UUID;
@Entity
@Table(name = "auth_perm", schema = "kcg",
uniqueConstraints = @UniqueConstraint(columnNames = {"role_sn", "rsrc_cd", "oper_cd"}))
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Perm {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "perm_sn")
private Long permSn;
@Column(name = "role_sn", nullable = false)
private Long roleSn;
@Column(name = "rsrc_cd", nullable = false, length = 100)
private String rsrcCd;
@Column(name = "oper_cd", nullable = false, length = 20)
private String operCd; // READ/CREATE/UPDATE/DELETE/EXPORT/MANAGE
@Column(name = "grant_yn", nullable = false, length = 1)
private String grantYn; // Y / N
@Column(name = "updated_at", nullable = false)
private OffsetDateTime updatedAt;
@JdbcTypeCode(SqlTypes.UUID)
@Column(name = "updated_by")
private UUID updatedBy;
@PrePersist
@PreUpdate
void preUpdate() {
updatedAt = OffsetDateTime.now();
}
}

파일 보기

@ -0,0 +1,17 @@
package gc.mda.kcg.permission;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
public interface PermRepository extends JpaRepository<Perm, Long> {
List<Perm> findByRoleSn(Long roleSn);
@Query("SELECT p FROM Perm p WHERE p.roleSn IN :roleSns")
List<Perm> findByRoleSnIn(@Param("roleSns") List<Long> roleSns);
void deleteByRoleSn(Long roleSn);
}

파일 보기

@ -0,0 +1,179 @@
package gc.mda.kcg.permission;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.*;
/**
* 트리 기반 RBAC 권한 해석기 (wing 프로젝트의 permResolver.ts Java 이식).
*
* 핵심 규칙:
* 1. READ가 게이팅 오퍼레이션: 부모의 READ가 N(deny)이면 자식의 모든 작업도 강제 deny
* 2. 명시 권한 우선: AUTH_PERM에 grant_yn 명시값이 있으면 그것 사용
* 3. 상속: 명시값 없으면 부모의 동일 작업 권한을 상속
* 4. 미정 = 거부 (기본값)
* 5. 다중 역할: 역할의 결과를 OR(합집합)
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class PermResolver {
public static final List<String> OPERATIONS = List.of("READ", "CREATE", "UPDATE", "DELETE", "EXPORT");
/**
* 권한 생성 헬퍼.
*/
public static String makePermKey(String rsrcCd, String operCd) {
return rsrcCd + "::" + operCd;
}
/**
* 단일 역할의 명시 권한 + 트리 해석된 (rsrcCd operCd[]) .
*
* @param treeNodes 전체 트리 노드 (use_yn=Y)
* @param explicitPerms 해당 역할의 명시 권한 grantYn ('Y'/'N')
* @return 트리 순회 결과 (모든 사용 가능한 노드에 대한 R/C/U/D/E 결정)
*/
public Map<String, Set<String>> resolveSingleRole(
List<PermTree> treeNodes,
Map<String, String> explicitPerms
) {
// 트리 인덱싱 (parent children)
Map<String, List<PermTree>> childrenMap = new HashMap<>();
Map<String, PermTree> nodeMap = new HashMap<>();
for (PermTree node : treeNodes) {
if (!"Y".equals(node.getUseYn())) continue;
nodeMap.put(node.getRsrcCd(), node);
String parent = node.getParentCd();
childrenMap.computeIfAbsent(parent, k -> new ArrayList<>()).add(node);
}
// 결과 : rsrcCd granted operations Set
Map<String, Set<String>> resolved = new HashMap<>();
// 루트 노드부터 BFS (parentCd가 null인 노드)
List<PermTree> roots = childrenMap.getOrDefault(null, Collections.emptyList());
for (PermTree root : roots) {
walkTree(root, null, childrenMap, explicitPerms, resolved);
}
return resolved;
}
/**
* 트리 순회: 부모의 효과적 권한을 컨텍스트로 받아 자식에 전파.
*/
private void walkTree(
PermTree node,
Set<String> parentEffective,
Map<String, List<PermTree>> childrenMap,
Map<String, String> explicitPerms,
Map<String, Set<String>> resolved
) {
Set<String> nodeEffective = new HashSet<>();
// 1. 오퍼레이션에 대해 (READ 먼저, 다른 작업은 다음)
// READ 결정
boolean readGranted = resolveOperation(node.getRsrcCd(), "READ",
parentEffective != null && parentEffective.contains("READ"),
explicitPerms);
// 부모 READ가 deny면 모든 작업 강제 deny
boolean parentReadDenied = parentEffective != null && !parentEffective.contains("READ") && parentEffective.contains("__defined__");
if (readGranted && !parentReadDenied) {
nodeEffective.add("READ");
}
// 다른 작업: READ가 부여된 경우에만 평가
if (nodeEffective.contains("READ")) {
for (String op : List.of("CREATE", "UPDATE", "DELETE", "EXPORT")) {
boolean parentHasOp = parentEffective != null && parentEffective.contains(op);
boolean granted = resolveOperation(node.getRsrcCd(), op, parentHasOp, explicitPerms);
if (granted) {
nodeEffective.add(op);
}
}
}
// 마커: 노드가 평가되었음을 표시 (자식에서 parent_read_denied 판단용)
nodeEffective.add("__defined__");
// 결과 저장 (마커 제외)
Set<String> publicOps = new HashSet<>(nodeEffective);
publicOps.remove("__defined__");
if (!publicOps.isEmpty()) {
resolved.put(node.getRsrcCd(), publicOps);
}
// 자식 재귀
List<PermTree> children = childrenMap.getOrDefault(node.getRsrcCd(), Collections.emptyList());
for (PermTree child : children) {
walkTree(child, nodeEffective, childrenMap, explicitPerms, resolved);
}
}
/**
* 단일 (rsrc, oper) 권한 해석:
* - 명시값이 있으면 그것 우선
* - 없으면 부모 권한 상속
*/
private boolean resolveOperation(String rsrcCd, String operCd, boolean parentGranted, Map<String, String> explicitPerms) {
String key = makePermKey(rsrcCd, operCd);
String explicit = explicitPerms.get(key);
if ("Y".equals(explicit)) return true;
if ("N".equals(explicit)) return false;
return parentGranted;
}
/**
* 다중 역할 해석: 역할 결과를 OR 합집합.
*
* @param treeNodes 전체 트리
* @param permsByRole 역할 sn 명시 권한 grantYn
* @return 최종 (rsrcCd operCd[])
*/
public Map<String, List<String>> resolveMultiRole(
List<PermTree> treeNodes,
Map<Long, Map<String, String>> permsByRole
) {
Map<String, Set<String>> merged = new HashMap<>();
for (Map.Entry<Long, Map<String, String>> entry : permsByRole.entrySet()) {
Map<String, Set<String>> single = resolveSingleRole(treeNodes, entry.getValue());
for (Map.Entry<String, Set<String>> e : single.entrySet()) {
merged.computeIfAbsent(e.getKey(), k -> new HashSet<>()).addAll(e.getValue());
}
}
// Set List 변환 + 안정적 정렬
Map<String, List<String>> result = new HashMap<>();
for (Map.Entry<String, Set<String>> e : merged.entrySet()) {
List<String> sorted = new ArrayList<>(e.getValue());
sorted.sort(Comparator.comparingInt(OPERATIONS::indexOf));
result.put(e.getKey(), sorted);
}
return result;
}
/**
* 단일 권한 체크 헬퍼: hasPermission(resolved, "detection:gear-detection", "READ")
* 부모 fallback 지원: "detection:gear-detection" 미존재 "detection" 검사
*/
public boolean hasPermission(Map<String, List<String>> resolved, String rsrcCd, String operCd) {
List<String> ops = resolved.get(rsrcCd);
if (ops != null && ops.contains(operCd)) return true;
// 부모 fallback
int colonIdx = rsrcCd.indexOf(':');
if (colonIdx > 0) {
String parent = rsrcCd.substring(0, colonIdx);
List<String> parentOps = resolved.get(parent);
return parentOps != null && parentOps.contains(operCd);
}
return false;
}
}

파일 보기

@ -0,0 +1,62 @@
package gc.mda.kcg.permission;
import jakarta.persistence.*;
import lombok.*;
import java.time.OffsetDateTime;
@Entity
@Table(name = "auth_perm_tree", schema = "kcg")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class PermTree {
@Id
@Column(name = "rsrc_cd", length = 100)
private String rsrcCd;
@Column(name = "parent_cd", length = 100)
private String parentCd;
@Column(name = "rsrc_nm", nullable = false, length = 100)
private String rsrcNm;
@Column(name = "rsrc_desc", columnDefinition = "text")
private String rsrcDesc;
@Column(name = "icon", length = 50)
private String icon;
@Column(name = "rsrc_level", nullable = false)
private Integer rsrcLevel;
@Column(name = "sort_ord", nullable = false)
private Integer sortOrd;
@Column(name = "use_yn", nullable = false, length = 1)
private String useYn;
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private OffsetDateTime updatedAt;
@PrePersist
void prePersist() {
OffsetDateTime now = OffsetDateTime.now();
if (createdAt == null) createdAt = now;
if (updatedAt == null) updatedAt = now;
if (useYn == null) useYn = "Y";
if (sortOrd == null) sortOrd = 0;
if (rsrcLevel == null) rsrcLevel = 0;
}
@PreUpdate
void preUpdate() {
updatedAt = OffsetDateTime.now();
}
}

파일 보기

@ -0,0 +1,10 @@
package gc.mda.kcg.permission;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface PermTreeRepository extends JpaRepository<PermTree, String> {
List<PermTree> findAllByOrderByRsrcLevelAscSortOrdAsc();
List<PermTree> findByUseYn(String useYn);
}

파일 보기

@ -0,0 +1,97 @@
package gc.mda.kcg.permission;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.*;
import java.util.UUID;
/**
* 사용자 권한 조회/캐싱 서비스.
* 권한 변경 CacheEvict로 무효화.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class PermissionService {
private final UserRoleRepository userRoleRepository;
private final PermRepository permRepository;
private final PermTreeRepository permTreeRepository;
private final RoleRepository roleRepository;
private final PermResolver permResolver;
/**
* 사용자 ID로 해석된 권한 조회.
* Caffeine 캐시 (TTL 10분, 권한 변경 evict).
*/
@Cacheable(value = "permissions", key = "#userId")
@Transactional(readOnly = true)
public Map<String, List<String>> getResolvedPermissionsByUserId(UUID userId) {
// 1. 사용자의 역할 조회
List<UserRole> userRoles = userRoleRepository.findByUserId(userId);
if (userRoles.isEmpty()) {
log.debug("User {} has no roles", userId);
return Collections.emptyMap();
}
List<Long> roleSns = userRoles.stream().map(UserRole::getRoleSn).toList();
// 2. 역할별 명시 권한 로드
List<Perm> perms = permRepository.findByRoleSnIn(roleSns);
Map<Long, Map<String, String>> permsByRole = new HashMap<>();
for (Perm p : perms) {
permsByRole
.computeIfAbsent(p.getRoleSn(), k -> new HashMap<>())
.put(PermResolver.makePermKey(p.getRsrcCd(), p.getOperCd()), p.getGrantYn());
}
// 권한이 하나도 없는 역할도 맵으로 등록 (트리 순회는 필요)
for (Long sn : roleSns) {
permsByRole.computeIfAbsent(sn, k -> new HashMap<>());
}
// 3. 트리 노드 로드 (use_yn=Y)
List<PermTree> treeNodes = permTreeRepository.findByUseYn("Y");
// 4. 다중 역할 해석
Map<String, List<String>> resolved = permResolver.resolveMultiRole(treeNodes, permsByRole);
log.debug("Resolved {} resources for user {}", resolved.size(), userId);
return resolved;
}
/**
* 권한 체크 (resource + operation).
*/
public boolean hasPermission(UUID userId, String rsrcCd, String operCd) {
Map<String, List<String>> resolved = getResolvedPermissionsByUserId(userId);
return permResolver.hasPermission(resolved, rsrcCd, operCd);
}
/**
* 사용자 역할 코드 목록 조회.
*/
@Transactional(readOnly = true)
public List<String> getRoleCodesByUserId(UUID userId) {
return userRoleRepository.findRoleCodesByUserId(userId);
}
/**
* 권한 캐시 무효화 (역할 배정/권한 매트릭스 변경 호출).
*/
@CacheEvict(value = "permissions", key = "#userId")
public void evictUserPermissions(UUID userId) {
log.info("Evicted permissions cache for user {}", userId);
}
/**
* 전체 권한 캐시 무효화 (대량 변경 ).
*/
@CacheEvict(value = "permissions", allEntries = true)
public void evictAllPermissions() {
log.info("Evicted all permissions cache");
}
}

파일 보기

@ -0,0 +1,56 @@
package gc.mda.kcg.permission;
import jakarta.persistence.*;
import lombok.*;
import java.time.OffsetDateTime;
@Entity
@Table(name = "auth_role", schema = "kcg")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Role {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "role_sn")
private Long roleSn;
@Column(name = "role_cd", nullable = false, unique = true, length = 50)
private String roleCd;
@Column(name = "role_nm", nullable = false, length = 100)
private String roleNm;
@Column(name = "role_dc", columnDefinition = "text")
private String roleDc;
@Column(name = "dflt_yn", nullable = false, length = 1)
private String dfltYn;
@Column(name = "builtin_yn", nullable = false, length = 1)
private String builtinYn;
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private OffsetDateTime updatedAt;
@PrePersist
void prePersist() {
OffsetDateTime now = OffsetDateTime.now();
if (createdAt == null) createdAt = now;
if (updatedAt == null) updatedAt = now;
if (dfltYn == null) dfltYn = "N";
if (builtinYn == null) builtinYn = "N";
}
@PreUpdate
void preUpdate() {
updatedAt = OffsetDateTime.now();
}
}

파일 보기

@ -0,0 +1,11 @@
package gc.mda.kcg.permission;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface RoleRepository extends JpaRepository<Role, Long> {
Optional<Role> findByRoleCd(String roleCd);
List<Role> findAllByOrderByRoleSnAsc();
}

파일 보기

@ -0,0 +1,41 @@
package gc.mda.kcg.permission;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import java.time.OffsetDateTime;
import java.util.UUID;
@Entity
@Table(name = "auth_user_role", schema = "kcg")
@IdClass(UserRoleId.class)
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class UserRole {
@Id
@JdbcTypeCode(SqlTypes.UUID)
@Column(name = "user_id")
private UUID userId;
@Id
@Column(name = "role_sn")
private Long roleSn;
@Column(name = "granted_at", nullable = false)
private OffsetDateTime grantedAt;
@JdbcTypeCode(SqlTypes.UUID)
@Column(name = "granted_by")
private UUID grantedBy;
@PrePersist
void prePersist() {
if (grantedAt == null) grantedAt = OffsetDateTime.now();
}
}

파일 보기

@ -0,0 +1,16 @@
package gc.mda.kcg.permission;
import lombok.*;
import java.io.Serializable;
import java.util.UUID;
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode
public class UserRoleId implements Serializable {
private UUID userId;
private Long roleSn;
}

파일 보기

@ -0,0 +1,17 @@
package gc.mda.kcg.permission;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import java.util.List;
import java.util.UUID;
public interface UserRoleRepository extends JpaRepository<UserRole, UserRoleId> {
List<UserRole> findByUserId(UUID userId);
void deleteByUserId(UUID userId);
@Query("SELECT r.roleCd FROM Role r JOIN UserRole ur ON ur.roleSn = r.roleSn WHERE ur.userId = :userId")
List<String> findRoleCodesByUserId(UUID userId);
}

파일 보기

@ -0,0 +1,53 @@
package gc.mda.kcg.permission.annotation;
import gc.mda.kcg.auth.AuthPrincipal;
import gc.mda.kcg.permission.PermissionService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.reflect.MethodSignature;
import org.aspectj.lang.JoinPoint;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.authentication.AuthenticationCredentialsNotFoundException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import java.lang.reflect.Method;
/**
* @RequirePermission 어노테이션 처리 AOP.
* 메서드 호출 직전 권한 체크 거부 AccessDeniedException 발생.
*/
@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class PermissionAspect {
private final PermissionService permissionService;
@Before("@annotation(gc.mda.kcg.permission.annotation.RequirePermission) || @within(gc.mda.kcg.permission.annotation.RequirePermission)")
public void checkPermission(JoinPoint jp) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !(auth.getPrincipal() instanceof AuthPrincipal principal)) {
throw new AuthenticationCredentialsNotFoundException("로그인이 필요합니다");
}
// 메서드 우선, 없으면 클래스
MethodSignature ms = (MethodSignature) jp.getSignature();
Method method = ms.getMethod();
RequirePermission ann = method.getAnnotation(RequirePermission.class);
if (ann == null) {
ann = method.getDeclaringClass().getAnnotation(RequirePermission.class);
}
if (ann == null) return;
boolean granted = permissionService.hasPermission(principal.getUserId(), ann.resource(), ann.operation());
if (!granted) {
log.warn("권한 거부: user={}, resource={}, op={}", principal.getUserAcnt(), ann.resource(), ann.operation());
throw new AccessDeniedException("권한 없음: " + ann.resource() + "::" + ann.operation());
}
}
}

파일 보기

@ -0,0 +1,24 @@
package gc.mda.kcg.permission.annotation;
import java.lang.annotation.*;
/**
* 메서드/클래스 레벨 권한 요구 어노테이션.
*
* 사용 :
* <pre>
* @RequirePermission(resource = "parent-inference-workflow:parent-review", operation = "UPDATE")
* @PostMapping("/groups/{key}/parent-inference/{sub}/review")
* public ResponseEntity<?> review(...) { ... }
* </pre>
*/
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RequirePermission {
/** 리소스 코드 (예: "detection:gear-detection") */
String resource();
/** 오퍼레이션 (READ/CREATE/UPDATE/DELETE/EXPORT). 기본 READ */
String operation() default "READ";
}

파일 보기

@ -15,7 +15,7 @@ CREATE TABLE kcg.auth_org (
org_tp_cd VARCHAR(20), -- HQ, REGIONAL, STATION, AGENCY
upper_org_sn BIGINT REFERENCES kcg.auth_org(org_sn),
sort_ord INT DEFAULT 0,
use_yn CHAR(1) NOT NULL DEFAULT 'Y',
use_yn VARCHAR(1) NOT NULL DEFAULT 'Y',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
@ -60,8 +60,8 @@ CREATE TABLE kcg.auth_role (
role_cd VARCHAR(50) UNIQUE NOT NULL, -- ADMIN, OPERATOR, ANALYST, VIEWER, FIELD
role_nm VARCHAR(100) NOT NULL,
role_dc TEXT,
dflt_yn CHAR(1) NOT NULL DEFAULT 'N', -- 신규 사용자 자동 배정 여부
builtin_yn CHAR(1) NOT NULL DEFAULT 'N', -- 내장 역할 (삭제 불가)
dflt_yn VARCHAR(1) NOT NULL DEFAULT 'N', -- 신규 사용자 자동 배정 여부
builtin_yn VARCHAR(1) NOT NULL DEFAULT 'N', -- 내장 역할 (삭제 불가)
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

파일 보기

@ -13,7 +13,7 @@ CREATE TABLE kcg.auth_perm_tree (
icon VARCHAR(50),
rsrc_level INT NOT NULL DEFAULT 0, -- 0=tab(권한그룹), 1=subtab/패널, 2+=중첩
sort_ord INT NOT NULL DEFAULT 0,
use_yn CHAR(1) NOT NULL DEFAULT 'Y',
use_yn VARCHAR(1) NOT NULL DEFAULT 'Y',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
@ -32,7 +32,7 @@ CREATE TABLE kcg.auth_perm (
role_sn BIGINT NOT NULL REFERENCES kcg.auth_role(role_sn) ON DELETE CASCADE,
rsrc_cd VARCHAR(100) NOT NULL REFERENCES kcg.auth_perm_tree(rsrc_cd) ON DELETE CASCADE,
oper_cd VARCHAR(20) NOT NULL, -- READ, CREATE, UPDATE, DELETE, EXPORT, MANAGE
grant_yn CHAR(1) NOT NULL, -- Y(허용), N(명시적 거부)
grant_yn VARCHAR(1) NOT NULL, -- Y(허용), N(명시적 거부)
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_by UUID,
UNIQUE(role_sn, rsrc_cd, oper_cd)

파일 보기

@ -0,0 +1,28 @@
-- ============================================================================
-- V006: 데모 계정 5종 생성 (해시는 AccountSeeder가 시동 시 갱신)
-- ============================================================================
-- 향후 운영 배포 시에도 데모 계정은 유지됨 (운영자가 비활성화 가능)
-- 비밀번호는 AccountSeeder.java가 BCrypt로 시동 시 한 번만 시드/갱신
-- ----------------------------------------------------------------------------
-- 기존 admin placeholder 제거 (V003에서 만든 행)
DELETE FROM kcg.auth_user_role WHERE user_id IN (SELECT user_id FROM kcg.auth_user WHERE user_acnt = 'admin');
DELETE FROM kcg.auth_user WHERE user_acnt = 'admin';
-- 데모 계정 5종 생성 (pswd_hash는 placeholder, AccountSeeder가 BCrypt로 갱신)
INSERT INTO kcg.auth_user (user_acnt, user_nm, rnkp_nm, email, user_stts_cd, auth_provider, pswd_hash) VALUES
('admin', '김영수', '사무관', 'admin@kcg.go.kr', 'ACTIVE', 'PASSWORD', 'PLACEHOLDER_TO_BE_SEEDED'),
('operator', '이상호', '경위', 'operator@kcg.go.kr', 'ACTIVE', 'PASSWORD', 'PLACEHOLDER_TO_BE_SEEDED'),
('analyst', '정해진', '주무관', 'analyst@kcg.go.kr', 'ACTIVE', 'PASSWORD', 'PLACEHOLDER_TO_BE_SEEDED'),
('field', '박민수', '경사', 'field@kcg.go.kr', 'ACTIVE', 'PASSWORD', 'PLACEHOLDER_TO_BE_SEEDED'),
('viewer', '최원석', '6급', 'viewer@kcg.go.kr', 'ACTIVE', 'PASSWORD', 'PLACEHOLDER_TO_BE_SEEDED');
-- 역할 매핑 (계정 → 역할)
INSERT INTO kcg.auth_user_role (user_id, role_sn)
SELECT u.user_id, r.role_sn
FROM kcg.auth_user u, kcg.auth_role r
WHERE (u.user_acnt = 'admin' AND r.role_cd = 'ADMIN')
OR (u.user_acnt = 'operator' AND r.role_cd = 'OPERATOR')
OR (u.user_acnt = 'analyst' AND r.role_cd = 'ANALYST')
OR (u.user_acnt = 'field' AND r.role_cd = 'FIELD')
OR (u.user_acnt = 'viewer' AND r.role_cd = 'VIEWER');

파일 보기

@ -1,11 +1,13 @@
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react';
import { fetchMe, loginApi, logoutApi, LoginError, type BackendUser } from '@/services/authApi';
/*
* SFR-01: 시스템
* - (RBAC)
* - (30 )
* - 1 1
* -
* - JWT
* - RBAC ( auth_perm_tree + auth_perm)
* - + fallback (: detection:gear-detection detection )
* - 타임아웃: 30분
* - + DB(kcgaidb)
*/
// ─── RBAC 역할 정의 ─────────────────────
@ -13,95 +15,125 @@ export type UserRole = 'ADMIN' | 'OPERATOR' | 'ANALYST' | 'FIELD' | 'VIEWER';
export interface AuthUser {
id: string;
/** 로그인 ID */
account: string;
name: string;
rank: string;
org: string;
/** 다중 역할 (백엔드는 배열 반환) */
roles: UserRole[];
/** 1차 역할 (기존 코드 호환) */
role: UserRole;
/** 권한 트리: rsrcCd → operations[] */
permissions: Record<string, string[]>;
authMethod: 'password' | 'gpki' | 'sso';
loginAt: string;
}
// ─── 역할별 접근 가능 경로 ──────────────────
const ROLE_PERMISSIONS: Record<UserRole, string[]> = {
ADMIN: [
'/dashboard', '/monitoring', '/events', '/map-control', '/event-list',
'/risk-map', '/enforcement-plan',
'/dark-vessel', '/gear-detection', '/china-fishing',
'/patrol-route', '/fleet-optimization',
'/enforcement-history', '/statistics', '/reports',
'/ai-alert', '/mobile-service', '/ship-agent', '/external-service',
'/ai-model', '/mlops', '/ai-assistant',
'/data-hub', '/system-config', '/notices', '/admin', '/access-control',
],
OPERATOR: [
'/dashboard', '/monitoring', '/events', '/map-control', '/event-list',
'/risk-map', '/enforcement-plan',
'/dark-vessel', '/gear-detection', '/china-fishing',
'/patrol-route', '/fleet-optimization',
'/enforcement-history', '/statistics', '/reports',
'/ai-alert', '/mobile-service', '/ship-agent',
'/data-hub', '/system-config',
],
ANALYST: [
'/dashboard', '/monitoring', '/events', '/event-list',
'/risk-map', '/dark-vessel', '/gear-detection', '/china-fishing',
'/enforcement-history', '/statistics', '/reports',
'/ai-model', '/mlops', '/ai-assistant',
'/system-config',
],
FIELD: [
'/dashboard', '/monitoring', '/events', '/event-list',
'/risk-map', '/enforcement-plan',
'/dark-vessel', '/china-fishing',
'/mobile-service', '/ship-agent', '/ai-alert',
],
VIEWER: [
'/dashboard', '/monitoring', '/statistics',
],
};
// ─── 감사 로그 ──────────────────────────
export interface AuditEntry {
time: string;
user: string;
action: string;
target: string;
ip: string;
result: '성공' | '실패' | '차단';
}
function writeAuditLog(entry: Omit<AuditEntry, 'time' | 'ip'>) {
const log: AuditEntry = {
...entry,
time: new Date().toISOString().replace('T', ' ').slice(0, 19),
ip: '10.20.30.1', // 시뮬레이션
};
const logs: AuditEntry[] = JSON.parse(sessionStorage.getItem('audit_logs') || '[]');
logs.unshift(log);
sessionStorage.setItem('audit_logs', JSON.stringify(logs.slice(0, 200)));
}
// ─── 세션 타임아웃 (30분) ──────────────────
const SESSION_TIMEOUT = 30 * 60 * 1000;
// 경로 → 권한 리소스 매핑 (ProtectedRoute용)
const PATH_TO_RESOURCE: Record<string, string> = {
'/dashboard': 'dashboard',
'/monitoring': 'monitoring',
'/events': 'surveillance:live-map',
'/map-control': 'surveillance:map-control',
'/dark-vessel': 'detection:dark-vessel',
'/gear-detection': 'detection:gear-detection',
'/china-fishing': 'detection:china-fishing',
'/vessel': 'vessel',
'/risk-map': 'risk-assessment:risk-map',
'/enforcement-plan': 'risk-assessment:enforcement-plan',
'/patrol-route': 'patrol:patrol-route',
'/fleet-optimization': 'patrol:fleet-optimization',
'/enforcement-history': 'enforcement:enforcement-history',
'/event-list': 'enforcement:event-list',
'/mobile-service': 'field-ops:mobile-service',
'/ship-agent': 'field-ops:ship-agent',
'/ai-alert': 'field-ops:ai-alert',
'/ai-assistant': 'ai-operations:ai-assistant',
'/ai-model': 'ai-operations:ai-model',
'/mlops': 'ai-operations:mlops',
'/statistics': 'statistics:statistics',
'/external-service': 'statistics:external-service',
'/admin': 'admin',
'/access-control': 'admin:permission-management',
'/system-config': 'admin:system-config',
'/notices': 'admin',
'/reports': 'statistics:statistics',
'/data-hub': 'admin:system-config',
};
interface AuthContextType {
user: AuthUser | null;
login: (user: AuthUser) => void;
logout: () => void;
loading: boolean;
/** ID/PW 로그인 (백엔드 호출) */
login: (account: string, password: string) => Promise<void>;
logout: () => Promise<void>;
/** 경로 기반 접근 가능 여부 (메뉴/라우트 가드용) */
hasAccess: (path: string) => boolean;
sessionRemaining: number; // seconds
/** 트리 기반 권한 체크 (resource + operation) */
hasPermission: (resource: string, operation?: string) => boolean;
sessionRemaining: number;
}
const AuthContext = createContext<AuthContextType | null>(null);
function backendToAuthUser(b: BackendUser): AuthUser {
const primaryRole = (b.roles[0] ?? 'VIEWER') as UserRole;
return {
id: b.id,
account: b.account,
name: b.name,
rank: b.rank ?? '',
org: '', // 향후 백엔드에서 org_sn/org_nm 추가 시 채움
roles: b.roles as UserRole[],
role: primaryRole,
permissions: b.permissions,
authMethod: (b.authProvider?.toLowerCase() as AuthUser['authMethod']) ?? 'password',
loginAt: new Date().toISOString().replace('T', ' ').slice(0, 19),
};
}
/**
* ( fallback ).
* "detection:gear-detection" "detection" .
*/
function checkPermission(perms: Record<string, string[]>, resource: string, operation: string): boolean {
const ops = perms[resource];
if (ops && ops.includes(operation)) return true;
// 부모 fallback
const colonIdx = resource.indexOf(':');
if (colonIdx > 0) {
const parent = resource.substring(0, colonIdx);
const parentOps = perms[parent];
return !!parentOps && parentOps.includes(operation);
}
return false;
}
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<AuthUser | null>(() => {
const stored = sessionStorage.getItem('auth_user');
return stored ? JSON.parse(stored) : null;
});
const [user, setUser] = useState<AuthUser | null>(null);
const [loading, setLoading] = useState(true);
const [lastActivity, setLastActivity] = useState(Date.now());
const [sessionRemaining, setSessionRemaining] = useState(SESSION_TIMEOUT / 1000);
// 초기 세션 복원: /api/auth/me 호출
useEffect(() => {
let alive = true;
fetchMe()
.then((b) => {
if (alive && b) setUser(backendToAuthUser(b));
})
.finally(() => {
if (alive) setLoading(false);
});
return () => {
alive = false;
};
}, []);
// 사용자 활동 감지 → 세션 갱신
const resetActivity = useCallback(() => {
setLastActivity(Date.now());
@ -123,37 +155,54 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setSessionRemaining(remaining);
if (elapsed >= SESSION_TIMEOUT) {
writeAuditLog({ user: user.name, action: '세션 타임아웃 로그아웃', target: '시스템', result: '성공' });
logoutApi().catch(() => undefined);
setUser(null);
sessionStorage.removeItem('auth_user');
}
}, 1000);
return () => clearInterval(interval);
}, [user, lastActivity]);
const login = useCallback((u: AuthUser) => {
setUser(u);
setLastActivity(Date.now());
sessionStorage.setItem('auth_user', JSON.stringify(u));
writeAuditLog({ user: u.name, action: `로그인 (${u.authMethod})`, target: '시스템', result: '성공' });
const login = useCallback(async (account: string, password: string) => {
try {
const b = await loginApi(account, password);
setUser(backendToAuthUser(b));
setLastActivity(Date.now());
} catch (e) {
if (e instanceof LoginError) throw e;
throw new LoginError('NETWORK_ERROR');
}
}, []);
const logout = useCallback(() => {
if (user) {
writeAuditLog({ user: user.name, action: '로그아웃', target: '시스템', result: '성공' });
const logout = useCallback(async () => {
try {
await logoutApi();
} finally {
setUser(null);
}
setUser(null);
sessionStorage.removeItem('auth_user');
}, [user]);
}, []);
const hasAccess = useCallback((path: string) => {
if (!user) return false;
const allowed = ROLE_PERMISSIONS[user.role] || [];
return allowed.some((p) => path.startsWith(p));
}, [user]);
const hasPermission = useCallback(
(resource: string, operation: string = 'READ') => {
if (!user) return false;
return checkPermission(user.permissions, resource, operation);
},
[user],
);
const hasAccess = useCallback(
(path: string) => {
if (!user) return false;
// 경로의 첫 세그먼트로 매핑
const matched = Object.keys(PATH_TO_RESOURCE).find((p) => path.startsWith(p));
if (!matched) return true; // 매핑 없는 경로는 허용 (안전한 기본값으로 변경 가능)
const resource = PATH_TO_RESOURCE[matched];
return hasPermission(resource, 'READ');
},
[user, hasPermission],
);
return (
<AuthContext.Provider value={{ user, login, logout, hasAccess, sessionRemaining }}>
<AuthContext.Provider value={{ user, loading, login, logout, hasAccess, hasPermission, sessionRemaining }}>
{children}
</AuthContext.Provider>
);

파일 보기

@ -0,0 +1,61 @@
import { useTranslation } from 'react-i18next';
/*
* .
*
* 조건: VITE_SHOW_DEMO_LOGIN === 'true' (.env )
* - 개발: .env.development에 VITE_SHOW_DEMO_LOGIN=true
* - 환경: .env.production에 VITE_SHOW_DEMO_LOGIN=false ( )
*
* DB(kcgaidb) BCrypt ,
* , , .
* .
*/
export interface DemoAccount {
account: string;
password: string;
roleLabelKey: string; // i18n 키
}
export const DEMO_ACCOUNTS: DemoAccount[] = [
{ account: 'admin', password: 'admin1234!', roleLabelKey: 'demo.admin' },
{ account: 'operator', password: 'oper12345!', roleLabelKey: 'demo.operator' },
{ account: 'analyst', password: 'anal12345!', roleLabelKey: 'demo.analyst' },
{ account: 'field', password: 'field1234!', roleLabelKey: 'demo.field' },
{ account: 'viewer', password: 'view12345!', roleLabelKey: 'demo.viewer' },
];
export function isDemoLoginEnabled(): boolean {
return import.meta.env.VITE_SHOW_DEMO_LOGIN === 'true';
}
interface DemoQuickLoginProps {
onSelect: (account: DemoAccount) => void;
disabled?: boolean;
}
export function DemoQuickLogin({ onSelect, disabled }: DemoQuickLoginProps) {
const { t } = useTranslation('auth');
if (!isDemoLoginEnabled()) return null;
return (
<div className="pt-2 border-t border-border">
<div className="text-[9px] text-hint text-center mb-2">{t('demo.title')}</div>
<div className="grid grid-cols-5 gap-1.5">
{DEMO_ACCOUNTS.map((acct) => (
<button
key={acct.account}
type="button"
disabled={disabled}
onClick={() => onSelect(acct)}
className="py-1.5 rounded-md text-[9px] font-medium bg-surface-overlay border border-border text-muted-foreground hover:text-heading hover:bg-switch-background/60 transition-colors whitespace-nowrap disabled:opacity-50"
>
{t(acct.roleLabelKey)}
</button>
))}
</div>
</div>
);
}

파일 보기

@ -2,30 +2,42 @@ import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Shield, Eye, EyeOff, Lock, User, Fingerprint, KeyRound, AlertCircle } from 'lucide-react';
import { useAuth, type UserRole } from '@/app/auth/AuthContext';
import { useAuth } from '@/app/auth/AuthContext';
import { LoginError } from '@/services/authApi';
import { DemoQuickLogin, type DemoAccount } from './DemoQuickLogin';
/*
* SFR-01: 시스템
* - SSO··GPKI
* - (RBAC)
* - ,
* -
* - 5 (30)
* - ID/PW ( + JWT )
* - GPKI/SSO는 Phase 9 ( )
* - ,
* - (/) DB에
*
* DemoQuickLogin
* (VITE_SHOW_DEMO_LOGIN=true일 ).
*/
type AuthMethod = 'password' | 'gpki' | 'sso';
// SFR-01: 시뮬레이션 계정 (역할별)
const DEMO_ACCOUNTS: Record<string, { pw: string; name: string; rank: string; org: string; role: UserRole }> = {
admin: { pw: 'admin1234!', name: '김영수', rank: '사무관', org: '본청 정보통신과', role: 'ADMIN' },
operator: { pw: 'oper12345!', name: '이상호', rank: '경위', org: '서해지방해경청', role: 'OPERATOR' },
analyst: { pw: 'anal12345!', name: '정해진', rank: '주무관', org: '남해지방해경청', role: 'ANALYST' },
field: { pw: 'field1234!', name: '박민수', rank: '경사', org: '5001함 삼봉', role: 'FIELD' },
viewer: { pw: 'view12345!', name: '최원석', rank: '6급', org: '해수부 어업관리과', role: 'VIEWER' },
const ERROR_MESSAGES: Record<string, string> = {
USER_NOT_FOUND: '존재하지 않는 계정입니다.',
ACCOUNT_LOCKED: '계정이 잠겨있습니다. 관리자에게 문의하세요.',
WRONG_PROVIDER: '다른 인증 방식으로 가입된 계정입니다.',
MAX_FAIL_LOCKED: '5회 연속 실패로 계정이 잠금 처리되었습니다.',
NETWORK_ERROR: '네트워크 오류가 발생했습니다.',
};
const MAX_LOGIN_ATTEMPTS = 5;
const LOCKOUT_DURATION = 30 * 60 * 1000; // 30분
function translateError(reason: string): string {
// WRONG_PASSWORD:N → 시도 N회 메시지
if (reason.startsWith('WRONG_PASSWORD:')) {
const cnt = reason.substring('WRONG_PASSWORD:'.length);
return `비밀번호가 올바르지 않습니다. (${cnt}/5)`;
}
if (reason.startsWith('ACCOUNT_NOT_ACTIVE:')) {
return '활성화되지 않은 계정입니다. 관리자 승인이 필요합니다.';
}
return ERROR_MESSAGES[reason] ?? `로그인 실패: ${reason}`;
}
export function LoginPage() {
const { t } = useTranslation('auth');
@ -37,80 +49,47 @@ export function LoginPage() {
const [showPw, setShowPw] = useState(false);
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const [failCount, setFailCount] = useState(0);
const [lockedUntil, setLockedUntil] = useState<number | null>(null);
// user 상태가 확정된 후 대시보드로 이동
useEffect(() => {
if (user) navigate('/dashboard', { replace: true });
}, [user, navigate]);
const doLogin = (method: AuthMethod, account?: typeof DEMO_ACCOUNTS[string]) => {
const u = account || DEMO_ACCOUNTS['operator'];
login({
id: userId || u.role,
name: u.name,
rank: u.rank,
org: u.org,
role: u.role,
authMethod: method,
loginAt: new Date().toISOString().replace('T', ' ').slice(0, 19),
});
const doLogin = async (account: string, pw: string) => {
setError('');
setLoading(true);
try {
await login(account, pw);
// 성공 시 useEffect가 navigate 처리
} catch (e) {
if (e instanceof LoginError) {
setError(translateError(e.reason));
} else {
setError(translateError('NETWORK_ERROR'));
}
} finally {
setLoading(false);
}
};
const handleLogin = (e: React.FormEvent) => {
e.preventDefault();
setError('');
// SFR-01: 계정 잠금 확인
if (lockedUntil && Date.now() < lockedUntil) {
const remainMin = Math.ceil((lockedUntil - Date.now()) / 60000);
setError(t('error.locked', { minutes: remainMin }));
return;
}
if (authMethod === 'password') {
if (!userId.trim()) { setError(t('error.emptyId')); return; }
if (!password.trim()) { setError(t('error.emptyPassword')); return; }
if (password.length < 9) { setError(t('error.invalidPassword')); return; }
}
setLoading(true);
setTimeout(() => {
setLoading(false);
// SFR-01: ID/PW 인증 시 계정 검증
const account = DEMO_ACCOUNTS[userId.toLowerCase()];
if (authMethod === 'password' && (!account || account.pw !== password)) {
const newCount = failCount + 1;
setFailCount(newCount);
if (newCount >= MAX_LOGIN_ATTEMPTS) {
setLockedUntil(Date.now() + LOCKOUT_DURATION);
setError(t('error.maxFailed', { max: MAX_LOGIN_ATTEMPTS }));
} else {
setError(t('error.wrongCredentials', { count: newCount, max: MAX_LOGIN_ATTEMPTS }));
}
return;
}
setFailCount(0);
setLockedUntil(null);
doLogin(authMethod, account);
}, 1200);
if (authMethod !== 'password') return;
if (!userId.trim()) { setError(t('error.emptyId')); return; }
if (!password.trim()) { setError(t('error.emptyPassword')); return; }
doLogin(userId, password);
};
const DEMO_ROLE_LABELS: Record<UserRole, string> = {
ADMIN: t('demo.admin'),
OPERATOR: t('demo.operator'),
ANALYST: t('demo.analyst'),
FIELD: t('demo.field'),
VIEWER: t('demo.viewer'),
const handleDemoSelect = (acct: DemoAccount) => {
setUserId(acct.account);
setPassword(acct.password);
doLogin(acct.account, acct.password);
};
const authMethods: { key: AuthMethod; icon: React.ElementType; label: string; desc: string }[] = [
const authMethods: { key: AuthMethod; icon: React.ElementType; label: string; desc: string; disabled?: boolean }[] = [
{ key: 'password', icon: Lock, label: t('authMethod.password'), desc: t('authMethod.passwordDesc') },
{ key: 'gpki', icon: Fingerprint, label: t('authMethod.gpki'), desc: t('authMethod.gpkiDesc') },
{ key: 'sso', icon: KeyRound, label: t('authMethod.sso'), desc: t('authMethod.ssoDesc') },
{ key: 'gpki', icon: Fingerprint, label: t('authMethod.gpki'), desc: t('authMethod.gpkiDesc'), disabled: true },
{ key: 'sso', icon: KeyRound, label: t('authMethod.sso'), desc: t('authMethod.ssoDesc'), disabled: true },
];
return (
@ -138,12 +117,15 @@ export function LoginPage() {
{authMethods.map((m) => (
<button
key={m.key}
onClick={() => { setAuthMethod(m.key); setError(''); }}
type="button"
onClick={() => { if (!m.disabled) { setAuthMethod(m.key); setError(''); } }}
disabled={m.disabled}
className={`flex-1 flex flex-col items-center gap-1 py-3 transition-colors ${
authMethod === m.key
? 'bg-blue-600/10 border-b-2 border-blue-500 text-blue-400'
: 'text-hint hover:bg-surface-overlay hover:text-label'
}`}
} ${m.disabled ? 'opacity-40 cursor-not-allowed' : ''}`}
title={m.disabled ? '향후 도입 예정' : ''}
>
<m.icon className="w-4 h-4" />
<span className="text-[9px] font-medium whitespace-nowrap">{m.label}</span>
@ -223,89 +205,26 @@ export function LoginPage() {
) : t('button.login')}
</button>
{/* 데모 퀵로그인 */}
<div className="pt-2 border-t border-border">
<div className="text-[9px] text-hint text-center mb-2">{t('demo.title')}</div>
<div className="grid grid-cols-5 gap-1.5">
{Object.entries(DEMO_ACCOUNTS).map(([key, acct]) => (
<button
key={key}
type="button"
onClick={() => doLogin('password', acct)}
className="py-1.5 rounded-md text-[9px] font-medium bg-surface-overlay border border-border text-muted-foreground hover:text-heading hover:bg-switch-background/60 transition-colors whitespace-nowrap"
>
{DEMO_ROLE_LABELS[acct.role]}
</button>
))}
</div>
</div>
{/* 데모 퀵로그인 (VITE_SHOW_DEMO_LOGIN=true일 때만 렌더링) */}
<DemoQuickLogin onSelect={handleDemoSelect} disabled={loading} />
</form>
)}
{/* GPKI 인증 */}
{/* GPKI 인증 (Phase 9 도입 예정) */}
{authMethod === 'gpki' && (
<div className="space-y-4">
<div className="text-center py-6">
<Fingerprint className="w-12 h-12 text-blue-400 mx-auto mb-3" />
<p className="text-sm text-heading font-medium">{t('gpki.title')}</p>
<p className="text-[10px] text-hint mt-1">{t('gpki.desc')}</p>
</div>
<div className="bg-surface-overlay rounded-lg p-4 border border-dashed border-slate-700/50">
<div className="text-center">
<div className="text-[10px] text-hint mb-2">{t('gpki.certStatus')}</div>
<div className="flex items-center justify-center gap-2">
<div className="w-2 h-2 rounded-full bg-yellow-500 animate-pulse" />
<span className="text-[11px] text-yellow-400">{t('gpki.certWaiting')}</span>
</div>
</div>
</div>
<button
onClick={() => { setLoading(true); setTimeout(() => { setLoading(false); doLogin('gpki', DEMO_ACCOUNTS['operator']); }, 1500); }}
disabled={loading}
className="w-full py-2.5 bg-blue-600 hover:bg-blue-500 disabled:bg-blue-600/50 text-heading text-sm font-bold rounded-lg transition-colors flex items-center justify-center gap-2 whitespace-nowrap"
>
{loading ? (
<><div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />{t('gpki.authenticating')}</>
) : t('gpki.start')}
</button>
<p className="text-[9px] text-hint text-center">
{t('gpki.internalOnly')}
</p>
<div className="space-y-4 text-center py-12">
<Fingerprint className="w-12 h-12 text-blue-400 mx-auto mb-3" />
<p className="text-sm text-heading font-medium">{t('gpki.title')}</p>
<p className="text-[10px] text-hint mt-1"> (Phase 9)</p>
</div>
)}
{/* SSO 연동 */}
{/* SSO 연동 (Phase 9 도입 예정) */}
{authMethod === 'sso' && (
<div className="space-y-4">
<div className="text-center py-6">
<KeyRound className="w-12 h-12 text-green-400 mx-auto mb-3" />
<p className="text-sm text-heading font-medium">{t('sso.title')}</p>
<p className="text-[10px] text-hint mt-1">{t('sso.desc')}</p>
</div>
<div className="bg-green-900/20 border border-green-700/30 rounded-lg p-3">
<div className="flex items-center gap-2 text-[10px] text-green-400">
<Shield className="w-3.5 h-3.5" />
{t('sso.tokenDetected')}
</div>
</div>
<button
onClick={() => { setLoading(true); setTimeout(() => { setLoading(false); doLogin('sso', DEMO_ACCOUNTS['operator']); }, 800); }}
disabled={loading}
className="w-full py-2.5 bg-green-600 hover:bg-green-500 disabled:bg-green-600/50 text-heading text-sm font-bold rounded-lg transition-colors flex items-center justify-center gap-2 whitespace-nowrap"
>
{loading ? (
<><div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />{t('sso.authenticating')}</>
) : t('sso.autoLogin')}
</button>
<p className="text-[9px] text-hint text-center">
{t('sso.sessionNote')}
</p>
<div className="space-y-4 text-center py-12">
<KeyRound className="w-12 h-12 text-green-400 mx-auto mb-3" />
<p className="text-sm text-heading font-medium">{t('sso.title')}</p>
<p className="text-[10px] text-hint mt-1"> (Phase 9)</p>
</div>
)}
</div>

파일 보기

@ -0,0 +1,65 @@
/**
* API .
* - JWT (credentials: include)
* - //
*/
const API_BASE = import.meta.env.VITE_API_URL ?? '/api';
export interface BackendUser {
id: string;
account: string;
name: string;
rank: string | null;
email: string | null;
status: string;
authProvider: string;
roles: string[];
/** rsrcCd → operCd[] (READ/CREATE/UPDATE/DELETE/EXPORT) */
permissions: Record<string, string[]>;
}
export class LoginError extends Error {
constructor(public readonly reason: string) {
super(reason);
}
}
export async function loginApi(account: string, password: string): Promise<BackendUser> {
const res = await fetch(`${API_BASE}/auth/login`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ account, password }),
});
if (!res.ok) {
let reason = `HTTP_${res.status}`;
try {
const body = await res.json();
if (body?.reason) reason = body.reason;
} catch {
// ignore
}
throw new LoginError(reason);
}
return res.json();
}
export async function logoutApi(): Promise<void> {
await fetch(`${API_BASE}/auth/logout`, {
method: 'POST',
credentials: 'include',
});
}
export async function fetchMe(): Promise<BackendUser | null> {
try {
const res = await fetch(`${API_BASE}/auth/me`, {
credentials: 'include',
});
if (!res.ok) return null;
return res.json();
} catch {
return null;
}
}

파일 보기

@ -4,6 +4,8 @@ interface ImportMetaEnv {
readonly VITE_API_URL?: string;
readonly VITE_PREDICTION_URL?: string;
readonly VITE_USE_MOCK?: string;
/** 데모 퀵로그인 영역 표시 여부 (로컬 개발 환경에서만 'true') */
readonly VITE_SHOW_DEMO_LOGIN?: string;
}
interface ImportMeta {