From b0c9a9fffbd40b77d59511e17a8626bdd8d344a1 Mon Sep 17 00:00:00 2001 From: htlee Date: Tue, 7 Apr 2026 09:29:52 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=203=20-=20=EC=9E=90=EC=B2=B4=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20+=20=ED=8A=B8=EB=A6=AC=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=20RBAC=20+=20=EA=B0=90=EC=82=AC=EB=A1=9C=EA=B7=B8=20+?= =?UTF-8?q?=20=EB=8D=B0=EB=AA=A8=20=EA=B3=84=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .gitignore | 3 + backend/pom.xml | 4 + .../main/java/gc/mda/kcg/audit/AccessLog.java | 60 +++++ .../gc/mda/kcg/audit/AccessLogFilter.java | 106 ++++++++ .../gc/mda/kcg/audit/AccessLogRepository.java | 9 + .../main/java/gc/mda/kcg/audit/AuditLog.java | 62 +++++ .../gc/mda/kcg/audit/AuditLogRepository.java | 13 + .../mda/kcg/audit/annotation/AuditAspect.java | 104 ++++++++ .../mda/kcg/audit/annotation/Auditable.java | 23 ++ .../java/gc/mda/kcg/auth/AccountSeeder.java | 63 +++++ .../java/gc/mda/kcg/auth/AuthController.java | 107 ++++++++ .../java/gc/mda/kcg/auth/AuthPrincipal.java | 19 ++ .../java/gc/mda/kcg/auth/AuthService.java | 80 ++++++ .../java/gc/mda/kcg/auth/JwtAuthFilter.java | 82 +++++++ .../main/java/gc/mda/kcg/auth/JwtService.java | 74 ++++++ .../gc/mda/kcg/auth/LoginAuditWriter.java | 68 ++++++ .../java/gc/mda/kcg/auth/LoginHistory.java | 54 +++++ .../mda/kcg/auth/LoginHistoryRepository.java | 12 + .../src/main/java/gc/mda/kcg/auth/User.java | 87 +++++++ .../java/gc/mda/kcg/auth/UserRepository.java | 11 + .../gc/mda/kcg/auth/dto/LoginRequest.java | 8 + .../gc/mda/kcg/auth/dto/UserInfoResponse.java | 16 ++ .../mda/kcg/auth/provider/AuthProvider.java | 39 +++ .../auth/provider/PasswordAuthProvider.java | 71 ++++++ .../java/gc/mda/kcg/config/AppProperties.java | 39 +++ .../gc/mda/kcg/config/SecurityConfig.java | 66 ++++- .../main/java/gc/mda/kcg/permission/Perm.java | 50 ++++ .../gc/mda/kcg/permission/PermRepository.java | 17 ++ .../gc/mda/kcg/permission/PermResolver.java | 179 ++++++++++++++ .../java/gc/mda/kcg/permission/PermTree.java | 62 +++++ .../kcg/permission/PermTreeRepository.java | 10 + .../mda/kcg/permission/PermissionService.java | 97 ++++++++ .../main/java/gc/mda/kcg/permission/Role.java | 56 +++++ .../gc/mda/kcg/permission/RoleRepository.java | 11 + .../java/gc/mda/kcg/permission/UserRole.java | 41 ++++ .../gc/mda/kcg/permission/UserRoleId.java | 16 ++ .../kcg/permission/UserRoleRepository.java | 17 ++ .../annotation/PermissionAspect.java | 53 ++++ .../annotation/RequirePermission.java | 24 ++ .../db/migration/V001__auth_init.sql | 6 +- .../db/migration/V002__perm_tree.sql | 4 +- .../db/migration/V006__demo_accounts.sql | 28 +++ frontend/src/app/auth/AuthContext.tsx | 229 +++++++++++------- frontend/src/features/auth/DemoQuickLogin.tsx | 61 +++++ frontend/src/features/auth/LoginPage.tsx | 221 ++++++----------- frontend/src/services/authApi.ts | 65 +++++ frontend/src/vite-env.d.ts | 2 + 47 files changed, 2279 insertions(+), 250 deletions(-) create mode 100644 backend/src/main/java/gc/mda/kcg/audit/AccessLog.java create mode 100644 backend/src/main/java/gc/mda/kcg/audit/AccessLogFilter.java create mode 100644 backend/src/main/java/gc/mda/kcg/audit/AccessLogRepository.java create mode 100644 backend/src/main/java/gc/mda/kcg/audit/AuditLog.java create mode 100644 backend/src/main/java/gc/mda/kcg/audit/AuditLogRepository.java create mode 100644 backend/src/main/java/gc/mda/kcg/audit/annotation/AuditAspect.java create mode 100644 backend/src/main/java/gc/mda/kcg/audit/annotation/Auditable.java create mode 100644 backend/src/main/java/gc/mda/kcg/auth/AccountSeeder.java create mode 100644 backend/src/main/java/gc/mda/kcg/auth/AuthController.java create mode 100644 backend/src/main/java/gc/mda/kcg/auth/AuthPrincipal.java create mode 100644 backend/src/main/java/gc/mda/kcg/auth/AuthService.java create mode 100644 backend/src/main/java/gc/mda/kcg/auth/JwtAuthFilter.java create mode 100644 backend/src/main/java/gc/mda/kcg/auth/JwtService.java create mode 100644 backend/src/main/java/gc/mda/kcg/auth/LoginAuditWriter.java create mode 100644 backend/src/main/java/gc/mda/kcg/auth/LoginHistory.java create mode 100644 backend/src/main/java/gc/mda/kcg/auth/LoginHistoryRepository.java create mode 100644 backend/src/main/java/gc/mda/kcg/auth/User.java create mode 100644 backend/src/main/java/gc/mda/kcg/auth/UserRepository.java create mode 100644 backend/src/main/java/gc/mda/kcg/auth/dto/LoginRequest.java create mode 100644 backend/src/main/java/gc/mda/kcg/auth/dto/UserInfoResponse.java create mode 100644 backend/src/main/java/gc/mda/kcg/auth/provider/AuthProvider.java create mode 100644 backend/src/main/java/gc/mda/kcg/auth/provider/PasswordAuthProvider.java create mode 100644 backend/src/main/java/gc/mda/kcg/config/AppProperties.java create mode 100644 backend/src/main/java/gc/mda/kcg/permission/Perm.java create mode 100644 backend/src/main/java/gc/mda/kcg/permission/PermRepository.java create mode 100644 backend/src/main/java/gc/mda/kcg/permission/PermResolver.java create mode 100644 backend/src/main/java/gc/mda/kcg/permission/PermTree.java create mode 100644 backend/src/main/java/gc/mda/kcg/permission/PermTreeRepository.java create mode 100644 backend/src/main/java/gc/mda/kcg/permission/PermissionService.java create mode 100644 backend/src/main/java/gc/mda/kcg/permission/Role.java create mode 100644 backend/src/main/java/gc/mda/kcg/permission/RoleRepository.java create mode 100644 backend/src/main/java/gc/mda/kcg/permission/UserRole.java create mode 100644 backend/src/main/java/gc/mda/kcg/permission/UserRoleId.java create mode 100644 backend/src/main/java/gc/mda/kcg/permission/UserRoleRepository.java create mode 100644 backend/src/main/java/gc/mda/kcg/permission/annotation/PermissionAspect.java create mode 100644 backend/src/main/java/gc/mda/kcg/permission/annotation/RequirePermission.java create mode 100644 backend/src/main/resources/db/migration/V006__demo_accounts.sql create mode 100644 frontend/src/features/auth/DemoQuickLogin.tsx create mode 100644 frontend/src/services/authApi.ts diff --git a/.gitignore b/.gitignore index 1e9ea7f..2a3d704 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,9 @@ Thumbs.db .env .env.* !.env.example +# 프론트엔드 환경별 설정 (Vite VITE_* 변수, 배포 빌드에 필요) +!frontend/.env.development +!frontend/.env.production secrets/ # === Debug === diff --git a/backend/pom.xml b/backend/pom.xml index e3106d9..7a0d549 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -54,6 +54,10 @@ org.springframework.boot spring-boot-starter-web + + org.springframework.boot + spring-boot-starter-aop + org.flywaydb flyway-core diff --git a/backend/src/main/java/gc/mda/kcg/audit/AccessLog.java b/backend/src/main/java/gc/mda/kcg/audit/AccessLog.java new file mode 100644 index 0000000..e3bf06f --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/audit/AccessLog.java @@ -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(); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/audit/AccessLogFilter.java b/backend/src/main/java/gc/mda/kcg/audit/AccessLogFilter.java new file mode 100644 index 0000000..356bc3a --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/audit/AccessLogFilter.java @@ -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 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(); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/audit/AccessLogRepository.java b/backend/src/main/java/gc/mda/kcg/audit/AccessLogRepository.java new file mode 100644 index 0000000..2f69fad --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/audit/AccessLogRepository.java @@ -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 { + Page findAllByOrderByCreatedAtDesc(Pageable pageable); +} diff --git a/backend/src/main/java/gc/mda/kcg/audit/AuditLog.java b/backend/src/main/java/gc/mda/kcg/audit/AuditLog.java new file mode 100644 index 0000000..3cc3f95 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/audit/AuditLog.java @@ -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 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(); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/audit/AuditLogRepository.java b/backend/src/main/java/gc/mda/kcg/audit/AuditLogRepository.java new file mode 100644 index 0000000..68aeaf4 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/audit/AuditLogRepository.java @@ -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 { + Page findAllByOrderByCreatedAtDesc(Pageable pageable); + Page findByUserIdOrderByCreatedAtDesc(UUID userId, Pageable pageable); + Page findByActionCdOrderByCreatedAtDesc(String actionCd, Pageable pageable); +} diff --git a/backend/src/main/java/gc/mda/kcg/audit/annotation/AuditAspect.java b/backend/src/main/java/gc/mda/kcg/audit/annotation/AuditAspect.java new file mode 100644 index 0000000..f06b125 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/audit/annotation/AuditAspect.java @@ -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 detail = new HashMap<>(); + detail.put("method", ((MethodSignature) pjp.getSignature()).getMethod().getName()); + // 파라미터 이름은 컴파일 옵션 -parameters 필요 - 여기서는 단순 인덱스로 기록 + Object[] args = pjp.getArgs(); + if (args != null) { + Map 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 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; + } + } +} diff --git a/backend/src/main/java/gc/mda/kcg/audit/annotation/Auditable.java b/backend/src/main/java/gc/mda/kcg/audit/annotation/Auditable.java new file mode 100644 index 0000000..31abc5c --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/audit/annotation/Auditable.java @@ -0,0 +1,23 @@ +package gc.mda.kcg.audit.annotation; + +import java.lang.annotation.*; + +/** + * 메서드 실행 시 감사로그 자동 기록. + * + * 사용 예: + *
+ * @Auditable(action = "CONFIRM_PARENT", resourceType = "GEAR_GROUP")
+ * public void confirmParent(String groupKey, ...) { ... }
+ * 
+ */ +@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"; +} diff --git a/backend/src/main/java/gc/mda/kcg/auth/AccountSeeder.java b/backend/src/main/java/gc/mda/kcg/auth/AccountSeeder.java new file mode 100644 index 0000000..f57abfb --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/auth/AccountSeeder.java @@ -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 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 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); + }; + } +} diff --git a/backend/src/main/java/gc/mda/kcg/auth/AuthController.java b/backend/src/main/java/gc/mda/kcg/auth/AuthController.java new file mode 100644 index 0000000..e02da34 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/auth/AuthController.java @@ -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(); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/auth/AuthPrincipal.java b/backend/src/main/java/gc/mda/kcg/auth/AuthPrincipal.java new file mode 100644 index 0000000..bf44ed0 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/auth/AuthPrincipal.java @@ -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 roles; +} diff --git a/backend/src/main/java/gc/mda/kcg/auth/AuthService.java b/backend/src/main/java/gc/mda/kcg/auth/AuthService.java new file mode 100644 index 0000000..31d1399 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/auth/AuthService.java @@ -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 roles = permissionService.getRoleCodesByUserId(userId); + Map> 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 roles, Map> permissions) {} +} diff --git a/backend/src/main/java/gc/mda/kcg/auth/JwtAuthFilter.java b/backend/src/main/java/gc/mda/kcg/auth/JwtAuthFilter.java new file mode 100644 index 0000000..14afe0a --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/auth/JwtAuthFilter.java @@ -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 roles = claims.get("roles", List.class); + + AuthPrincipal principal = AuthPrincipal.builder() + .userId(userId) + .userAcnt(userAcnt) + .userNm(userNm) + .roles(roles) + .build(); + + List 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; + } +} diff --git a/backend/src/main/java/gc/mda/kcg/auth/JwtService.java b/backend/src/main/java/gc/mda/kcg/auth/JwtService.java new file mode 100644 index 0000000..366dbef --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/auth/JwtService.java @@ -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 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(); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/auth/LoginAuditWriter.java b/backend/src/main/java/gc/mda/kcg/auth/LoginAuditWriter.java new file mode 100644 index 0000000..0939527 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/auth/LoginAuditWriter.java @@ -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()); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/auth/LoginHistory.java b/backend/src/main/java/gc/mda/kcg/auth/LoginHistory.java new file mode 100644 index 0000000..3076f4b --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/auth/LoginHistory.java @@ -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(); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/auth/LoginHistoryRepository.java b/backend/src/main/java/gc/mda/kcg/auth/LoginHistoryRepository.java new file mode 100644 index 0000000..1aad7b6 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/auth/LoginHistoryRepository.java @@ -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 { + Page findByUserIdOrderByLoginDtmDesc(UUID userId, Pageable pageable); + Page findAllByOrderByLoginDtmDesc(Pageable pageable); +} diff --git a/backend/src/main/java/gc/mda/kcg/auth/User.java b/backend/src/main/java/gc/mda/kcg/auth/User.java new file mode 100644 index 0000000..9aa2574 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/auth/User.java @@ -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); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/auth/UserRepository.java b/backend/src/main/java/gc/mda/kcg/auth/UserRepository.java new file mode 100644 index 0000000..895cf11 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/auth/UserRepository.java @@ -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 { + Optional findByUserAcnt(String userAcnt); + boolean existsByUserAcnt(String userAcnt); +} diff --git a/backend/src/main/java/gc/mda/kcg/auth/dto/LoginRequest.java b/backend/src/main/java/gc/mda/kcg/auth/dto/LoginRequest.java new file mode 100644 index 0000000..ed4c591 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/auth/dto/LoginRequest.java @@ -0,0 +1,8 @@ +package gc.mda.kcg.auth.dto; + +import jakarta.validation.constraints.NotBlank; + +public record LoginRequest( + @NotBlank String account, + @NotBlank String password +) {} diff --git a/backend/src/main/java/gc/mda/kcg/auth/dto/UserInfoResponse.java b/backend/src/main/java/gc/mda/kcg/auth/dto/UserInfoResponse.java new file mode 100644 index 0000000..6e101ea --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/auth/dto/UserInfoResponse.java @@ -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 roles, + Map> permissions +) {} diff --git a/backend/src/main/java/gc/mda/kcg/auth/provider/AuthProvider.java b/backend/src/main/java/gc/mda/kcg/auth/provider/AuthProvider.java new file mode 100644 index 0000000..656ff09 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/auth/provider/AuthProvider.java @@ -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; } + } +} diff --git a/backend/src/main/java/gc/mda/kcg/auth/provider/PasswordAuthProvider.java b/backend/src/main/java/gc/mda/kcg/auth/provider/PasswordAuthProvider.java new file mode 100644 index 0000000..168f4d7 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/auth/provider/PasswordAuthProvider.java @@ -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; + } +} diff --git a/backend/src/main/java/gc/mda/kcg/config/AppProperties.java b/backend/src/main/java/gc/mda/kcg/config/AppProperties.java new file mode 100644 index 0000000..2df79d4 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/config/AppProperties.java @@ -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; + } +} diff --git a/backend/src/main/java/gc/mda/kcg/config/SecurityConfig.java b/backend/src/main/java/gc/mda/kcg/config/SecurityConfig.java index 68cdb2c..1a2417a 100644 --- a/backend/src/main/java/gc/mda/kcg/config/SecurityConfig.java +++ b/backend/src/main/java/gc/mda/kcg/config/SecurityConfig.java @@ -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(); } diff --git a/backend/src/main/java/gc/mda/kcg/permission/Perm.java b/backend/src/main/java/gc/mda/kcg/permission/Perm.java new file mode 100644 index 0000000..0d9b043 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/permission/Perm.java @@ -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(); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/permission/PermRepository.java b/backend/src/main/java/gc/mda/kcg/permission/PermRepository.java new file mode 100644 index 0000000..2704c9a --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/permission/PermRepository.java @@ -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 { + + List findByRoleSn(Long roleSn); + + @Query("SELECT p FROM Perm p WHERE p.roleSn IN :roleSns") + List findByRoleSnIn(@Param("roleSns") List roleSns); + + void deleteByRoleSn(Long roleSn); +} diff --git a/backend/src/main/java/gc/mda/kcg/permission/PermResolver.java b/backend/src/main/java/gc/mda/kcg/permission/PermResolver.java new file mode 100644 index 0000000..c4bf754 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/permission/PermResolver.java @@ -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 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> resolveSingleRole( + List treeNodes, + Map explicitPerms + ) { + // 트리 인덱싱 (parent → children) + Map> childrenMap = new HashMap<>(); + Map 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> resolved = new HashMap<>(); + + // 루트 노드부터 BFS (parentCd가 null인 노드) + List roots = childrenMap.getOrDefault(null, Collections.emptyList()); + for (PermTree root : roots) { + walkTree(root, null, childrenMap, explicitPerms, resolved); + } + + return resolved; + } + + /** + * 트리 순회: 부모의 효과적 권한을 컨텍스트로 받아 자식에 전파. + */ + private void walkTree( + PermTree node, + Set parentEffective, + Map> childrenMap, + Map explicitPerms, + Map> resolved + ) { + Set 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 publicOps = new HashSet<>(nodeEffective); + publicOps.remove("__defined__"); + if (!publicOps.isEmpty()) { + resolved.put(node.getRsrcCd(), publicOps); + } + + // 자식 재귀 + List 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 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> resolveMultiRole( + List treeNodes, + Map> permsByRole + ) { + Map> merged = new HashMap<>(); + + for (Map.Entry> entry : permsByRole.entrySet()) { + Map> single = resolveSingleRole(treeNodes, entry.getValue()); + for (Map.Entry> e : single.entrySet()) { + merged.computeIfAbsent(e.getKey(), k -> new HashSet<>()).addAll(e.getValue()); + } + } + + // Set → List 변환 + 안정적 정렬 + Map> result = new HashMap<>(); + for (Map.Entry> e : merged.entrySet()) { + List 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> resolved, String rsrcCd, String operCd) { + List 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 parentOps = resolved.get(parent); + return parentOps != null && parentOps.contains(operCd); + } + return false; + } +} diff --git a/backend/src/main/java/gc/mda/kcg/permission/PermTree.java b/backend/src/main/java/gc/mda/kcg/permission/PermTree.java new file mode 100644 index 0000000..4c6b194 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/permission/PermTree.java @@ -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(); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/permission/PermTreeRepository.java b/backend/src/main/java/gc/mda/kcg/permission/PermTreeRepository.java new file mode 100644 index 0000000..f168c11 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/permission/PermTreeRepository.java @@ -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 { + List findAllByOrderByRsrcLevelAscSortOrdAsc(); + List findByUseYn(String useYn); +} diff --git a/backend/src/main/java/gc/mda/kcg/permission/PermissionService.java b/backend/src/main/java/gc/mda/kcg/permission/PermissionService.java new file mode 100644 index 0000000..da1a9ec --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/permission/PermissionService.java @@ -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> getResolvedPermissionsByUserId(UUID userId) { + // 1. 사용자의 역할 조회 + List userRoles = userRoleRepository.findByUserId(userId); + if (userRoles.isEmpty()) { + log.debug("User {} has no roles", userId); + return Collections.emptyMap(); + } + + List roleSns = userRoles.stream().map(UserRole::getRoleSn).toList(); + + // 2. 역할별 명시 권한 로드 + List perms = permRepository.findByRoleSnIn(roleSns); + Map> 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 treeNodes = permTreeRepository.findByUseYn("Y"); + + // 4. 다중 역할 해석 + Map> 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> resolved = getResolvedPermissionsByUserId(userId); + return permResolver.hasPermission(resolved, rsrcCd, operCd); + } + + /** + * 사용자 역할 코드 목록 조회. + */ + @Transactional(readOnly = true) + public List 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"); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/permission/Role.java b/backend/src/main/java/gc/mda/kcg/permission/Role.java new file mode 100644 index 0000000..c8e22de --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/permission/Role.java @@ -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(); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/permission/RoleRepository.java b/backend/src/main/java/gc/mda/kcg/permission/RoleRepository.java new file mode 100644 index 0000000..ea1b8a8 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/permission/RoleRepository.java @@ -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 { + Optional findByRoleCd(String roleCd); + List findAllByOrderByRoleSnAsc(); +} diff --git a/backend/src/main/java/gc/mda/kcg/permission/UserRole.java b/backend/src/main/java/gc/mda/kcg/permission/UserRole.java new file mode 100644 index 0000000..fb74682 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/permission/UserRole.java @@ -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(); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/permission/UserRoleId.java b/backend/src/main/java/gc/mda/kcg/permission/UserRoleId.java new file mode 100644 index 0000000..0e5e821 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/permission/UserRoleId.java @@ -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; +} diff --git a/backend/src/main/java/gc/mda/kcg/permission/UserRoleRepository.java b/backend/src/main/java/gc/mda/kcg/permission/UserRoleRepository.java new file mode 100644 index 0000000..31b6267 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/permission/UserRoleRepository.java @@ -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 { + + List 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 findRoleCodesByUserId(UUID userId); +} diff --git a/backend/src/main/java/gc/mda/kcg/permission/annotation/PermissionAspect.java b/backend/src/main/java/gc/mda/kcg/permission/annotation/PermissionAspect.java new file mode 100644 index 0000000..8b8dbde --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/permission/annotation/PermissionAspect.java @@ -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()); + } + } +} diff --git a/backend/src/main/java/gc/mda/kcg/permission/annotation/RequirePermission.java b/backend/src/main/java/gc/mda/kcg/permission/annotation/RequirePermission.java new file mode 100644 index 0000000..b6bec07 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/permission/annotation/RequirePermission.java @@ -0,0 +1,24 @@ +package gc.mda.kcg.permission.annotation; + +import java.lang.annotation.*; + +/** + * 메서드/클래스 레벨 권한 요구 어노테이션. + * + * 사용 예: + *
+ * @RequirePermission(resource = "parent-inference-workflow:parent-review", operation = "UPDATE")
+ * @PostMapping("/groups/{key}/parent-inference/{sub}/review")
+ * public ResponseEntity review(...) { ... }
+ * 
+ */ +@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"; +} diff --git a/backend/src/main/resources/db/migration/V001__auth_init.sql b/backend/src/main/resources/db/migration/V001__auth_init.sql index 4f6da55..9b785e0 100644 --- a/backend/src/main/resources/db/migration/V001__auth_init.sql +++ b/backend/src/main/resources/db/migration/V001__auth_init.sql @@ -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() ); diff --git a/backend/src/main/resources/db/migration/V002__perm_tree.sql b/backend/src/main/resources/db/migration/V002__perm_tree.sql index 8c425e6..3674dbe 100644 --- a/backend/src/main/resources/db/migration/V002__perm_tree.sql +++ b/backend/src/main/resources/db/migration/V002__perm_tree.sql @@ -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) diff --git a/backend/src/main/resources/db/migration/V006__demo_accounts.sql b/backend/src/main/resources/db/migration/V006__demo_accounts.sql new file mode 100644 index 0000000..56530a1 --- /dev/null +++ b/backend/src/main/resources/db/migration/V006__demo_accounts.sql @@ -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'); diff --git a/frontend/src/app/auth/AuthContext.tsx b/frontend/src/app/auth/AuthContext.tsx index d0d02ab..4de13af 100644 --- a/frontend/src/app/auth/AuthContext.tsx +++ b/frontend/src/app/auth/AuthContext.tsx @@ -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; authMethod: 'password' | 'gpki' | 'sso'; loginAt: string; } -// ─── 역할별 접근 가능 경로 ────────────────── -const ROLE_PERMISSIONS: Record = { - 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) { - 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 = { + '/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; + logout: () => Promise; + /** 경로 기반 접근 가능 여부 (메뉴/라우트 가드용) */ hasAccess: (path: string) => boolean; - sessionRemaining: number; // seconds + /** 트리 기반 권한 체크 (resource + operation) */ + hasPermission: (resource: string, operation?: string) => boolean; + sessionRemaining: number; } const AuthContext = createContext(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, 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(() => { - const stored = sessionStorage.getItem('auth_user'); - return stored ? JSON.parse(stored) : null; - }); + const [user, setUser] = useState(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 ( - + {children} ); diff --git a/frontend/src/features/auth/DemoQuickLogin.tsx b/frontend/src/features/auth/DemoQuickLogin.tsx new file mode 100644 index 0000000..c60c75a --- /dev/null +++ b/frontend/src/features/auth/DemoQuickLogin.tsx @@ -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 ( +
+
{t('demo.title')}
+
+ {DEMO_ACCOUNTS.map((acct) => ( + + ))} +
+
+ ); +} diff --git a/frontend/src/features/auth/LoginPage.tsx b/frontend/src/features/auth/LoginPage.tsx index 5d313eb..a8d7f8b 100644 --- a/frontend/src/features/auth/LoginPage.tsx +++ b/frontend/src/features/auth/LoginPage.tsx @@ -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 = { - 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 = { + 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(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 = { - 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) => ( - {/* 데모 퀵로그인 */} -
-
{t('demo.title')}
-
- {Object.entries(DEMO_ACCOUNTS).map(([key, acct]) => ( - - ))} -
-
+ {/* 데모 퀵로그인 (VITE_SHOW_DEMO_LOGIN=true일 때만 렌더링) */} + )} - {/* GPKI 인증 */} + {/* GPKI 인증 (Phase 9 도입 예정) */} {authMethod === 'gpki' && ( -
-
- -

{t('gpki.title')}

-

{t('gpki.desc')}

-
- -
-
-
{t('gpki.certStatus')}
-
-
- {t('gpki.certWaiting')} -
-
-
- - - -

- {t('gpki.internalOnly')} -

+
+ +

{t('gpki.title')}

+

향후 도입 예정 (Phase 9)

)} - {/* SSO 연동 */} + {/* SSO 연동 (Phase 9 도입 예정) */} {authMethod === 'sso' && ( -
-
- -

{t('sso.title')}

-

{t('sso.desc')}

-
- -
-
- - {t('sso.tokenDetected')} -
-
- - - -

- {t('sso.sessionNote')} -

+
+ +

{t('sso.title')}

+

향후 도입 예정 (Phase 9)

)}
diff --git a/frontend/src/services/authApi.ts b/frontend/src/services/authApi.ts new file mode 100644 index 0000000..7035b2a --- /dev/null +++ b/frontend/src/services/authApi.ts @@ -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; +} + +export class LoginError extends Error { + constructor(public readonly reason: string) { + super(reason); + } +} + +export async function loginApi(account: string, password: string): Promise { + 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 { + await fetch(`${API_BASE}/auth/logout`, { + method: 'POST', + credentials: 'include', + }); +} + +export async function fetchMe(): Promise { + try { + const res = await fetch(`${API_BASE}/auth/me`, { + credentials: 'include', + }); + if (!res.ok) return null; + return res.json(); + } catch { + return null; + } +} diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts index 593777d..d2e3bdc 100644 --- a/frontend/src/vite-env.d.ts +++ b/frontend/src/vite-env.d.ts @@ -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 {