feat: Phase 3 - 자체 인증 + 트리 기반 RBAC + 감사로그 + 데모 계정
Phase 3-1~10: 백엔드
- pom.xml에 spring-boot-starter-aop 추가
- JPA 엔티티 12종 + Repository 9종
(User/LoginHistory/Role/UserRole/PermTree/Perm/AuditLog/AccessLog 등)
- PermResolver: wing 프로젝트의 permResolver.ts를 Java로 이식
- 트리 BFS + 부모 READ 게이팅 + 다중 역할 OR 합집합 + 부모 fallback
- PermissionService: Caffeine 캐싱 (TTL 10분)
- JwtService + JwtAuthFilter (HttpOnly 쿠키 + Authorization 헤더 fallback)
- AuthProvider 인터페이스 + PasswordAuthProvider (BCrypt + 5회 잠금)
- REQUIRES_NEW + noRollbackFor로 fail_cnt 증가 보존
- AuthService + LoginAuditWriter (REQUIRES_NEW로 실패 기록 보존)
- AuthController: /api/auth/login, /logout, /me
- @RequirePermission 어노테이션 + PermissionAspect (메서드 권한 체크)
- @Auditable 어노테이션 + AuditAspect (의사결정 자동 기록)
- AccessLogFilter: 모든 HTTP 요청 비동기 기록 (BlockingQueue)
- SecurityConfig 본격 도입 (CORS + JWT 필터 + 401/403 핸들러)
Phase 3-10: 데모 계정
- V006__demo_accounts.sql: 5개 데모 계정 (admin/operator/analyst/field/viewer)
+ 역할 매핑 (PLACEHOLDER 해시)
- AccountSeeder.java: 시동 시 BCrypt 해시 시드 (PLACEHOLDER만 갱신)
- 데모 계정도 실제 권한, 로그인 이력, 감사로그 기록 대상
Phase 3-11: 백엔드 검증 완료
- admin/operator/viewer 로그인 성공
- 권한 매트릭스: ADMIN(49), OPERATOR(40), VIEWER(35)
- 트리 상속 검증: detection READ → 자식 4개 자동 상속
- 잘못된 비밀번호 → fail_cnt 증가 + login_hist FAILED 기록
- 정상 로그인 → fail_cnt 0 초기화
- 모든 요청 access_log에 비동기 기록
V001/V002: CHAR(1) → VARCHAR(1) 변경 (Hibernate validate 호환성)
Phase 3-12: 프론트엔드 연동
- services/authApi.ts: 백엔드 호출 클라이언트 (login/logout/me)
- AuthContext.tsx: 백엔드 API 통합 + 트리 기반 hasPermission
+ 부모 fallback (예: detection:gear-detection 미등록 시 detection 검사)
+ 30분 세션 타임아웃 유지
- DemoQuickLogin.tsx: 데모 퀵로그인 컴포넌트 분리
+ isDemoLoginEnabled() = VITE_SHOW_DEMO_LOGIN === 'true'
+ 데모 클릭 시에도 정상 백엔드 인증 플로우 사용
- LoginPage.tsx: 백엔드 인증 호출 + DemoQuickLogin 통합
+ 에러 메시지 한국어 변환 (WRONG_PASSWORD:N, ACCOUNT_LOCKED 등)
+ GPKI/SSO 탭은 disabled (Phase 9 도입 예정)
- frontend/.env.development: VITE_SHOW_DEMO_LOGIN=true
- frontend/.env.production: VITE_SHOW_DEMO_LOGIN=true (현재 단계)
- .gitignore에 frontend/.env.{development,production} 예외 추가
설계 핵심:
- 데모 계정은 백엔드 DB에 실제 권한 부여 + 로그인/감사 기록 대상
- DemoQuickLogin 컴포넌트는 환경변수로 토글 가능하도록 구조 분리
- 향후 운영 배포 시 .env.production만 false로 변경하면 데모 영역 숨김
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
부모
04dfdf2d36
커밋
b0c9a9fffb
3
.gitignore
vendored
3
.gitignore
vendored
@ -22,6 +22,9 @@ Thumbs.db
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
# 프론트엔드 환경별 설정 (Vite VITE_* 변수, 배포 빌드에 필요)
|
||||
!frontend/.env.development
|
||||
!frontend/.env.production
|
||||
secrets/
|
||||
|
||||
# === Debug ===
|
||||
|
||||
@ -54,6 +54,10 @@
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-aop</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.flywaydb</groupId>
|
||||
<artifactId>flyway-core</artifactId>
|
||||
|
||||
60
backend/src/main/java/gc/mda/kcg/audit/AccessLog.java
Normal file
60
backend/src/main/java/gc/mda/kcg/audit/AccessLog.java
Normal file
@ -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();
|
||||
}
|
||||
}
|
||||
106
backend/src/main/java/gc/mda/kcg/audit/AccessLogFilter.java
Normal file
106
backend/src/main/java/gc/mda/kcg/audit/AccessLogFilter.java
Normal file
@ -0,0 +1,106 @@
|
||||
package gc.mda.kcg.audit;
|
||||
|
||||
import gc.mda.kcg.auth.AuthPrincipal;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.concurrent.ArrayBlockingQueue;
|
||||
import java.util.concurrent.BlockingQueue;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
/**
|
||||
* 모든 HTTP 요청을 auth_access_log에 기록.
|
||||
* 비동기 큐 기반 — 요청 처리 지연 최소화.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@Order(100) // JwtAuthFilter(Spring 기본 -100) 이후 실행
|
||||
@RequiredArgsConstructor
|
||||
public class AccessLogFilter extends OncePerRequestFilter {
|
||||
|
||||
private final AccessLogRepository accessLogRepository;
|
||||
private static final BlockingQueue<AccessLog> QUEUE = new ArrayBlockingQueue<>(10000);
|
||||
private static volatile boolean workerStarted = false;
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
|
||||
throws ServletException, IOException {
|
||||
|
||||
long start = System.currentTimeMillis();
|
||||
try {
|
||||
chain.doFilter(req, res);
|
||||
} finally {
|
||||
ensureWorkerStarted();
|
||||
try {
|
||||
AuthPrincipal principal = currentPrincipal();
|
||||
AccessLog log = AccessLog.builder()
|
||||
.userId(principal != null ? principal.getUserId() : null)
|
||||
.userAcnt(principal != null ? principal.getUserAcnt() : null)
|
||||
.httpMethod(req.getMethod())
|
||||
.requestPath(req.getRequestURI())
|
||||
.queryString(req.getQueryString())
|
||||
.statusCode(res.getStatus())
|
||||
.durationMs((int) (System.currentTimeMillis() - start))
|
||||
.ipAddress(extractIp(req))
|
||||
.userAgent(req.getHeader("User-Agent"))
|
||||
.build();
|
||||
QUEUE.offer(log);
|
||||
} catch (Exception ignored) {
|
||||
// 접근 로그 실패가 응답을 막지 않도록
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean shouldNotFilter(HttpServletRequest req) {
|
||||
String path = req.getRequestURI();
|
||||
return path.startsWith("/actuator/health") || path.startsWith("/error") || path.equals("/favicon.ico");
|
||||
}
|
||||
|
||||
private void ensureWorkerStarted() {
|
||||
if (workerStarted) return;
|
||||
synchronized (AccessLogFilter.class) {
|
||||
if (workerStarted) return;
|
||||
workerStarted = true;
|
||||
Executors.newSingleThreadExecutor(r -> {
|
||||
Thread t = new Thread(r, "access-log-writer");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
}).submit(() -> {
|
||||
while (true) {
|
||||
try {
|
||||
AccessLog log = QUEUE.take();
|
||||
accessLogRepository.save(log);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
return;
|
||||
} catch (Exception e) {
|
||||
AccessLogFilter.log.error("AccessLog 저장 실패", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private AuthPrincipal currentPrincipal() {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth != null && auth.getPrincipal() instanceof AuthPrincipal p) return p;
|
||||
return null;
|
||||
}
|
||||
|
||||
private String extractIp(HttpServletRequest req) {
|
||||
String fwd = req.getHeader("X-Forwarded-For");
|
||||
if (fwd != null && !fwd.isBlank()) return fwd.split(",")[0].trim();
|
||||
return req.getRemoteAddr();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
package gc.mda.kcg.audit;
|
||||
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface AccessLogRepository extends JpaRepository<AccessLog, Long> {
|
||||
Page<AccessLog> findAllByOrderByCreatedAtDesc(Pageable pageable);
|
||||
}
|
||||
62
backend/src/main/java/gc/mda/kcg/audit/AuditLog.java
Normal file
62
backend/src/main/java/gc/mda/kcg/audit/AuditLog.java
Normal file
@ -0,0 +1,62 @@
|
||||
package gc.mda.kcg.audit;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import org.hibernate.annotations.JdbcTypeCode;
|
||||
import org.hibernate.type.SqlTypes;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(name = "auth_audit_log", schema = "kcg")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class AuditLog {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@Column(name = "audit_sn")
|
||||
private Long auditSn;
|
||||
|
||||
@JdbcTypeCode(SqlTypes.UUID)
|
||||
@Column(name = "user_id")
|
||||
private UUID userId;
|
||||
|
||||
@Column(name = "user_acnt", length = 50)
|
||||
private String userAcnt;
|
||||
|
||||
@Column(name = "action_cd", nullable = false, length = 50)
|
||||
private String actionCd;
|
||||
|
||||
@Column(name = "resource_type", length = 50)
|
||||
private String resourceType;
|
||||
|
||||
@Column(name = "resource_id", length = 100)
|
||||
private String resourceId;
|
||||
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
@Column(name = "detail", columnDefinition = "jsonb")
|
||||
private Map<String, Object> detail;
|
||||
|
||||
@Column(name = "ip_address", length = 45)
|
||||
private String ipAddress;
|
||||
|
||||
@Column(name = "result", length = 20)
|
||||
private String result; // SUCCESS / FAILED
|
||||
|
||||
@Column(name = "fail_reason", columnDefinition = "text")
|
||||
private String failReason;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private OffsetDateTime createdAt;
|
||||
|
||||
@PrePersist
|
||||
void prePersist() {
|
||||
if (createdAt == null) createdAt = OffsetDateTime.now();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,13 @@
|
||||
package gc.mda.kcg.audit;
|
||||
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public interface AuditLogRepository extends JpaRepository<AuditLog, Long> {
|
||||
Page<AuditLog> findAllByOrderByCreatedAtDesc(Pageable pageable);
|
||||
Page<AuditLog> findByUserIdOrderByCreatedAtDesc(UUID userId, Pageable pageable);
|
||||
Page<AuditLog> findByActionCdOrderByCreatedAtDesc(String actionCd, Pageable pageable);
|
||||
}
|
||||
@ -0,0 +1,104 @@
|
||||
package gc.mda.kcg.audit.annotation;
|
||||
|
||||
import gc.mda.kcg.audit.AuditLog;
|
||||
import gc.mda.kcg.audit.AuditLogRepository;
|
||||
import gc.mda.kcg.auth.AuthPrincipal;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.aspectj.lang.ProceedingJoinPoint;
|
||||
import org.aspectj.lang.annotation.Around;
|
||||
import org.aspectj.lang.annotation.Aspect;
|
||||
import org.aspectj.lang.reflect.MethodSignature;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.context.request.RequestContextHolder;
|
||||
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* @Auditable 어노테이션 → AOP가 메서드 실행 전후 auth_audit_log 기록.
|
||||
* 성공/실패 모두 기록.
|
||||
*/
|
||||
@Slf4j
|
||||
@Aspect
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class AuditAspect {
|
||||
|
||||
private final AuditLogRepository auditLogRepository;
|
||||
|
||||
@Around("@annotation(auditable)")
|
||||
public Object audit(ProceedingJoinPoint pjp, Auditable auditable) throws Throwable {
|
||||
AuthPrincipal principal = currentPrincipal();
|
||||
String ipAddress = currentIp();
|
||||
|
||||
Map<String, Object> detail = new HashMap<>();
|
||||
detail.put("method", ((MethodSignature) pjp.getSignature()).getMethod().getName());
|
||||
// 파라미터 이름은 컴파일 옵션 -parameters 필요 - 여기서는 단순 인덱스로 기록
|
||||
Object[] args = pjp.getArgs();
|
||||
if (args != null) {
|
||||
Map<String, Object> argMap = new HashMap<>();
|
||||
for (int i = 0; i < args.length; i++) {
|
||||
Object a = args[i];
|
||||
if (a == null) continue;
|
||||
if (a instanceof CharSequence || a instanceof Number || a instanceof Boolean) {
|
||||
argMap.put("arg" + i, a.toString());
|
||||
}
|
||||
}
|
||||
if (!argMap.isEmpty()) detail.put("args", argMap);
|
||||
}
|
||||
|
||||
try {
|
||||
Object result = pjp.proceed();
|
||||
saveLog(principal, auditable, detail, ipAddress, "SUCCESS", null);
|
||||
return result;
|
||||
} catch (Throwable e) {
|
||||
detail.put("exception", e.getClass().getSimpleName());
|
||||
saveLog(principal, auditable, detail, ipAddress, "FAILED", e.getMessage());
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
private void saveLog(AuthPrincipal principal, Auditable ann, Map<String, Object> detail,
|
||||
String ipAddress, String result, String failReason) {
|
||||
try {
|
||||
AuditLog log = AuditLog.builder()
|
||||
.userId(principal != null ? principal.getUserId() : null)
|
||||
.userAcnt(principal != null ? principal.getUserAcnt() : null)
|
||||
.actionCd(ann.action())
|
||||
.resourceType(ann.resourceType())
|
||||
.ipAddress(ipAddress)
|
||||
.detail(detail)
|
||||
.result(result)
|
||||
.failReason(failReason)
|
||||
.build();
|
||||
auditLogRepository.save(log);
|
||||
} catch (Exception ex) {
|
||||
// 감사 기록 실패가 비즈니스를 막지 않도록
|
||||
AuditAspect.log.error("감사로그 기록 실패", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private AuthPrincipal currentPrincipal() {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth != null && auth.getPrincipal() instanceof AuthPrincipal p) return p;
|
||||
return null;
|
||||
}
|
||||
|
||||
private String currentIp() {
|
||||
try {
|
||||
ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
|
||||
if (attrs == null) return null;
|
||||
HttpServletRequest req = attrs.getRequest();
|
||||
String fwd = req.getHeader("X-Forwarded-For");
|
||||
if (fwd != null && !fwd.isBlank()) return fwd.split(",")[0].trim();
|
||||
return req.getRemoteAddr();
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
package gc.mda.kcg.audit.annotation;
|
||||
|
||||
import java.lang.annotation.*;
|
||||
|
||||
/**
|
||||
* 메서드 실행 시 감사로그 자동 기록.
|
||||
*
|
||||
* 사용 예:
|
||||
* <pre>
|
||||
* @Auditable(action = "CONFIRM_PARENT", resourceType = "GEAR_GROUP")
|
||||
* public void confirmParent(String groupKey, ...) { ... }
|
||||
* </pre>
|
||||
*/
|
||||
@Target(ElementType.METHOD)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Documented
|
||||
public @interface Auditable {
|
||||
/** 액션 코드 (예: CONFIRM_PARENT, REJECT_PARENT, USER_CREATE, ROLE_GRANT, PERM_UPDATE) */
|
||||
String action();
|
||||
|
||||
/** 리소스 타입 (예: VESSEL, GROUP, USER, ROLE, SYSTEM) */
|
||||
String resourceType() default "SYSTEM";
|
||||
}
|
||||
63
backend/src/main/java/gc/mda/kcg/auth/AccountSeeder.java
Normal file
63
backend/src/main/java/gc/mda/kcg/auth/AccountSeeder.java
Normal file
@ -0,0 +1,63 @@
|
||||
package gc.mda.kcg.auth;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.ApplicationRunner;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 데모 계정 5종의 BCrypt 해시 시드/갱신 (시동 시 1회).
|
||||
* V006이 PLACEHOLDER로 계정을 만들었고, 이 Runner가 실제 해시를 채워넣음.
|
||||
*
|
||||
* 데모 계정 비밀번호 (LoginPage의 DEMO_ACCOUNTS와 동일):
|
||||
* admin / admin1234!
|
||||
* operator / oper12345!
|
||||
* analyst / anal12345!
|
||||
* field / field1234!
|
||||
* viewer / view12345!
|
||||
*
|
||||
* 기존 해시가 PLACEHOLDER가 아니면 갱신하지 않음 (운영 중 비밀번호 변경 보존).
|
||||
*/
|
||||
@Slf4j
|
||||
@Configuration
|
||||
@RequiredArgsConstructor
|
||||
public class AccountSeeder {
|
||||
|
||||
private static final String PLACEHOLDER = "PLACEHOLDER_TO_BE_SEEDED";
|
||||
|
||||
private static final Map<String, String> DEMO_PASSWORDS = Map.of(
|
||||
"admin", "admin1234!",
|
||||
"operator", "oper12345!",
|
||||
"analyst", "anal12345!",
|
||||
"field", "field1234!",
|
||||
"viewer", "view12345!"
|
||||
);
|
||||
|
||||
@Bean
|
||||
public ApplicationRunner seedDemoAccounts(UserRepository userRepository, PasswordEncoder passwordEncoder) {
|
||||
return args -> {
|
||||
int updated = 0;
|
||||
for (Map.Entry<String, String> e : DEMO_PASSWORDS.entrySet()) {
|
||||
String acnt = e.getKey();
|
||||
String rawPw = e.getValue();
|
||||
userRepository.findByUserAcnt(acnt).ifPresent(user -> {
|
||||
if (PLACEHOLDER.equals(user.getPswdHash())) {
|
||||
user.setPswdHash(passwordEncoder.encode(rawPw));
|
||||
userRepository.save(user);
|
||||
log.info("데모 계정 BCrypt 해시 시드: {}", acnt);
|
||||
}
|
||||
});
|
||||
if (userRepository.findByUserAcnt(acnt)
|
||||
.map(u -> u.getPswdHash() != null && !PLACEHOLDER.equals(u.getPswdHash()))
|
||||
.orElse(false)) {
|
||||
updated++;
|
||||
}
|
||||
}
|
||||
log.info("AccountSeeder 완료: {}개 데모 계정 활성", updated);
|
||||
};
|
||||
}
|
||||
}
|
||||
107
backend/src/main/java/gc/mda/kcg/auth/AuthController.java
Normal file
107
backend/src/main/java/gc/mda/kcg/auth/AuthController.java
Normal file
@ -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();
|
||||
}
|
||||
}
|
||||
19
backend/src/main/java/gc/mda/kcg/auth/AuthPrincipal.java
Normal file
19
backend/src/main/java/gc/mda/kcg/auth/AuthPrincipal.java
Normal file
@ -0,0 +1,19 @@
|
||||
package gc.mda.kcg.auth;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 인증된 사용자 컨텍스트 (SecurityContextHolder의 principal 객체).
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
public class AuthPrincipal {
|
||||
private final UUID userId;
|
||||
private final String userAcnt;
|
||||
private final String userNm;
|
||||
private final List<String> roles;
|
||||
}
|
||||
80
backend/src/main/java/gc/mda/kcg/auth/AuthService.java
Normal file
80
backend/src/main/java/gc/mda/kcg/auth/AuthService.java
Normal file
@ -0,0 +1,80 @@
|
||||
package gc.mda.kcg.auth;
|
||||
|
||||
import gc.mda.kcg.audit.AuditLog;
|
||||
import gc.mda.kcg.audit.AuditLogRepository;
|
||||
import gc.mda.kcg.auth.provider.AuthProvider;
|
||||
import gc.mda.kcg.auth.provider.PasswordAuthProvider;
|
||||
import gc.mda.kcg.permission.PermissionService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 인증 + 로그인 이력/감사 기록.
|
||||
* 로그인 이력 기록은 LoginAuditWriter (REQUIRES_NEW 트랜잭션)에 위임 — 실패 시에도 기록 보존.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AuthService {
|
||||
|
||||
private final PasswordAuthProvider passwordAuthProvider;
|
||||
private final UserRepository userRepository;
|
||||
private final AuditLogRepository auditLogRepository;
|
||||
private final PermissionService permissionService;
|
||||
private final LoginAuditWriter loginAuditWriter;
|
||||
|
||||
/**
|
||||
* ID/PW 로그인.
|
||||
* 트랜잭션을 별도 분리: 인증 실패가 외부 호출자(Controller)에서 catch되더라도
|
||||
* LoginAuditWriter는 REQUIRES_NEW로 별도 커밋되어 기록이 남는다.
|
||||
*/
|
||||
public AuthResult login(String userAcnt, String password, String ipAddress, String userAgent) {
|
||||
AuthProvider.AuthRequest req = new AuthProvider.AuthRequest(userAcnt, password, ipAddress, userAgent);
|
||||
|
||||
try {
|
||||
User user = passwordAuthProvider.authenticate(req);
|
||||
loginAuditWriter.recordSuccess(user.getUserId(), user.getUserAcnt(), ipAddress, userAgent);
|
||||
return AuthResult.success(user);
|
||||
} catch (AuthProvider.AuthenticationException e) {
|
||||
loginAuditWriter.recordFailure(userAcnt, ipAddress, userAgent, e.getReason());
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그아웃 - 감사로그만 기록.
|
||||
*/
|
||||
@Transactional
|
||||
public void logout(UUID userId, String userAcnt, String ipAddress) {
|
||||
auditLogRepository.save(AuditLog.builder()
|
||||
.userId(userId)
|
||||
.userAcnt(userAcnt)
|
||||
.actionCd("LOGOUT")
|
||||
.resourceType("SYSTEM")
|
||||
.resourceId("auth")
|
||||
.ipAddress(ipAddress)
|
||||
.result("SUCCESS")
|
||||
.build());
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public UserInfo getUserInfo(UUID userId) {
|
||||
User user = userRepository.findById(userId)
|
||||
.orElseThrow(() -> new IllegalStateException("User not found: " + userId));
|
||||
List<String> roles = permissionService.getRoleCodesByUserId(userId);
|
||||
Map<String, List<String>> perms = permissionService.getResolvedPermissionsByUserId(userId);
|
||||
return new UserInfo(user, roles, perms);
|
||||
}
|
||||
|
||||
public record AuthResult(User user) {
|
||||
public static AuthResult success(User user) { return new AuthResult(user); }
|
||||
}
|
||||
|
||||
public record UserInfo(User user, List<String> roles, Map<String, List<String>> permissions) {}
|
||||
}
|
||||
82
backend/src/main/java/gc/mda/kcg/auth/JwtAuthFilter.java
Normal file
82
backend/src/main/java/gc/mda/kcg/auth/JwtAuthFilter.java
Normal file
@ -0,0 +1,82 @@
|
||||
package gc.mda.kcg.auth;
|
||||
|
||||
import io.jsonwebtoken.Claims;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.Cookie;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class JwtAuthFilter extends OncePerRequestFilter {
|
||||
|
||||
public static final String COOKIE_NAME = "kcg_token";
|
||||
public static final String AUTH_HEADER = "Authorization";
|
||||
public static final String BEARER_PREFIX = "Bearer ";
|
||||
|
||||
private final JwtService jwtService;
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
|
||||
throws ServletException, IOException {
|
||||
|
||||
String token = extractToken(request);
|
||||
if (token != null && jwtService.isValid(token)) {
|
||||
try {
|
||||
Claims claims = jwtService.parseToken(token);
|
||||
UUID userId = UUID.fromString(claims.getSubject());
|
||||
String userAcnt = claims.get("acnt", String.class);
|
||||
String userNm = claims.get("name", String.class);
|
||||
@SuppressWarnings("unchecked")
|
||||
List<String> roles = claims.get("roles", List.class);
|
||||
|
||||
AuthPrincipal principal = AuthPrincipal.builder()
|
||||
.userId(userId)
|
||||
.userAcnt(userAcnt)
|
||||
.userNm(userNm)
|
||||
.roles(roles)
|
||||
.build();
|
||||
|
||||
List<SimpleGrantedAuthority> authorities = roles == null ? List.of() :
|
||||
roles.stream().map(r -> new SimpleGrantedAuthority("ROLE_" + r)).toList();
|
||||
|
||||
UsernamePasswordAuthenticationToken auth =
|
||||
new UsernamePasswordAuthenticationToken(principal, null, authorities);
|
||||
auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
|
||||
SecurityContextHolder.getContext().setAuthentication(auth);
|
||||
} catch (Exception e) {
|
||||
log.debug("JWT processing failed: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
chain.doFilter(request, response);
|
||||
}
|
||||
|
||||
private String extractToken(HttpServletRequest req) {
|
||||
// 1. Cookie 우선
|
||||
if (req.getCookies() != null) {
|
||||
for (Cookie c : req.getCookies()) {
|
||||
if (COOKIE_NAME.equals(c.getName())) return c.getValue();
|
||||
}
|
||||
}
|
||||
// 2. Authorization 헤더 fallback
|
||||
String header = req.getHeader(AUTH_HEADER);
|
||||
if (header != null && header.startsWith(BEARER_PREFIX)) {
|
||||
return header.substring(BEARER_PREFIX.length());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
74
backend/src/main/java/gc/mda/kcg/auth/JwtService.java
Normal file
74
backend/src/main/java/gc/mda/kcg/auth/JwtService.java
Normal file
@ -0,0 +1,74 @@
|
||||
package gc.mda.kcg.auth;
|
||||
|
||||
import gc.mda.kcg.config.AppProperties;
|
||||
import io.jsonwebtoken.Claims;
|
||||
import io.jsonwebtoken.Jwts;
|
||||
import io.jsonwebtoken.security.Keys;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.crypto.SecretKey;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class JwtService {
|
||||
|
||||
private final AppProperties appProperties;
|
||||
private SecretKey signingKey;
|
||||
|
||||
private SecretKey getSigningKey() {
|
||||
if (signingKey == null) {
|
||||
byte[] keyBytes = appProperties.getJwt().getSecret().getBytes(StandardCharsets.UTF_8);
|
||||
signingKey = Keys.hmacShaKeyFor(keyBytes);
|
||||
}
|
||||
return signingKey;
|
||||
}
|
||||
|
||||
public String generateToken(UUID userId, String userAcnt, String userNm, List<String> roles) {
|
||||
Instant now = Instant.now();
|
||||
Instant exp = now.plusMillis(appProperties.getJwt().getExpirationMs());
|
||||
|
||||
return Jwts.builder()
|
||||
.subject(userId.toString())
|
||||
.claim("acnt", userAcnt)
|
||||
.claim("name", userNm)
|
||||
.claim("roles", roles)
|
||||
.issuedAt(Date.from(now))
|
||||
.expiration(Date.from(exp))
|
||||
.signWith(getSigningKey())
|
||||
.compact();
|
||||
}
|
||||
|
||||
public Claims parseToken(String token) {
|
||||
return Jwts.parser()
|
||||
.verifyWith(getSigningKey())
|
||||
.build()
|
||||
.parseSignedClaims(token)
|
||||
.getPayload();
|
||||
}
|
||||
|
||||
public UUID extractUserId(String token) {
|
||||
return UUID.fromString(parseToken(token).getSubject());
|
||||
}
|
||||
|
||||
public boolean isValid(String token) {
|
||||
try {
|
||||
parseToken(token);
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
log.debug("Invalid JWT: {}", e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public long getExpirationMs() {
|
||||
return appProperties.getJwt().getExpirationMs();
|
||||
}
|
||||
}
|
||||
68
backend/src/main/java/gc/mda/kcg/auth/LoginAuditWriter.java
Normal file
68
backend/src/main/java/gc/mda/kcg/auth/LoginAuditWriter.java
Normal file
@ -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());
|
||||
}
|
||||
}
|
||||
54
backend/src/main/java/gc/mda/kcg/auth/LoginHistory.java
Normal file
54
backend/src/main/java/gc/mda/kcg/auth/LoginHistory.java
Normal file
@ -0,0 +1,54 @@
|
||||
package gc.mda.kcg.auth;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import org.hibernate.annotations.JdbcTypeCode;
|
||||
import org.hibernate.type.SqlTypes;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(name = "auth_login_hist", schema = "kcg")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class LoginHistory {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@Column(name = "hist_sn")
|
||||
private Long histSn;
|
||||
|
||||
@JdbcTypeCode(SqlTypes.UUID)
|
||||
@Column(name = "user_id")
|
||||
private UUID userId;
|
||||
|
||||
@Column(name = "user_acnt", length = 50)
|
||||
private String userAcnt;
|
||||
|
||||
@Column(name = "login_dtm", nullable = false)
|
||||
private OffsetDateTime loginDtm;
|
||||
|
||||
@Column(name = "login_ip", length = 45)
|
||||
private String loginIp;
|
||||
|
||||
@Column(name = "user_agent", columnDefinition = "text")
|
||||
private String userAgent;
|
||||
|
||||
@Column(name = "result", nullable = false, length = 20)
|
||||
private String result; // SUCCESS, FAILED, LOCKED
|
||||
|
||||
@Column(name = "fail_reason", length = 255)
|
||||
private String failReason;
|
||||
|
||||
@Column(name = "auth_provider", length = 20)
|
||||
private String authProvider;
|
||||
|
||||
@PrePersist
|
||||
void prePersist() {
|
||||
if (loginDtm == null) loginDtm = OffsetDateTime.now();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
package gc.mda.kcg.auth;
|
||||
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public interface LoginHistoryRepository extends JpaRepository<LoginHistory, Long> {
|
||||
Page<LoginHistory> findByUserIdOrderByLoginDtmDesc(UUID userId, Pageable pageable);
|
||||
Page<LoginHistory> findAllByOrderByLoginDtmDesc(Pageable pageable);
|
||||
}
|
||||
87
backend/src/main/java/gc/mda/kcg/auth/User.java
Normal file
87
backend/src/main/java/gc/mda/kcg/auth/User.java
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
11
backend/src/main/java/gc/mda/kcg/auth/UserRepository.java
Normal file
11
backend/src/main/java/gc/mda/kcg/auth/UserRepository.java
Normal file
@ -0,0 +1,11 @@
|
||||
package gc.mda.kcg.auth;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface UserRepository extends JpaRepository<User, UUID> {
|
||||
Optional<User> findByUserAcnt(String userAcnt);
|
||||
boolean existsByUserAcnt(String userAcnt);
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
package gc.mda.kcg.auth.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record LoginRequest(
|
||||
@NotBlank String account,
|
||||
@NotBlank String password
|
||||
) {}
|
||||
@ -0,0 +1,16 @@
|
||||
package gc.mda.kcg.auth.dto;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public record UserInfoResponse(
|
||||
String id,
|
||||
String account,
|
||||
String name,
|
||||
String rank,
|
||||
String email,
|
||||
String status,
|
||||
String authProvider,
|
||||
List<String> roles,
|
||||
Map<String, List<String>> permissions
|
||||
) {}
|
||||
@ -0,0 +1,39 @@
|
||||
package gc.mda.kcg.auth.provider;
|
||||
|
||||
import gc.mda.kcg.auth.User;
|
||||
|
||||
/**
|
||||
* 인증 방식 확장 포인트.
|
||||
* Phase 3: PASSWORD만 구현.
|
||||
* Phase 9 (TODO): GPKI(공무원 인증서), SSO(SAML/OIDC) 추가.
|
||||
*/
|
||||
public interface AuthProvider {
|
||||
|
||||
/**
|
||||
* 인증 방식 식별자: PASSWORD / GPKI / SSO
|
||||
*/
|
||||
String getProviderType();
|
||||
|
||||
/**
|
||||
* 인증 수행. 성공 시 User 반환, 실패 시 AuthenticationException 발생.
|
||||
*/
|
||||
User authenticate(AuthRequest request) throws AuthenticationException;
|
||||
|
||||
record AuthRequest(
|
||||
String userAcnt,
|
||||
String credential, // 비밀번호 또는 인증서/SSO 토큰
|
||||
String ipAddress,
|
||||
String userAgent
|
||||
) {}
|
||||
|
||||
class AuthenticationException extends RuntimeException {
|
||||
private final String reason;
|
||||
|
||||
public AuthenticationException(String reason) {
|
||||
super(reason);
|
||||
this.reason = reason;
|
||||
}
|
||||
|
||||
public String getReason() { return reason; }
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,71 @@
|
||||
package gc.mda.kcg.auth.provider;
|
||||
|
||||
import gc.mda.kcg.auth.User;
|
||||
import gc.mda.kcg.auth.UserRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
/**
|
||||
* 자체 ID/PW 인증 (BCrypt).
|
||||
* Phase 1 인증 방식 — Phase 9에서 GPKI/SSO 추가 예정.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class PasswordAuthProvider implements AuthProvider {
|
||||
|
||||
private static final int MAX_FAIL_ATTEMPTS = 5;
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
|
||||
@Override
|
||||
public String getProviderType() {
|
||||
return "PASSWORD";
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW, noRollbackFor = AuthenticationException.class)
|
||||
public User authenticate(AuthRequest request) {
|
||||
User user = userRepository.findByUserAcnt(request.userAcnt())
|
||||
.orElseThrow(() -> new AuthenticationException("USER_NOT_FOUND"));
|
||||
|
||||
// 상태 검증
|
||||
if (user.isLocked()) {
|
||||
throw new AuthenticationException("ACCOUNT_LOCKED");
|
||||
}
|
||||
if (!user.isActive()) {
|
||||
throw new AuthenticationException("ACCOUNT_NOT_ACTIVE:" + user.getUserSttsCd());
|
||||
}
|
||||
|
||||
// PASSWORD provider만 처리
|
||||
if (!"PASSWORD".equals(user.getAuthProvider())) {
|
||||
throw new AuthenticationException("WRONG_PROVIDER:" + user.getAuthProvider());
|
||||
}
|
||||
|
||||
// BCrypt 비교
|
||||
if (user.getPswdHash() == null || !passwordEncoder.matches(request.credential(), user.getPswdHash())) {
|
||||
int newFailCnt = user.getFailCnt() + 1;
|
||||
user.setFailCnt(newFailCnt);
|
||||
if (newFailCnt >= MAX_FAIL_ATTEMPTS) {
|
||||
user.setUserSttsCd("LOCKED");
|
||||
userRepository.save(user);
|
||||
throw new AuthenticationException("MAX_FAIL_LOCKED");
|
||||
}
|
||||
userRepository.save(user);
|
||||
throw new AuthenticationException("WRONG_PASSWORD:" + newFailCnt);
|
||||
}
|
||||
|
||||
// 로그인 성공: 실패 카운터 초기화 + 마지막 로그인 시각 갱신
|
||||
user.setFailCnt(0);
|
||||
user.setLastLoginDtm(OffsetDateTime.now());
|
||||
userRepository.save(user);
|
||||
return user;
|
||||
}
|
||||
}
|
||||
39
backend/src/main/java/gc/mda/kcg/config/AppProperties.java
Normal file
39
backend/src/main/java/gc/mda/kcg/config/AppProperties.java
Normal file
@ -0,0 +1,39 @@
|
||||
package gc.mda.kcg.config;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration
|
||||
@ConfigurationProperties(prefix = "app")
|
||||
@Getter
|
||||
@Setter
|
||||
public class AppProperties {
|
||||
|
||||
private Prediction prediction = new Prediction();
|
||||
private IranBackend iranBackend = new IranBackend();
|
||||
private Cors cors = new Cors();
|
||||
private Jwt jwt = new Jwt();
|
||||
|
||||
@Getter @Setter
|
||||
public static class Prediction {
|
||||
private String baseUrl;
|
||||
}
|
||||
|
||||
@Getter @Setter
|
||||
public static class IranBackend {
|
||||
private String baseUrl;
|
||||
}
|
||||
|
||||
@Getter @Setter
|
||||
public static class Cors {
|
||||
private String allowedOrigins;
|
||||
}
|
||||
|
||||
@Getter @Setter
|
||||
public static class Jwt {
|
||||
private String secret;
|
||||
private long expirationMs;
|
||||
}
|
||||
}
|
||||
@ -1,25 +1,83 @@
|
||||
package gc.mda.kcg.config;
|
||||
|
||||
import gc.mda.kcg.auth.JwtAuthFilter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.cors.CorsConfigurationSource;
|
||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Phase 2 임시 SecurityConfig.
|
||||
* Phase 3에서 JWT 필터 + 권한 체계 본격 도입 시 확장.
|
||||
* Phase 3: JWT 기반 인증 + 트리 RBAC 권한 체계.
|
||||
*
|
||||
* - JwtAuthFilter가 토큰 파싱 → SecurityContext에 AuthPrincipal 주입
|
||||
* - 권한 체크는 @RequirePermission 어노테이션 (PermissionAspect)이 담당
|
||||
* - 세션 STATELESS
|
||||
*/
|
||||
@Configuration
|
||||
@RequiredArgsConstructor
|
||||
public class SecurityConfig {
|
||||
|
||||
private final JwtAuthFilter jwtAuthFilter;
|
||||
private final AppProperties appProperties;
|
||||
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public CorsConfigurationSource corsConfigurationSource() {
|
||||
CorsConfiguration config = new CorsConfiguration();
|
||||
String origins = appProperties.getCors().getAllowedOrigins();
|
||||
if (origins != null && !origins.isBlank()) {
|
||||
config.setAllowedOrigins(Arrays.asList(origins.split(",")));
|
||||
}
|
||||
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
|
||||
config.setAllowedHeaders(List.of("*"));
|
||||
config.setAllowCredentials(true);
|
||||
config.setMaxAge(3600L);
|
||||
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
source.registerCorsConfiguration("/**", config);
|
||||
return source;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
.csrf(AbstractHttpConfigurer::disable)
|
||||
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
|
||||
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers("/actuator/**").permitAll()
|
||||
.anyRequest().permitAll() // Phase 2: 모두 허용. Phase 3에서 인증 필수로 전환
|
||||
.requestMatchers("/actuator/health", "/actuator/info").permitAll()
|
||||
.requestMatchers("/api/auth/login", "/api/auth/logout").permitAll()
|
||||
.requestMatchers("/error").permitAll()
|
||||
.anyRequest().authenticated()
|
||||
)
|
||||
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
|
||||
.exceptionHandling(eh -> eh
|
||||
.authenticationEntryPoint((req, res, ex) -> {
|
||||
res.setStatus(401);
|
||||
res.setContentType("application/json");
|
||||
res.getWriter().write("{\"error\":\"UNAUTHENTICATED\",\"message\":\"" + ex.getMessage() + "\"}");
|
||||
})
|
||||
.accessDeniedHandler((req, res, ex) -> {
|
||||
res.setStatus(403);
|
||||
res.setContentType("application/json");
|
||||
res.getWriter().write("{\"error\":\"FORBIDDEN\",\"message\":\"" + ex.getMessage() + "\"}");
|
||||
})
|
||||
);
|
||||
return http.build();
|
||||
}
|
||||
|
||||
50
backend/src/main/java/gc/mda/kcg/permission/Perm.java
Normal file
50
backend/src/main/java/gc/mda/kcg/permission/Perm.java
Normal file
@ -0,0 +1,50 @@
|
||||
package gc.mda.kcg.permission;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import org.hibernate.annotations.JdbcTypeCode;
|
||||
import org.hibernate.type.SqlTypes;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(name = "auth_perm", schema = "kcg",
|
||||
uniqueConstraints = @UniqueConstraint(columnNames = {"role_sn", "rsrc_cd", "oper_cd"}))
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class Perm {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@Column(name = "perm_sn")
|
||||
private Long permSn;
|
||||
|
||||
@Column(name = "role_sn", nullable = false)
|
||||
private Long roleSn;
|
||||
|
||||
@Column(name = "rsrc_cd", nullable = false, length = 100)
|
||||
private String rsrcCd;
|
||||
|
||||
@Column(name = "oper_cd", nullable = false, length = 20)
|
||||
private String operCd; // READ/CREATE/UPDATE/DELETE/EXPORT/MANAGE
|
||||
|
||||
@Column(name = "grant_yn", nullable = false, length = 1)
|
||||
private String grantYn; // Y / N
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private OffsetDateTime updatedAt;
|
||||
|
||||
@JdbcTypeCode(SqlTypes.UUID)
|
||||
@Column(name = "updated_by")
|
||||
private UUID updatedBy;
|
||||
|
||||
@PrePersist
|
||||
@PreUpdate
|
||||
void preUpdate() {
|
||||
updatedAt = OffsetDateTime.now();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,17 @@
|
||||
package gc.mda.kcg.permission;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface PermRepository extends JpaRepository<Perm, Long> {
|
||||
|
||||
List<Perm> findByRoleSn(Long roleSn);
|
||||
|
||||
@Query("SELECT p FROM Perm p WHERE p.roleSn IN :roleSns")
|
||||
List<Perm> findByRoleSnIn(@Param("roleSns") List<Long> roleSns);
|
||||
|
||||
void deleteByRoleSn(Long roleSn);
|
||||
}
|
||||
179
backend/src/main/java/gc/mda/kcg/permission/PermResolver.java
Normal file
179
backend/src/main/java/gc/mda/kcg/permission/PermResolver.java
Normal file
@ -0,0 +1,179 @@
|
||||
package gc.mda.kcg.permission;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* 트리 기반 RBAC 권한 해석기 (wing 프로젝트의 permResolver.ts Java 이식).
|
||||
*
|
||||
* 핵심 규칙:
|
||||
* 1. READ가 게이팅 오퍼레이션: 부모의 READ가 N(deny)이면 자식의 모든 작업도 강제 deny
|
||||
* 2. 명시 권한 우선: AUTH_PERM에 grant_yn 명시값이 있으면 그것 사용
|
||||
* 3. 상속: 명시값 없으면 부모의 동일 작업 권한을 상속
|
||||
* 4. 미정 = 거부 (기본값)
|
||||
* 5. 다중 역할: 각 역할의 결과를 OR(합집합)
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class PermResolver {
|
||||
|
||||
public static final List<String> OPERATIONS = List.of("READ", "CREATE", "UPDATE", "DELETE", "EXPORT");
|
||||
|
||||
/**
|
||||
* 권한 키 생성 헬퍼.
|
||||
*/
|
||||
public static String makePermKey(String rsrcCd, String operCd) {
|
||||
return rsrcCd + "::" + operCd;
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 역할의 명시 권한 + 트리 → 해석된 (rsrcCd → operCd[]) 맵.
|
||||
*
|
||||
* @param treeNodes 전체 트리 노드 (use_yn=Y)
|
||||
* @param explicitPerms 해당 역할의 명시 권한 키 → grantYn ('Y'/'N')
|
||||
* @return 트리 순회 결과 (모든 사용 가능한 노드에 대한 R/C/U/D/E 결정)
|
||||
*/
|
||||
public Map<String, Set<String>> resolveSingleRole(
|
||||
List<PermTree> treeNodes,
|
||||
Map<String, String> explicitPerms
|
||||
) {
|
||||
// 트리 인덱싱 (parent → children)
|
||||
Map<String, List<PermTree>> childrenMap = new HashMap<>();
|
||||
Map<String, PermTree> nodeMap = new HashMap<>();
|
||||
for (PermTree node : treeNodes) {
|
||||
if (!"Y".equals(node.getUseYn())) continue;
|
||||
nodeMap.put(node.getRsrcCd(), node);
|
||||
String parent = node.getParentCd();
|
||||
childrenMap.computeIfAbsent(parent, k -> new ArrayList<>()).add(node);
|
||||
}
|
||||
|
||||
// 결과 맵: rsrcCd → granted operations Set
|
||||
Map<String, Set<String>> resolved = new HashMap<>();
|
||||
|
||||
// 루트 노드부터 BFS (parentCd가 null인 노드)
|
||||
List<PermTree> roots = childrenMap.getOrDefault(null, Collections.emptyList());
|
||||
for (PermTree root : roots) {
|
||||
walkTree(root, null, childrenMap, explicitPerms, resolved);
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* 트리 순회: 부모의 효과적 권한을 컨텍스트로 받아 자식에 전파.
|
||||
*/
|
||||
private void walkTree(
|
||||
PermTree node,
|
||||
Set<String> parentEffective,
|
||||
Map<String, List<PermTree>> childrenMap,
|
||||
Map<String, String> explicitPerms,
|
||||
Map<String, Set<String>> resolved
|
||||
) {
|
||||
Set<String> nodeEffective = new HashSet<>();
|
||||
|
||||
// 1. 각 오퍼레이션에 대해 (READ 먼저, 다른 작업은 그 다음)
|
||||
// READ 결정
|
||||
boolean readGranted = resolveOperation(node.getRsrcCd(), "READ",
|
||||
parentEffective != null && parentEffective.contains("READ"),
|
||||
explicitPerms);
|
||||
|
||||
// 부모 READ가 deny면 모든 작업 강제 deny
|
||||
boolean parentReadDenied = parentEffective != null && !parentEffective.contains("READ") && parentEffective.contains("__defined__");
|
||||
|
||||
if (readGranted && !parentReadDenied) {
|
||||
nodeEffective.add("READ");
|
||||
}
|
||||
|
||||
// 다른 작업: READ가 부여된 경우에만 평가
|
||||
if (nodeEffective.contains("READ")) {
|
||||
for (String op : List.of("CREATE", "UPDATE", "DELETE", "EXPORT")) {
|
||||
boolean parentHasOp = parentEffective != null && parentEffective.contains(op);
|
||||
boolean granted = resolveOperation(node.getRsrcCd(), op, parentHasOp, explicitPerms);
|
||||
if (granted) {
|
||||
nodeEffective.add(op);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 마커: 이 노드가 평가되었음을 표시 (자식에서 parent_read_denied 판단용)
|
||||
nodeEffective.add("__defined__");
|
||||
|
||||
// 결과 저장 (마커 제외)
|
||||
Set<String> publicOps = new HashSet<>(nodeEffective);
|
||||
publicOps.remove("__defined__");
|
||||
if (!publicOps.isEmpty()) {
|
||||
resolved.put(node.getRsrcCd(), publicOps);
|
||||
}
|
||||
|
||||
// 자식 재귀
|
||||
List<PermTree> children = childrenMap.getOrDefault(node.getRsrcCd(), Collections.emptyList());
|
||||
for (PermTree child : children) {
|
||||
walkTree(child, nodeEffective, childrenMap, explicitPerms, resolved);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 (rsrc, oper) 권한 해석:
|
||||
* - 명시값이 있으면 그것 우선
|
||||
* - 없으면 부모 권한 상속
|
||||
*/
|
||||
private boolean resolveOperation(String rsrcCd, String operCd, boolean parentGranted, Map<String, String> explicitPerms) {
|
||||
String key = makePermKey(rsrcCd, operCd);
|
||||
String explicit = explicitPerms.get(key);
|
||||
if ("Y".equals(explicit)) return true;
|
||||
if ("N".equals(explicit)) return false;
|
||||
return parentGranted;
|
||||
}
|
||||
|
||||
/**
|
||||
* 다중 역할 해석: 각 역할 결과를 OR 합집합.
|
||||
*
|
||||
* @param treeNodes 전체 트리
|
||||
* @param permsByRole 역할 sn → 명시 권한 키 → grantYn 맵
|
||||
* @return 최종 (rsrcCd → operCd[]) 맵
|
||||
*/
|
||||
public Map<String, List<String>> resolveMultiRole(
|
||||
List<PermTree> treeNodes,
|
||||
Map<Long, Map<String, String>> permsByRole
|
||||
) {
|
||||
Map<String, Set<String>> merged = new HashMap<>();
|
||||
|
||||
for (Map.Entry<Long, Map<String, String>> entry : permsByRole.entrySet()) {
|
||||
Map<String, Set<String>> single = resolveSingleRole(treeNodes, entry.getValue());
|
||||
for (Map.Entry<String, Set<String>> e : single.entrySet()) {
|
||||
merged.computeIfAbsent(e.getKey(), k -> new HashSet<>()).addAll(e.getValue());
|
||||
}
|
||||
}
|
||||
|
||||
// Set → List 변환 + 안정적 정렬
|
||||
Map<String, List<String>> result = new HashMap<>();
|
||||
for (Map.Entry<String, Set<String>> e : merged.entrySet()) {
|
||||
List<String> sorted = new ArrayList<>(e.getValue());
|
||||
sorted.sort(Comparator.comparingInt(OPERATIONS::indexOf));
|
||||
result.put(e.getKey(), sorted);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 권한 체크 헬퍼: hasPermission(resolved, "detection:gear-detection", "READ")
|
||||
* 부모 fallback 지원: "detection:gear-detection" 미존재 시 "detection" 검사
|
||||
*/
|
||||
public boolean hasPermission(Map<String, List<String>> resolved, String rsrcCd, String operCd) {
|
||||
List<String> ops = resolved.get(rsrcCd);
|
||||
if (ops != null && ops.contains(operCd)) return true;
|
||||
|
||||
// 부모 fallback
|
||||
int colonIdx = rsrcCd.indexOf(':');
|
||||
if (colonIdx > 0) {
|
||||
String parent = rsrcCd.substring(0, colonIdx);
|
||||
List<String> parentOps = resolved.get(parent);
|
||||
return parentOps != null && parentOps.contains(operCd);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
62
backend/src/main/java/gc/mda/kcg/permission/PermTree.java
Normal file
62
backend/src/main/java/gc/mda/kcg/permission/PermTree.java
Normal file
@ -0,0 +1,62 @@
|
||||
package gc.mda.kcg.permission;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(name = "auth_perm_tree", schema = "kcg")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class PermTree {
|
||||
|
||||
@Id
|
||||
@Column(name = "rsrc_cd", length = 100)
|
||||
private String rsrcCd;
|
||||
|
||||
@Column(name = "parent_cd", length = 100)
|
||||
private String parentCd;
|
||||
|
||||
@Column(name = "rsrc_nm", nullable = false, length = 100)
|
||||
private String rsrcNm;
|
||||
|
||||
@Column(name = "rsrc_desc", columnDefinition = "text")
|
||||
private String rsrcDesc;
|
||||
|
||||
@Column(name = "icon", length = 50)
|
||||
private String icon;
|
||||
|
||||
@Column(name = "rsrc_level", nullable = false)
|
||||
private Integer rsrcLevel;
|
||||
|
||||
@Column(name = "sort_ord", nullable = false)
|
||||
private Integer sortOrd;
|
||||
|
||||
@Column(name = "use_yn", nullable = false, length = 1)
|
||||
private String useYn;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private OffsetDateTime createdAt;
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private OffsetDateTime updatedAt;
|
||||
|
||||
@PrePersist
|
||||
void prePersist() {
|
||||
OffsetDateTime now = OffsetDateTime.now();
|
||||
if (createdAt == null) createdAt = now;
|
||||
if (updatedAt == null) updatedAt = now;
|
||||
if (useYn == null) useYn = "Y";
|
||||
if (sortOrd == null) sortOrd = 0;
|
||||
if (rsrcLevel == null) rsrcLevel = 0;
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
void preUpdate() {
|
||||
updatedAt = OffsetDateTime.now();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
package gc.mda.kcg.permission;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface PermTreeRepository extends JpaRepository<PermTree, String> {
|
||||
List<PermTree> findAllByOrderByRsrcLevelAscSortOrdAsc();
|
||||
List<PermTree> findByUseYn(String useYn);
|
||||
}
|
||||
@ -0,0 +1,97 @@
|
||||
package gc.mda.kcg.permission;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.cache.annotation.CacheEvict;
|
||||
import org.springframework.cache.annotation.Cacheable;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 사용자 권한 조회/캐싱 서비스.
|
||||
* 권한 변경 시 CacheEvict로 무효화.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class PermissionService {
|
||||
|
||||
private final UserRoleRepository userRoleRepository;
|
||||
private final PermRepository permRepository;
|
||||
private final PermTreeRepository permTreeRepository;
|
||||
private final RoleRepository roleRepository;
|
||||
private final PermResolver permResolver;
|
||||
|
||||
/**
|
||||
* 사용자 ID로 해석된 권한 맵 조회.
|
||||
* Caffeine 캐시 (TTL 10분, 권한 변경 시 evict).
|
||||
*/
|
||||
@Cacheable(value = "permissions", key = "#userId")
|
||||
@Transactional(readOnly = true)
|
||||
public Map<String, List<String>> getResolvedPermissionsByUserId(UUID userId) {
|
||||
// 1. 사용자의 역할 조회
|
||||
List<UserRole> userRoles = userRoleRepository.findByUserId(userId);
|
||||
if (userRoles.isEmpty()) {
|
||||
log.debug("User {} has no roles", userId);
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
|
||||
List<Long> roleSns = userRoles.stream().map(UserRole::getRoleSn).toList();
|
||||
|
||||
// 2. 역할별 명시 권한 로드
|
||||
List<Perm> perms = permRepository.findByRoleSnIn(roleSns);
|
||||
Map<Long, Map<String, String>> permsByRole = new HashMap<>();
|
||||
for (Perm p : perms) {
|
||||
permsByRole
|
||||
.computeIfAbsent(p.getRoleSn(), k -> new HashMap<>())
|
||||
.put(PermResolver.makePermKey(p.getRsrcCd(), p.getOperCd()), p.getGrantYn());
|
||||
}
|
||||
// 권한이 하나도 없는 역할도 빈 맵으로 등록 (트리 순회는 필요)
|
||||
for (Long sn : roleSns) {
|
||||
permsByRole.computeIfAbsent(sn, k -> new HashMap<>());
|
||||
}
|
||||
|
||||
// 3. 트리 노드 로드 (use_yn=Y)
|
||||
List<PermTree> treeNodes = permTreeRepository.findByUseYn("Y");
|
||||
|
||||
// 4. 다중 역할 해석
|
||||
Map<String, List<String>> resolved = permResolver.resolveMultiRole(treeNodes, permsByRole);
|
||||
log.debug("Resolved {} resources for user {}", resolved.size(), userId);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* 권한 체크 (resource + operation).
|
||||
*/
|
||||
public boolean hasPermission(UUID userId, String rsrcCd, String operCd) {
|
||||
Map<String, List<String>> resolved = getResolvedPermissionsByUserId(userId);
|
||||
return permResolver.hasPermission(resolved, rsrcCd, operCd);
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자 역할 코드 목록 조회.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public List<String> getRoleCodesByUserId(UUID userId) {
|
||||
return userRoleRepository.findRoleCodesByUserId(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 권한 캐시 무효화 (역할 배정/권한 매트릭스 변경 시 호출).
|
||||
*/
|
||||
@CacheEvict(value = "permissions", key = "#userId")
|
||||
public void evictUserPermissions(UUID userId) {
|
||||
log.info("Evicted permissions cache for user {}", userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체 권한 캐시 무효화 (대량 변경 시).
|
||||
*/
|
||||
@CacheEvict(value = "permissions", allEntries = true)
|
||||
public void evictAllPermissions() {
|
||||
log.info("Evicted all permissions cache");
|
||||
}
|
||||
}
|
||||
56
backend/src/main/java/gc/mda/kcg/permission/Role.java
Normal file
56
backend/src/main/java/gc/mda/kcg/permission/Role.java
Normal file
@ -0,0 +1,56 @@
|
||||
package gc.mda.kcg.permission;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(name = "auth_role", schema = "kcg")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class Role {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@Column(name = "role_sn")
|
||||
private Long roleSn;
|
||||
|
||||
@Column(name = "role_cd", nullable = false, unique = true, length = 50)
|
||||
private String roleCd;
|
||||
|
||||
@Column(name = "role_nm", nullable = false, length = 100)
|
||||
private String roleNm;
|
||||
|
||||
@Column(name = "role_dc", columnDefinition = "text")
|
||||
private String roleDc;
|
||||
|
||||
@Column(name = "dflt_yn", nullable = false, length = 1)
|
||||
private String dfltYn;
|
||||
|
||||
@Column(name = "builtin_yn", nullable = false, length = 1)
|
||||
private String builtinYn;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private OffsetDateTime createdAt;
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private OffsetDateTime updatedAt;
|
||||
|
||||
@PrePersist
|
||||
void prePersist() {
|
||||
OffsetDateTime now = OffsetDateTime.now();
|
||||
if (createdAt == null) createdAt = now;
|
||||
if (updatedAt == null) updatedAt = now;
|
||||
if (dfltYn == null) dfltYn = "N";
|
||||
if (builtinYn == null) builtinYn = "N";
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
void preUpdate() {
|
||||
updatedAt = OffsetDateTime.now();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
package gc.mda.kcg.permission;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface RoleRepository extends JpaRepository<Role, Long> {
|
||||
Optional<Role> findByRoleCd(String roleCd);
|
||||
List<Role> findAllByOrderByRoleSnAsc();
|
||||
}
|
||||
41
backend/src/main/java/gc/mda/kcg/permission/UserRole.java
Normal file
41
backend/src/main/java/gc/mda/kcg/permission/UserRole.java
Normal file
@ -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();
|
||||
}
|
||||
}
|
||||
16
backend/src/main/java/gc/mda/kcg/permission/UserRoleId.java
Normal file
16
backend/src/main/java/gc/mda/kcg/permission/UserRoleId.java
Normal file
@ -0,0 +1,16 @@
|
||||
package gc.mda.kcg.permission;
|
||||
|
||||
import lombok.*;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.UUID;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@EqualsAndHashCode
|
||||
public class UserRoleId implements Serializable {
|
||||
private UUID userId;
|
||||
private Long roleSn;
|
||||
}
|
||||
@ -0,0 +1,17 @@
|
||||
package gc.mda.kcg.permission;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface UserRoleRepository extends JpaRepository<UserRole, UserRoleId> {
|
||||
|
||||
List<UserRole> findByUserId(UUID userId);
|
||||
|
||||
void deleteByUserId(UUID userId);
|
||||
|
||||
@Query("SELECT r.roleCd FROM Role r JOIN UserRole ur ON ur.roleSn = r.roleSn WHERE ur.userId = :userId")
|
||||
List<String> findRoleCodesByUserId(UUID userId);
|
||||
}
|
||||
@ -0,0 +1,53 @@
|
||||
package gc.mda.kcg.permission.annotation;
|
||||
|
||||
import gc.mda.kcg.auth.AuthPrincipal;
|
||||
import gc.mda.kcg.permission.PermissionService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.aspectj.lang.annotation.Aspect;
|
||||
import org.aspectj.lang.annotation.Before;
|
||||
import org.aspectj.lang.reflect.MethodSignature;
|
||||
import org.aspectj.lang.JoinPoint;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.security.authentication.AuthenticationCredentialsNotFoundException;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
|
||||
/**
|
||||
* @RequirePermission 어노테이션 처리 AOP.
|
||||
* 메서드 호출 직전 권한 체크 → 거부 시 AccessDeniedException 발생.
|
||||
*/
|
||||
@Slf4j
|
||||
@Aspect
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class PermissionAspect {
|
||||
|
||||
private final PermissionService permissionService;
|
||||
|
||||
@Before("@annotation(gc.mda.kcg.permission.annotation.RequirePermission) || @within(gc.mda.kcg.permission.annotation.RequirePermission)")
|
||||
public void checkPermission(JoinPoint jp) {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth == null || !(auth.getPrincipal() instanceof AuthPrincipal principal)) {
|
||||
throw new AuthenticationCredentialsNotFoundException("로그인이 필요합니다");
|
||||
}
|
||||
|
||||
// 메서드 우선, 없으면 클래스
|
||||
MethodSignature ms = (MethodSignature) jp.getSignature();
|
||||
Method method = ms.getMethod();
|
||||
RequirePermission ann = method.getAnnotation(RequirePermission.class);
|
||||
if (ann == null) {
|
||||
ann = method.getDeclaringClass().getAnnotation(RequirePermission.class);
|
||||
}
|
||||
if (ann == null) return;
|
||||
|
||||
boolean granted = permissionService.hasPermission(principal.getUserId(), ann.resource(), ann.operation());
|
||||
if (!granted) {
|
||||
log.warn("권한 거부: user={}, resource={}, op={}", principal.getUserAcnt(), ann.resource(), ann.operation());
|
||||
throw new AccessDeniedException("권한 없음: " + ann.resource() + "::" + ann.operation());
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,24 @@
|
||||
package gc.mda.kcg.permission.annotation;
|
||||
|
||||
import java.lang.annotation.*;
|
||||
|
||||
/**
|
||||
* 메서드/클래스 레벨 권한 요구 어노테이션.
|
||||
*
|
||||
* 사용 예:
|
||||
* <pre>
|
||||
* @RequirePermission(resource = "parent-inference-workflow:parent-review", operation = "UPDATE")
|
||||
* @PostMapping("/groups/{key}/parent-inference/{sub}/review")
|
||||
* public ResponseEntity<?> review(...) { ... }
|
||||
* </pre>
|
||||
*/
|
||||
@Target({ElementType.METHOD, ElementType.TYPE})
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Documented
|
||||
public @interface RequirePermission {
|
||||
/** 리소스 코드 (예: "detection:gear-detection") */
|
||||
String resource();
|
||||
|
||||
/** 오퍼레이션 (READ/CREATE/UPDATE/DELETE/EXPORT). 기본 READ */
|
||||
String operation() default "READ";
|
||||
}
|
||||
@ -15,7 +15,7 @@ CREATE TABLE kcg.auth_org (
|
||||
org_tp_cd VARCHAR(20), -- HQ, REGIONAL, STATION, AGENCY
|
||||
upper_org_sn BIGINT REFERENCES kcg.auth_org(org_sn),
|
||||
sort_ord INT DEFAULT 0,
|
||||
use_yn CHAR(1) NOT NULL DEFAULT 'Y',
|
||||
use_yn VARCHAR(1) NOT NULL DEFAULT 'Y',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
@ -60,8 +60,8 @@ CREATE TABLE kcg.auth_role (
|
||||
role_cd VARCHAR(50) UNIQUE NOT NULL, -- ADMIN, OPERATOR, ANALYST, VIEWER, FIELD
|
||||
role_nm VARCHAR(100) NOT NULL,
|
||||
role_dc TEXT,
|
||||
dflt_yn CHAR(1) NOT NULL DEFAULT 'N', -- 신규 사용자 자동 배정 여부
|
||||
builtin_yn CHAR(1) NOT NULL DEFAULT 'N', -- 내장 역할 (삭제 불가)
|
||||
dflt_yn VARCHAR(1) NOT NULL DEFAULT 'N', -- 신규 사용자 자동 배정 여부
|
||||
builtin_yn VARCHAR(1) NOT NULL DEFAULT 'N', -- 내장 역할 (삭제 불가)
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
@ -13,7 +13,7 @@ CREATE TABLE kcg.auth_perm_tree (
|
||||
icon VARCHAR(50),
|
||||
rsrc_level INT NOT NULL DEFAULT 0, -- 0=tab(권한그룹), 1=subtab/패널, 2+=중첩
|
||||
sort_ord INT NOT NULL DEFAULT 0,
|
||||
use_yn CHAR(1) NOT NULL DEFAULT 'Y',
|
||||
use_yn VARCHAR(1) NOT NULL DEFAULT 'Y',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
@ -32,7 +32,7 @@ CREATE TABLE kcg.auth_perm (
|
||||
role_sn BIGINT NOT NULL REFERENCES kcg.auth_role(role_sn) ON DELETE CASCADE,
|
||||
rsrc_cd VARCHAR(100) NOT NULL REFERENCES kcg.auth_perm_tree(rsrc_cd) ON DELETE CASCADE,
|
||||
oper_cd VARCHAR(20) NOT NULL, -- READ, CREATE, UPDATE, DELETE, EXPORT, MANAGE
|
||||
grant_yn CHAR(1) NOT NULL, -- Y(허용), N(명시적 거부)
|
||||
grant_yn VARCHAR(1) NOT NULL, -- Y(허용), N(명시적 거부)
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_by UUID,
|
||||
UNIQUE(role_sn, rsrc_cd, oper_cd)
|
||||
|
||||
@ -0,0 +1,28 @@
|
||||
-- ============================================================================
|
||||
-- V006: 데모 계정 5종 생성 (해시는 AccountSeeder가 시동 시 갱신)
|
||||
-- ============================================================================
|
||||
-- 향후 운영 배포 시에도 데모 계정은 유지됨 (운영자가 비활성화 가능)
|
||||
-- 비밀번호는 AccountSeeder.java가 BCrypt로 시동 시 한 번만 시드/갱신
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
-- 기존 admin placeholder 제거 (V003에서 만든 행)
|
||||
DELETE FROM kcg.auth_user_role WHERE user_id IN (SELECT user_id FROM kcg.auth_user WHERE user_acnt = 'admin');
|
||||
DELETE FROM kcg.auth_user WHERE user_acnt = 'admin';
|
||||
|
||||
-- 데모 계정 5종 생성 (pswd_hash는 placeholder, AccountSeeder가 BCrypt로 갱신)
|
||||
INSERT INTO kcg.auth_user (user_acnt, user_nm, rnkp_nm, email, user_stts_cd, auth_provider, pswd_hash) VALUES
|
||||
('admin', '김영수', '사무관', 'admin@kcg.go.kr', 'ACTIVE', 'PASSWORD', 'PLACEHOLDER_TO_BE_SEEDED'),
|
||||
('operator', '이상호', '경위', 'operator@kcg.go.kr', 'ACTIVE', 'PASSWORD', 'PLACEHOLDER_TO_BE_SEEDED'),
|
||||
('analyst', '정해진', '주무관', 'analyst@kcg.go.kr', 'ACTIVE', 'PASSWORD', 'PLACEHOLDER_TO_BE_SEEDED'),
|
||||
('field', '박민수', '경사', 'field@kcg.go.kr', 'ACTIVE', 'PASSWORD', 'PLACEHOLDER_TO_BE_SEEDED'),
|
||||
('viewer', '최원석', '6급', 'viewer@kcg.go.kr', 'ACTIVE', 'PASSWORD', 'PLACEHOLDER_TO_BE_SEEDED');
|
||||
|
||||
-- 역할 매핑 (계정 → 역할)
|
||||
INSERT INTO kcg.auth_user_role (user_id, role_sn)
|
||||
SELECT u.user_id, r.role_sn
|
||||
FROM kcg.auth_user u, kcg.auth_role r
|
||||
WHERE (u.user_acnt = 'admin' AND r.role_cd = 'ADMIN')
|
||||
OR (u.user_acnt = 'operator' AND r.role_cd = 'OPERATOR')
|
||||
OR (u.user_acnt = 'analyst' AND r.role_cd = 'ANALYST')
|
||||
OR (u.user_acnt = 'field' AND r.role_cd = 'FIELD')
|
||||
OR (u.user_acnt = 'viewer' AND r.role_cd = 'VIEWER');
|
||||
@ -1,11 +1,13 @@
|
||||
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react';
|
||||
import { fetchMe, loginApi, logoutApi, LoginError, type BackendUser } from '@/services/authApi';
|
||||
|
||||
/*
|
||||
* SFR-01: 시스템 로그인 및 권한 관리
|
||||
* - 역할 기반 권한 관리(RBAC)
|
||||
* - 세션 타임아웃(30분 미사용 시 자동 로그아웃)
|
||||
* - 동시 접속 1계정 1세션
|
||||
* - 감사 로그 기록
|
||||
* - 백엔드 JWT 쿠키 기반 인증
|
||||
* - 트리 기반 RBAC (백엔드의 auth_perm_tree + auth_perm)
|
||||
* - 다중 역할 + 부모 fallback (예: detection:gear-detection 미존재 시 detection 검사)
|
||||
* - 세션 타임아웃: 30분 미사용 시 자동 로그아웃
|
||||
* - 로그인 이력 + 감사로그는 백엔드 DB(kcgaidb)에 기록
|
||||
*/
|
||||
|
||||
// ─── RBAC 역할 정의 ─────────────────────
|
||||
@ -13,95 +15,125 @@ export type UserRole = 'ADMIN' | 'OPERATOR' | 'ANALYST' | 'FIELD' | 'VIEWER';
|
||||
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
/** 로그인 ID */
|
||||
account: string;
|
||||
name: string;
|
||||
rank: string;
|
||||
org: string;
|
||||
/** 다중 역할 (백엔드는 배열 반환) */
|
||||
roles: UserRole[];
|
||||
/** 1차 역할 (기존 코드 호환) */
|
||||
role: UserRole;
|
||||
/** 권한 트리: rsrcCd → operations[] */
|
||||
permissions: Record<string, string[]>;
|
||||
authMethod: 'password' | 'gpki' | 'sso';
|
||||
loginAt: string;
|
||||
}
|
||||
|
||||
// ─── 역할별 접근 가능 경로 ──────────────────
|
||||
const ROLE_PERMISSIONS: Record<UserRole, string[]> = {
|
||||
ADMIN: [
|
||||
'/dashboard', '/monitoring', '/events', '/map-control', '/event-list',
|
||||
'/risk-map', '/enforcement-plan',
|
||||
'/dark-vessel', '/gear-detection', '/china-fishing',
|
||||
'/patrol-route', '/fleet-optimization',
|
||||
'/enforcement-history', '/statistics', '/reports',
|
||||
'/ai-alert', '/mobile-service', '/ship-agent', '/external-service',
|
||||
'/ai-model', '/mlops', '/ai-assistant',
|
||||
'/data-hub', '/system-config', '/notices', '/admin', '/access-control',
|
||||
],
|
||||
OPERATOR: [
|
||||
'/dashboard', '/monitoring', '/events', '/map-control', '/event-list',
|
||||
'/risk-map', '/enforcement-plan',
|
||||
'/dark-vessel', '/gear-detection', '/china-fishing',
|
||||
'/patrol-route', '/fleet-optimization',
|
||||
'/enforcement-history', '/statistics', '/reports',
|
||||
'/ai-alert', '/mobile-service', '/ship-agent',
|
||||
'/data-hub', '/system-config',
|
||||
],
|
||||
ANALYST: [
|
||||
'/dashboard', '/monitoring', '/events', '/event-list',
|
||||
'/risk-map', '/dark-vessel', '/gear-detection', '/china-fishing',
|
||||
'/enforcement-history', '/statistics', '/reports',
|
||||
'/ai-model', '/mlops', '/ai-assistant',
|
||||
'/system-config',
|
||||
],
|
||||
FIELD: [
|
||||
'/dashboard', '/monitoring', '/events', '/event-list',
|
||||
'/risk-map', '/enforcement-plan',
|
||||
'/dark-vessel', '/china-fishing',
|
||||
'/mobile-service', '/ship-agent', '/ai-alert',
|
||||
],
|
||||
VIEWER: [
|
||||
'/dashboard', '/monitoring', '/statistics',
|
||||
],
|
||||
};
|
||||
|
||||
// ─── 감사 로그 ──────────────────────────
|
||||
export interface AuditEntry {
|
||||
time: string;
|
||||
user: string;
|
||||
action: string;
|
||||
target: string;
|
||||
ip: string;
|
||||
result: '성공' | '실패' | '차단';
|
||||
}
|
||||
|
||||
function writeAuditLog(entry: Omit<AuditEntry, 'time' | 'ip'>) {
|
||||
const log: AuditEntry = {
|
||||
...entry,
|
||||
time: new Date().toISOString().replace('T', ' ').slice(0, 19),
|
||||
ip: '10.20.30.1', // 시뮬레이션
|
||||
};
|
||||
const logs: AuditEntry[] = JSON.parse(sessionStorage.getItem('audit_logs') || '[]');
|
||||
logs.unshift(log);
|
||||
sessionStorage.setItem('audit_logs', JSON.stringify(logs.slice(0, 200)));
|
||||
}
|
||||
|
||||
// ─── 세션 타임아웃 (30분) ──────────────────
|
||||
const SESSION_TIMEOUT = 30 * 60 * 1000;
|
||||
|
||||
// 경로 → 권한 리소스 매핑 (ProtectedRoute용)
|
||||
const PATH_TO_RESOURCE: Record<string, string> = {
|
||||
'/dashboard': 'dashboard',
|
||||
'/monitoring': 'monitoring',
|
||||
'/events': 'surveillance:live-map',
|
||||
'/map-control': 'surveillance:map-control',
|
||||
'/dark-vessel': 'detection:dark-vessel',
|
||||
'/gear-detection': 'detection:gear-detection',
|
||||
'/china-fishing': 'detection:china-fishing',
|
||||
'/vessel': 'vessel',
|
||||
'/risk-map': 'risk-assessment:risk-map',
|
||||
'/enforcement-plan': 'risk-assessment:enforcement-plan',
|
||||
'/patrol-route': 'patrol:patrol-route',
|
||||
'/fleet-optimization': 'patrol:fleet-optimization',
|
||||
'/enforcement-history': 'enforcement:enforcement-history',
|
||||
'/event-list': 'enforcement:event-list',
|
||||
'/mobile-service': 'field-ops:mobile-service',
|
||||
'/ship-agent': 'field-ops:ship-agent',
|
||||
'/ai-alert': 'field-ops:ai-alert',
|
||||
'/ai-assistant': 'ai-operations:ai-assistant',
|
||||
'/ai-model': 'ai-operations:ai-model',
|
||||
'/mlops': 'ai-operations:mlops',
|
||||
'/statistics': 'statistics:statistics',
|
||||
'/external-service': 'statistics:external-service',
|
||||
'/admin': 'admin',
|
||||
'/access-control': 'admin:permission-management',
|
||||
'/system-config': 'admin:system-config',
|
||||
'/notices': 'admin',
|
||||
'/reports': 'statistics:statistics',
|
||||
'/data-hub': 'admin:system-config',
|
||||
};
|
||||
|
||||
interface AuthContextType {
|
||||
user: AuthUser | null;
|
||||
login: (user: AuthUser) => void;
|
||||
logout: () => void;
|
||||
loading: boolean;
|
||||
/** ID/PW 로그인 (백엔드 호출) */
|
||||
login: (account: string, password: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
/** 경로 기반 접근 가능 여부 (메뉴/라우트 가드용) */
|
||||
hasAccess: (path: string) => boolean;
|
||||
sessionRemaining: number; // seconds
|
||||
/** 트리 기반 권한 체크 (resource + operation) */
|
||||
hasPermission: (resource: string, operation?: string) => boolean;
|
||||
sessionRemaining: number;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | null>(null);
|
||||
|
||||
function backendToAuthUser(b: BackendUser): AuthUser {
|
||||
const primaryRole = (b.roles[0] ?? 'VIEWER') as UserRole;
|
||||
return {
|
||||
id: b.id,
|
||||
account: b.account,
|
||||
name: b.name,
|
||||
rank: b.rank ?? '',
|
||||
org: '', // 향후 백엔드에서 org_sn/org_nm 추가 시 채움
|
||||
roles: b.roles as UserRole[],
|
||||
role: primaryRole,
|
||||
permissions: b.permissions,
|
||||
authMethod: (b.authProvider?.toLowerCase() as AuthUser['authMethod']) ?? 'password',
|
||||
loginAt: new Date().toISOString().replace('T', ' ').slice(0, 19),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 트리 기반 권한 체크 (부모 fallback 지원).
|
||||
* "detection:gear-detection"이 직접 등록되지 않았으면 "detection" 부모를 검사.
|
||||
*/
|
||||
function checkPermission(perms: Record<string, string[]>, resource: string, operation: string): boolean {
|
||||
const ops = perms[resource];
|
||||
if (ops && ops.includes(operation)) return true;
|
||||
// 부모 fallback
|
||||
const colonIdx = resource.indexOf(':');
|
||||
if (colonIdx > 0) {
|
||||
const parent = resource.substring(0, colonIdx);
|
||||
const parentOps = perms[parent];
|
||||
return !!parentOps && parentOps.includes(operation);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<AuthUser | null>(() => {
|
||||
const stored = sessionStorage.getItem('auth_user');
|
||||
return stored ? JSON.parse(stored) : null;
|
||||
});
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [lastActivity, setLastActivity] = useState(Date.now());
|
||||
const [sessionRemaining, setSessionRemaining] = useState(SESSION_TIMEOUT / 1000);
|
||||
|
||||
// 초기 세션 복원: /api/auth/me 호출
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
fetchMe()
|
||||
.then((b) => {
|
||||
if (alive && b) setUser(backendToAuthUser(b));
|
||||
})
|
||||
.finally(() => {
|
||||
if (alive) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 사용자 활동 감지 → 세션 갱신
|
||||
const resetActivity = useCallback(() => {
|
||||
setLastActivity(Date.now());
|
||||
@ -123,37 +155,54 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
setSessionRemaining(remaining);
|
||||
|
||||
if (elapsed >= SESSION_TIMEOUT) {
|
||||
writeAuditLog({ user: user.name, action: '세션 타임아웃 로그아웃', target: '시스템', result: '성공' });
|
||||
logoutApi().catch(() => undefined);
|
||||
setUser(null);
|
||||
sessionStorage.removeItem('auth_user');
|
||||
}
|
||||
}, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [user, lastActivity]);
|
||||
|
||||
const login = useCallback((u: AuthUser) => {
|
||||
setUser(u);
|
||||
setLastActivity(Date.now());
|
||||
sessionStorage.setItem('auth_user', JSON.stringify(u));
|
||||
writeAuditLog({ user: u.name, action: `로그인 (${u.authMethod})`, target: '시스템', result: '성공' });
|
||||
const login = useCallback(async (account: string, password: string) => {
|
||||
try {
|
||||
const b = await loginApi(account, password);
|
||||
setUser(backendToAuthUser(b));
|
||||
setLastActivity(Date.now());
|
||||
} catch (e) {
|
||||
if (e instanceof LoginError) throw e;
|
||||
throw new LoginError('NETWORK_ERROR');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
if (user) {
|
||||
writeAuditLog({ user: user.name, action: '로그아웃', target: '시스템', result: '성공' });
|
||||
const logout = useCallback(async () => {
|
||||
try {
|
||||
await logoutApi();
|
||||
} finally {
|
||||
setUser(null);
|
||||
}
|
||||
setUser(null);
|
||||
sessionStorage.removeItem('auth_user');
|
||||
}, [user]);
|
||||
}, []);
|
||||
|
||||
const hasAccess = useCallback((path: string) => {
|
||||
if (!user) return false;
|
||||
const allowed = ROLE_PERMISSIONS[user.role] || [];
|
||||
return allowed.some((p) => path.startsWith(p));
|
||||
}, [user]);
|
||||
const hasPermission = useCallback(
|
||||
(resource: string, operation: string = 'READ') => {
|
||||
if (!user) return false;
|
||||
return checkPermission(user.permissions, resource, operation);
|
||||
},
|
||||
[user],
|
||||
);
|
||||
|
||||
const hasAccess = useCallback(
|
||||
(path: string) => {
|
||||
if (!user) return false;
|
||||
// 경로의 첫 세그먼트로 매핑
|
||||
const matched = Object.keys(PATH_TO_RESOURCE).find((p) => path.startsWith(p));
|
||||
if (!matched) return true; // 매핑 없는 경로는 허용 (안전한 기본값으로 변경 가능)
|
||||
const resource = PATH_TO_RESOURCE[matched];
|
||||
return hasPermission(resource, 'READ');
|
||||
},
|
||||
[user, hasPermission],
|
||||
);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, login, logout, hasAccess, sessionRemaining }}>
|
||||
<AuthContext.Provider value={{ user, loading, login, logout, hasAccess, hasPermission, sessionRemaining }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
|
||||
61
frontend/src/features/auth/DemoQuickLogin.tsx
Normal file
61
frontend/src/features/auth/DemoQuickLogin.tsx
Normal file
@ -0,0 +1,61 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/*
|
||||
* 데모 퀵로그인 영역.
|
||||
*
|
||||
* 표시 조건: VITE_SHOW_DEMO_LOGIN === 'true' (.env 또는 빌드 환경변수)
|
||||
* - 로컬 개발: .env.development에 VITE_SHOW_DEMO_LOGIN=true
|
||||
* - 배포 환경: .env.production에 VITE_SHOW_DEMO_LOGIN=false (또는 미설정)
|
||||
*
|
||||
* 데모 계정은 백엔드 DB(kcgaidb)에 실제 BCrypt 해시로 시드되어 있으며,
|
||||
* 권한, 로그인 이력, 감사 로그도 동일하게 기록된다.
|
||||
* 따라서 클릭 시에도 정상 로그인 플로우를 거쳐 백엔드 인증을 수행한다.
|
||||
*/
|
||||
|
||||
export interface DemoAccount {
|
||||
account: string;
|
||||
password: string;
|
||||
roleLabelKey: string; // i18n 키
|
||||
}
|
||||
|
||||
export const DEMO_ACCOUNTS: DemoAccount[] = [
|
||||
{ account: 'admin', password: 'admin1234!', roleLabelKey: 'demo.admin' },
|
||||
{ account: 'operator', password: 'oper12345!', roleLabelKey: 'demo.operator' },
|
||||
{ account: 'analyst', password: 'anal12345!', roleLabelKey: 'demo.analyst' },
|
||||
{ account: 'field', password: 'field1234!', roleLabelKey: 'demo.field' },
|
||||
{ account: 'viewer', password: 'view12345!', roleLabelKey: 'demo.viewer' },
|
||||
];
|
||||
|
||||
export function isDemoLoginEnabled(): boolean {
|
||||
return import.meta.env.VITE_SHOW_DEMO_LOGIN === 'true';
|
||||
}
|
||||
|
||||
interface DemoQuickLoginProps {
|
||||
onSelect: (account: DemoAccount) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function DemoQuickLogin({ onSelect, disabled }: DemoQuickLoginProps) {
|
||||
const { t } = useTranslation('auth');
|
||||
|
||||
if (!isDemoLoginEnabled()) return null;
|
||||
|
||||
return (
|
||||
<div className="pt-2 border-t border-border">
|
||||
<div className="text-[9px] text-hint text-center mb-2">{t('demo.title')}</div>
|
||||
<div className="grid grid-cols-5 gap-1.5">
|
||||
{DEMO_ACCOUNTS.map((acct) => (
|
||||
<button
|
||||
key={acct.account}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onSelect(acct)}
|
||||
className="py-1.5 rounded-md text-[9px] font-medium bg-surface-overlay border border-border text-muted-foreground hover:text-heading hover:bg-switch-background/60 transition-colors whitespace-nowrap disabled:opacity-50"
|
||||
>
|
||||
{t(acct.roleLabelKey)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -2,30 +2,42 @@ import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Shield, Eye, EyeOff, Lock, User, Fingerprint, KeyRound, AlertCircle } from 'lucide-react';
|
||||
import { useAuth, type UserRole } from '@/app/auth/AuthContext';
|
||||
import { useAuth } from '@/app/auth/AuthContext';
|
||||
import { LoginError } from '@/services/authApi';
|
||||
import { DemoQuickLogin, type DemoAccount } from './DemoQuickLogin';
|
||||
|
||||
/*
|
||||
* SFR-01: 시스템 로그인 및 권한 관리
|
||||
* - 해양경찰 SSO·공무원증·GPKI 등 기존 인증체계 로그인 연동
|
||||
* - 역할 기반 권한 관리(RBAC)
|
||||
* - 비밀번호 정책, 계정 잠금 정책
|
||||
* - 감사 로그 기록
|
||||
* - 5회 연속 실패 시 계정 잠금(30분)
|
||||
* - 백엔드 ID/PW 인증 (자체 백엔드 + JWT 쿠키)
|
||||
* - GPKI/SSO는 향후 Phase 9 도입 (현재 비활성)
|
||||
* - 비밀번호 정책, 계정 잠금 정책은 백엔드에서 처리
|
||||
* - 모든 로그인 시도(성공/실패)는 백엔드 DB에 기록
|
||||
*
|
||||
* 데모 퀵로그인은 DemoQuickLogin 컴포넌트로 분리됨
|
||||
* (VITE_SHOW_DEMO_LOGIN=true일 때만 표시).
|
||||
*/
|
||||
|
||||
type AuthMethod = 'password' | 'gpki' | 'sso';
|
||||
|
||||
// SFR-01: 시뮬레이션 계정 (역할별)
|
||||
const DEMO_ACCOUNTS: Record<string, { pw: string; name: string; rank: string; org: string; role: UserRole }> = {
|
||||
admin: { pw: 'admin1234!', name: '김영수', rank: '사무관', org: '본청 정보통신과', role: 'ADMIN' },
|
||||
operator: { pw: 'oper12345!', name: '이상호', rank: '경위', org: '서해지방해경청', role: 'OPERATOR' },
|
||||
analyst: { pw: 'anal12345!', name: '정해진', rank: '주무관', org: '남해지방해경청', role: 'ANALYST' },
|
||||
field: { pw: 'field1234!', name: '박민수', rank: '경사', org: '5001함 삼봉', role: 'FIELD' },
|
||||
viewer: { pw: 'view12345!', name: '최원석', rank: '6급', org: '해수부 어업관리과', role: 'VIEWER' },
|
||||
const ERROR_MESSAGES: Record<string, string> = {
|
||||
USER_NOT_FOUND: '존재하지 않는 계정입니다.',
|
||||
ACCOUNT_LOCKED: '계정이 잠겨있습니다. 관리자에게 문의하세요.',
|
||||
WRONG_PROVIDER: '다른 인증 방식으로 가입된 계정입니다.',
|
||||
MAX_FAIL_LOCKED: '5회 연속 실패로 계정이 잠금 처리되었습니다.',
|
||||
NETWORK_ERROR: '네트워크 오류가 발생했습니다.',
|
||||
};
|
||||
|
||||
const MAX_LOGIN_ATTEMPTS = 5;
|
||||
const LOCKOUT_DURATION = 30 * 60 * 1000; // 30분
|
||||
function translateError(reason: string): string {
|
||||
// WRONG_PASSWORD:N → 시도 N회 메시지
|
||||
if (reason.startsWith('WRONG_PASSWORD:')) {
|
||||
const cnt = reason.substring('WRONG_PASSWORD:'.length);
|
||||
return `비밀번호가 올바르지 않습니다. (${cnt}/5)`;
|
||||
}
|
||||
if (reason.startsWith('ACCOUNT_NOT_ACTIVE:')) {
|
||||
return '활성화되지 않은 계정입니다. 관리자 승인이 필요합니다.';
|
||||
}
|
||||
return ERROR_MESSAGES[reason] ?? `로그인 실패: ${reason}`;
|
||||
}
|
||||
|
||||
export function LoginPage() {
|
||||
const { t } = useTranslation('auth');
|
||||
@ -37,80 +49,47 @@ export function LoginPage() {
|
||||
const [showPw, setShowPw] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [failCount, setFailCount] = useState(0);
|
||||
const [lockedUntil, setLockedUntil] = useState<number | null>(null);
|
||||
|
||||
// user 상태가 확정된 후 대시보드로 이동
|
||||
useEffect(() => {
|
||||
if (user) navigate('/dashboard', { replace: true });
|
||||
}, [user, navigate]);
|
||||
|
||||
const doLogin = (method: AuthMethod, account?: typeof DEMO_ACCOUNTS[string]) => {
|
||||
const u = account || DEMO_ACCOUNTS['operator'];
|
||||
login({
|
||||
id: userId || u.role,
|
||||
name: u.name,
|
||||
rank: u.rank,
|
||||
org: u.org,
|
||||
role: u.role,
|
||||
authMethod: method,
|
||||
loginAt: new Date().toISOString().replace('T', ' ').slice(0, 19),
|
||||
});
|
||||
const doLogin = async (account: string, pw: string) => {
|
||||
setError('');
|
||||
setLoading(true);
|
||||
try {
|
||||
await login(account, pw);
|
||||
// 성공 시 useEffect가 navigate 처리
|
||||
} catch (e) {
|
||||
if (e instanceof LoginError) {
|
||||
setError(translateError(e.reason));
|
||||
} else {
|
||||
setError(translateError('NETWORK_ERROR'));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogin = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
// SFR-01: 계정 잠금 확인
|
||||
if (lockedUntil && Date.now() < lockedUntil) {
|
||||
const remainMin = Math.ceil((lockedUntil - Date.now()) / 60000);
|
||||
setError(t('error.locked', { minutes: remainMin }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (authMethod === 'password') {
|
||||
if (!userId.trim()) { setError(t('error.emptyId')); return; }
|
||||
if (!password.trim()) { setError(t('error.emptyPassword')); return; }
|
||||
if (password.length < 9) { setError(t('error.invalidPassword')); return; }
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setTimeout(() => {
|
||||
setLoading(false);
|
||||
|
||||
// SFR-01: ID/PW 인증 시 계정 검증
|
||||
const account = DEMO_ACCOUNTS[userId.toLowerCase()];
|
||||
if (authMethod === 'password' && (!account || account.pw !== password)) {
|
||||
const newCount = failCount + 1;
|
||||
setFailCount(newCount);
|
||||
if (newCount >= MAX_LOGIN_ATTEMPTS) {
|
||||
setLockedUntil(Date.now() + LOCKOUT_DURATION);
|
||||
setError(t('error.maxFailed', { max: MAX_LOGIN_ATTEMPTS }));
|
||||
} else {
|
||||
setError(t('error.wrongCredentials', { count: newCount, max: MAX_LOGIN_ATTEMPTS }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setFailCount(0);
|
||||
setLockedUntil(null);
|
||||
doLogin(authMethod, account);
|
||||
}, 1200);
|
||||
if (authMethod !== 'password') return;
|
||||
if (!userId.trim()) { setError(t('error.emptyId')); return; }
|
||||
if (!password.trim()) { setError(t('error.emptyPassword')); return; }
|
||||
doLogin(userId, password);
|
||||
};
|
||||
|
||||
const DEMO_ROLE_LABELS: Record<UserRole, string> = {
|
||||
ADMIN: t('demo.admin'),
|
||||
OPERATOR: t('demo.operator'),
|
||||
ANALYST: t('demo.analyst'),
|
||||
FIELD: t('demo.field'),
|
||||
VIEWER: t('demo.viewer'),
|
||||
const handleDemoSelect = (acct: DemoAccount) => {
|
||||
setUserId(acct.account);
|
||||
setPassword(acct.password);
|
||||
doLogin(acct.account, acct.password);
|
||||
};
|
||||
|
||||
const authMethods: { key: AuthMethod; icon: React.ElementType; label: string; desc: string }[] = [
|
||||
const authMethods: { key: AuthMethod; icon: React.ElementType; label: string; desc: string; disabled?: boolean }[] = [
|
||||
{ key: 'password', icon: Lock, label: t('authMethod.password'), desc: t('authMethod.passwordDesc') },
|
||||
{ key: 'gpki', icon: Fingerprint, label: t('authMethod.gpki'), desc: t('authMethod.gpkiDesc') },
|
||||
{ key: 'sso', icon: KeyRound, label: t('authMethod.sso'), desc: t('authMethod.ssoDesc') },
|
||||
{ key: 'gpki', icon: Fingerprint, label: t('authMethod.gpki'), desc: t('authMethod.gpkiDesc'), disabled: true },
|
||||
{ key: 'sso', icon: KeyRound, label: t('authMethod.sso'), desc: t('authMethod.ssoDesc'), disabled: true },
|
||||
];
|
||||
|
||||
return (
|
||||
@ -138,12 +117,15 @@ export function LoginPage() {
|
||||
{authMethods.map((m) => (
|
||||
<button
|
||||
key={m.key}
|
||||
onClick={() => { setAuthMethod(m.key); setError(''); }}
|
||||
type="button"
|
||||
onClick={() => { if (!m.disabled) { setAuthMethod(m.key); setError(''); } }}
|
||||
disabled={m.disabled}
|
||||
className={`flex-1 flex flex-col items-center gap-1 py-3 transition-colors ${
|
||||
authMethod === m.key
|
||||
? 'bg-blue-600/10 border-b-2 border-blue-500 text-blue-400'
|
||||
: 'text-hint hover:bg-surface-overlay hover:text-label'
|
||||
}`}
|
||||
} ${m.disabled ? 'opacity-40 cursor-not-allowed' : ''}`}
|
||||
title={m.disabled ? '향후 도입 예정' : ''}
|
||||
>
|
||||
<m.icon className="w-4 h-4" />
|
||||
<span className="text-[9px] font-medium whitespace-nowrap">{m.label}</span>
|
||||
@ -223,89 +205,26 @@ export function LoginPage() {
|
||||
) : t('button.login')}
|
||||
</button>
|
||||
|
||||
{/* 데모 퀵로그인 */}
|
||||
<div className="pt-2 border-t border-border">
|
||||
<div className="text-[9px] text-hint text-center mb-2">{t('demo.title')}</div>
|
||||
<div className="grid grid-cols-5 gap-1.5">
|
||||
{Object.entries(DEMO_ACCOUNTS).map(([key, acct]) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => doLogin('password', acct)}
|
||||
className="py-1.5 rounded-md text-[9px] font-medium bg-surface-overlay border border-border text-muted-foreground hover:text-heading hover:bg-switch-background/60 transition-colors whitespace-nowrap"
|
||||
>
|
||||
{DEMO_ROLE_LABELS[acct.role]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* 데모 퀵로그인 (VITE_SHOW_DEMO_LOGIN=true일 때만 렌더링) */}
|
||||
<DemoQuickLogin onSelect={handleDemoSelect} disabled={loading} />
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* GPKI 인증 */}
|
||||
{/* GPKI 인증 (Phase 9 도입 예정) */}
|
||||
{authMethod === 'gpki' && (
|
||||
<div className="space-y-4">
|
||||
<div className="text-center py-6">
|
||||
<Fingerprint className="w-12 h-12 text-blue-400 mx-auto mb-3" />
|
||||
<p className="text-sm text-heading font-medium">{t('gpki.title')}</p>
|
||||
<p className="text-[10px] text-hint mt-1">{t('gpki.desc')}</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-surface-overlay rounded-lg p-4 border border-dashed border-slate-700/50">
|
||||
<div className="text-center">
|
||||
<div className="text-[10px] text-hint mb-2">{t('gpki.certStatus')}</div>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-yellow-500 animate-pulse" />
|
||||
<span className="text-[11px] text-yellow-400">{t('gpki.certWaiting')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => { setLoading(true); setTimeout(() => { setLoading(false); doLogin('gpki', DEMO_ACCOUNTS['operator']); }, 1500); }}
|
||||
disabled={loading}
|
||||
className="w-full py-2.5 bg-blue-600 hover:bg-blue-500 disabled:bg-blue-600/50 text-heading text-sm font-bold rounded-lg transition-colors flex items-center justify-center gap-2 whitespace-nowrap"
|
||||
>
|
||||
{loading ? (
|
||||
<><div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />{t('gpki.authenticating')}</>
|
||||
) : t('gpki.start')}
|
||||
</button>
|
||||
|
||||
<p className="text-[9px] text-hint text-center">
|
||||
{t('gpki.internalOnly')}
|
||||
</p>
|
||||
<div className="space-y-4 text-center py-12">
|
||||
<Fingerprint className="w-12 h-12 text-blue-400 mx-auto mb-3" />
|
||||
<p className="text-sm text-heading font-medium">{t('gpki.title')}</p>
|
||||
<p className="text-[10px] text-hint mt-1">향후 도입 예정 (Phase 9)</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SSO 연동 */}
|
||||
{/* SSO 연동 (Phase 9 도입 예정) */}
|
||||
{authMethod === 'sso' && (
|
||||
<div className="space-y-4">
|
||||
<div className="text-center py-6">
|
||||
<KeyRound className="w-12 h-12 text-green-400 mx-auto mb-3" />
|
||||
<p className="text-sm text-heading font-medium">{t('sso.title')}</p>
|
||||
<p className="text-[10px] text-hint mt-1">{t('sso.desc')}</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-green-900/20 border border-green-700/30 rounded-lg p-3">
|
||||
<div className="flex items-center gap-2 text-[10px] text-green-400">
|
||||
<Shield className="w-3.5 h-3.5" />
|
||||
{t('sso.tokenDetected')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => { setLoading(true); setTimeout(() => { setLoading(false); doLogin('sso', DEMO_ACCOUNTS['operator']); }, 800); }}
|
||||
disabled={loading}
|
||||
className="w-full py-2.5 bg-green-600 hover:bg-green-500 disabled:bg-green-600/50 text-heading text-sm font-bold rounded-lg transition-colors flex items-center justify-center gap-2 whitespace-nowrap"
|
||||
>
|
||||
{loading ? (
|
||||
<><div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />{t('sso.authenticating')}</>
|
||||
) : t('sso.autoLogin')}
|
||||
</button>
|
||||
|
||||
<p className="text-[9px] text-hint text-center">
|
||||
{t('sso.sessionNote')}
|
||||
</p>
|
||||
<div className="space-y-4 text-center py-12">
|
||||
<KeyRound className="w-12 h-12 text-green-400 mx-auto mb-3" />
|
||||
<p className="text-sm text-heading font-medium">{t('sso.title')}</p>
|
||||
<p className="text-[10px] text-hint mt-1">향후 도입 예정 (Phase 9)</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
65
frontend/src/services/authApi.ts
Normal file
65
frontend/src/services/authApi.ts
Normal file
@ -0,0 +1,65 @@
|
||||
/**
|
||||
* 백엔드 인증 API 클라이언트.
|
||||
* - JWT 쿠키 기반 (credentials: include)
|
||||
* - 로그인/로그아웃/세션 조회
|
||||
*/
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL ?? '/api';
|
||||
|
||||
export interface BackendUser {
|
||||
id: string;
|
||||
account: string;
|
||||
name: string;
|
||||
rank: string | null;
|
||||
email: string | null;
|
||||
status: string;
|
||||
authProvider: string;
|
||||
roles: string[];
|
||||
/** rsrcCd → operCd[] (READ/CREATE/UPDATE/DELETE/EXPORT) */
|
||||
permissions: Record<string, string[]>;
|
||||
}
|
||||
|
||||
export class LoginError extends Error {
|
||||
constructor(public readonly reason: string) {
|
||||
super(reason);
|
||||
}
|
||||
}
|
||||
|
||||
export async function loginApi(account: string, password: string): Promise<BackendUser> {
|
||||
const res = await fetch(`${API_BASE}/auth/login`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ account, password }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
let reason = `HTTP_${res.status}`;
|
||||
try {
|
||||
const body = await res.json();
|
||||
if (body?.reason) reason = body.reason;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
throw new LoginError(reason);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function logoutApi(): Promise<void> {
|
||||
await fetch(`${API_BASE}/auth/logout`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchMe(): Promise<BackendUser | null> {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/auth/me`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
return res.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
2
frontend/src/vite-env.d.ts
vendored
2
frontend/src/vite-env.d.ts
vendored
@ -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 {
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user