From aabb9d38a9390a280712a20799823d1b1006598c Mon Sep 17 00:00:00 2001 From: Rinjae Date: Thu, 13 Nov 2025 19:41:25 +0900 Subject: [PATCH] =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=8B=9C=20?= =?UTF-8?q?=EC=9D=B4=EB=A9=94=EC=9D=BC=20=EC=9D=B8=EC=A6=9D=20=EC=A0=88?= =?UTF-8?q?=EC=B0=A8=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .cursorrules | 139 ++++++ .github/copilot-instructions.md | Bin 0 -> 10287 bytes CLAUDE.md | 456 ++++++++++++++++++ README.md | 142 ++++++ .../auth/service/AuthNumberServiceImpl.java | 4 +- .../user/controller/AccountController.java | 112 +++++ .../portal/apps/user/facade/UserFacade.java | 2 + .../apps/user/facade/UserFacadeImpl.java | 19 + .../apps/user/service/PortalUserService.java | 9 + .../config/PortalAuthenticationManager.java | 7 +- .../PortalAuthenticationSuccessHandler.java | 10 +- src/main/resources/application.yml | 2 + src/main/resources/logback-gf63.xml | 1 + .../templates/views/apps/login/login.html | 8 +- .../templates/views/apps/main/index.html | 19 +- .../views/apps/mypage/verificationEmail.html | 246 ++++++++++ 16 files changed, 1165 insertions(+), 11 deletions(-) create mode 100644 .cursorrules create mode 100644 .github/copilot-instructions.md create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 src/main/resources/templates/views/apps/mypage/verificationEmail.html diff --git a/.cursorrules b/.cursorrules new file mode 100644 index 0000000..1a4c63c --- /dev/null +++ b/.cursorrules @@ -0,0 +1,139 @@ +# Cursor Rules for EAPIM Portal + +> 이 파일은 CLAUDE.md를 기반으로 합니다. 내용 수정 시 CLAUDE.md와 동기화해주세요. + +## 프로젝트 개요 + +광주은행 EAPIM Portal - 엔터프라이즈 API 포털 관리 시스템 +- Spring Boot 2.7.18, Java 8, Gradle 7.x +- Oracle 12c 이중 데이터베이스 (EMS, Gateway) +- Spring Security, JPA/Hibernate, MapStruct, Lombok + +## 빌드 & 실행 + +```bash +# gradlew 사용 금지 - offline gradle 사용 +gradle bootRun # 개발 환경 실행 +gradle bootWar # 실행 가능 WAR +gradle war # 서버용 WAR (JEUS/WebLogic) +gradle test # 테스트 +``` + +## 아키텍처 핵심 + +### 외부 모듈 의존성 +- elink-online-core-jpa: Gateway 데이터 모델 +- elink-portal-common: 공통 유틸리티 +- kjb-safedb: SafeDB 암호화 라이브러리 + +### 패키지 구조 (기능 모듈 방식) +``` +apps/ +├── user/ # 사용자 관리 +├── app/ # API 키 관리 +├── approval/ # 승인 워크플로우 +├── apis/ # API 카탈로그 +├── proxy/ # API 테스트 프록시 +└── ... +``` + +각 모듈: controller/ service/ repository/ dto/ mapper/ + +### 이중 DB +- EMS (Portal): jdbc/dsOBP_EMS, com.eactive.apim.portal.apps.* +- Gateway: jdbc/dsOBP_AGW, com.eactive.eai.data.entity.onl.* +- Atomikos JTA로 분산 트랜잭션 + +### 프로파일 +- dev: 개발 (192.168.240.177:1599, SQL 로깅) +- stage: 스테이징 (JNDI, proxy to inter-dapiwas01) +- prod: 운영 (JNDI, proxy to inter-apiwas00, 캐싱) + +## 개발 패턴 + +### 새 기능 모듈 +1. apps/ 하위에 패키지 생성 +2. controller/, service/, repository/, dto/, mapper/ 구조 +3. Entity는 AbstractAuditingEntity 확장 +4. DTO는 Lombok @Data +5. Mapper는 MapStruct +6. Service는 @Transactional +7. Thymeleaf 템플릿 추가 +8. application.yml의 pages 섹션에 라우트 설정 (또는 @Controller 직접 매핑) + +### 보안 +- config/PortalConfigSecurity.java +- PortalAuthenticationManager (커스텀 인증) +- CSRF, XSS 방어, 세션 고정 공격 방어 +- 역할 계층: ROLE_USER < ROLE_CORP_USER < ROLE_CORP_MANAGER + +### 트랜잭션 +- 기본: @Transactional (REQUIRED) +- 읽기 전용: @Transactional(readOnly = true) +- 다중 DB는 자동 처리 (Atomikos JTA) + +### 로깅 +- SLF4J with Lombok @Slf4j +- 로그 경로: /Log/App/eapim/ +- 개발 환경만 SQL 로깅 + +### 쿼리 +- 우선: Spring Data JPA 메서드 +- 복잡한 쿼리: QueryDSL + +## 주요 기능 + +### 사용자 관리 (apps/user/) +- 내부 사용자 (Staff), 기업 사용자 (Enterprise) +- 가입 → 이메일 인증 → 관리자 승인 → 활성화 +- 비밀번호 정책: 90일 만료, 복잡도 규칙 + +### API 키 관리 (apps/app/) +- 승인 워크플로우 +- 개발/운영 환경 지원 +- eLink Gateway ClientID/ClientSecret 연동 + +### 감사 추적 +- AbstractAuditingEntity (createdBy, createdDate, lastModifiedBy, lastModifiedDate) +- @Audited로 변경 이력 +- Spring Data Envers로 조회 + +## 중요 사항 + +### 한글 지원 +- Thymeleaf 템플릿 한글 +- DB 데이터 UTF-8 +- messages_ko.properties + +### Forward Proxy +- Dev: http://localhost:10000 +- Stage: http://inter-dapiwas01:10000 +- Prod: http://inter-apiwas00:10000 + +### 비밀번호 정책 (PortalUserValidator) +- 최소 8자, 대소문자+숫자+특수문자 +- 연속 문자 불가, 최근 3개 재사용 불가 +- 90일 만료 + +### 세션 +- 15분 타임아웃 +- 단일 세션 강제 +- Redis/Ehcache 클러스터링 (stage/prod) + +### 파일 업로드 +- 최대 8MB +- /Log/App/eapim/files/ +- XSS 스캐닝 + +## 배포 +- JEUS, WebLogic, GlassFish 6.3, Tomcat 지원 +- JNDI 설정 필수 (stage/prod) +- Docker 지원 + +## 관련 프로젝트 +- ../eapim-admin/ +- ../eapim-online/ +- ../kjb-eapim-sql/ + +--- +상세 내용은 CLAUDE.md 참조 diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000000000000000000000000000000000000..35270b0351ffd7e8050ab3b3d2bd902e1c4227ba GIT binary patch literal 10287 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 toDtoList(List entities); +} +``` + +생성된 구현체는 `build/generated/sources/annotationProcessor/java/main/`에 위치 + +### 보안 및 인증 + +보안 설정: `config/PortalConfigSecurity.java` +- 커스텀 인증 관리자: `PortalAuthenticationManager` +- `changeSessionId()`를 통한 세션 고정 공격 방어 +- 쿠키 기반 토큰을 사용한 CSRF 방어 +- Lucy 필터를 통한 XSS 방어 + +역할 계층: +``` +ROLE_USER → [ROLE_INQUIRY, ROLE_ACCOUNT] +ROLE_CORP_USER → [ROLE_INQUIRY, ROLE_APP, ROLE_ACCOUNT] +ROLE_CORP_MANAGER → [ROLE_API_KEY_REQUEST, ROLE_INQUIRY, ROLE_APP, + ROLE_ACCOUNT, ROLE_CORP_API, ROLE_DASHBOARD, + ROLE_USER_MANAGER] +``` + +### 데이터베이스 쿼리 + +Spring Data JPA 리포지토리 메서드 사용 권장: + +```java +public interface PortalUserRepository extends JpaRepository { + Optional findByEmail(String email); + + @Query("SELECT u FROM PortalUser u WHERE u.status = :status") + List findByStatus(@Param("status") UserStatus status); +} +``` + +복잡한 쿼리는 QueryDSL 사용: +```java +QPortalUser user = QPortalUser.portalUser; +return queryFactory.selectFrom(user) + .where(user.email.eq(email) + .and(user.status.eq(UserStatus.ACTIVE))) + .fetchOne(); +``` + +### 트랜잭션 관리 + +적절한 propagation과 함께 `@Transactional` 사용: +- 기본값: `REQUIRED` (기존 트랜잭션에 참여하거나 새로 생성) +- 읽기 전용 작업: 최적화를 위해 `@Transactional(readOnly = true)` +- 다중 데이터베이스: Atomikos JTA가 자동으로 분산 트랜잭션 처리 + +### 에러 처리 + +`common/exception/`의 글로벌 예외 처리: +- `@ControllerAdvice`가 적용된 `GlobalExceptionHandler` +- 커스텀 예외는 `RuntimeException` 확장 +- Thymeleaf 에러 뷰 또는 AJAX 요청에는 JSON 반환 + +### 로깅 + +`application.yml`의 로깅 설정: +```yaml +logging: + file: + path: /Log/App/eapim/ + level: + com.eactive.apim.portal: DEBUG + org.hibernate.SQL: DEBUG (개발 환경만) +``` + +Lombok의 `@Slf4j`와 함께 SLF4J 사용: +```java +@Slf4j +@Service +public class UserService { + public void someMethod() { + log.debug("디버그 메시지"); + log.info("정보 메시지"); + log.error("에러 메시지", exception); + } +} +``` + +## 테스트 가이드 + +### 테스트 구조 + +`src/test/java/com/eactive/apim/portal/`의 테스트: +- `BaseWebTest` - Spring Boot 테스트 컨텍스트를 가진 기본 클래스 +- 컨트롤러 테스트는 `BaseWebTest` 확장 +- 통합 테스트는 `@SpringBootTest` 사용 +- 독립적인 컨트롤러 테스트는 `@WebMvcTest` 사용 + +### 일반적인 테스트 패턴 + +```java +@SpringBootTest +@AutoConfigureMockMvc +class UserControllerTest extends BaseWebTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private UserService userService; + + @Test + void testUserRegistration() throws Exception { + mockMvc.perform(post("/user/register") + .param("email", "test@example.com") + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(view().name("user/register-success")); + } +} +``` + +### SafeDB 테스트 + +테스트에서는 기본적으로 SafeDB 암호화가 비활성화됩니다. 필요한 경우: +```bash +gradle test -Dsafedb=/path/to/safedb +``` + +## 중요 사항 + +### 한글 지원 + +이 포털은 한글을 광범위하게 사용합니다: +- Thymeleaf 템플릿은 한글 레이블 사용 +- 데이터베이스는 한글 데이터 포함 (UTF-8) +- 한글 메시지 프로퍼티 (`messages_ko.properties`) +- 한글 사용자 가이드 (`개발자포탈.md`) + +### Forward Proxy 설정 + +API 테스트는 Gateway로의 Forward Proxy 사용: +- Dev: `http://localhost:10000` +- Stage: `http://inter-dapiwas01:10000` +- Prod: `http://inter-apiwas00:10000` + +설정: `apps/proxy/service/ProxyService.java` + +### 비밀번호 정책 + +`PortalUserValidator`에서 적용: +- 최소 8자 +- 대문자, 소문자, 숫자, 특수문자 포함 필수 +- 연속된 문자 사용 불가 +- 최근 3개 비밀번호 재사용 불가 +- 90일 후 만료 + +### 세션 관리 + +- 사용자당 단일 세션 강제 +- 세션 타임아웃: 15분 +- 로그인 시 세션 고정 공격 방어 +- Redis/Ehcache를 통한 세션 클러스터링 (스테이징/운영) + +### 파일 업로드 제한 + +- 최대 파일 크기: 8MB +- `FileService`에서 허용 확장자 설정 +- 파일 저장 위치: `/Log/App/eapim/files/` +- 업로드 시 XSS 스캐닝 + +## 배포 + +### 애플리케이션 서버 지원 + +WAR 파일 호환 서버: +- **JEUS** (`jeus-web-dd.xml` 필요) +- **WebLogic** (`weblogic.xml` 필요) +- **Tomcat** (Spring Boot 내장) + +### JNDI 설정 필수 + +스테이징/운영 환경에서 JNDI 리소스 설정: +- `jdbc/dsOBP_EMS` → Portal 데이터베이스 +- `jdbc/dsOBP_AGW` → Gateway 데이터베이스 + +### 환경 변수 + +```bash +# 선택사항: 기본 설정 오버라이드 +JAVA_OPTS="-Xmx2g -Xms1g -Dspring.profiles.active=prod" +``` + +### Docker 배포 + +```bash +# WAR 빌드 +gradle bootWar + +# Docker 이미지 빌드 +./build_docker.sh + +# 컨테이너 실행 +docker run -p 8080:8080 \ + -e JAVA_OPTS="-Dspring.profiles.active=prod" \ + -v /Log:/Log \ + eapim-portal:latest +``` + +## 관련 모듈 + +다음 항목에 영향을 주는 변경 시: +- **Gateway API 명세**: `elink-online-core-jpa` 모듈 확인 +- **공통 유틸리티**: `elink-portal-common` 모듈 확인 +- **암호화**: `kjb-safedb` 모듈 확인 +- **Admin 포털**: `../eapim-admin/` 관련 프로젝트 +- **Online 포털**: `../eapim-online/` 관련 프로젝트 + +## 추가 자료 + +- 사용자 가이드 (한글): `개발자포탈.md` +- 빌드 스크립트: `build-gf63.sh`, `deploy_portal.sh`, `deploy_portal2.sh` +- Docker: `Dockerfile`, `build_docker.sh` +- SQL 스크립트: `../kjb-eapim-sql/` diff --git a/README.md b/README.md new file mode 100644 index 0000000..64b2e56 --- /dev/null +++ b/README.md @@ -0,0 +1,142 @@ +# EAPIM Portal + +광주은행 엔터프라이즈 API 포털 관리 시스템 + +## 프로젝트 개요 + +**EAPIM Portal**은 광주은행의 API 서비스를 관리하고, 사용자 등록, API 키 발급, 문서화, 테스트 기능을 제공하는 웹 기반 플랫폼입니다. + +## 기술 스택 + +- Spring Boot 2.7.18 +- Java 8 +- Gradle 8.7 +- Oracle 19c +- Spring Security +- JPA/Hibernate 5.6.15 with Envers +- MapStruct, Lombok + +## 빌드 및 실행 + +```bash +# 개발 환경 실행 +gradle bootRun + +# WAR 빌드 +gradle bootWar + +# 테스트 +gradle test +``` + +**주의**: gradlew 사용 금지 - offline gradle 단독 실행 + +## 프로젝트 구조 + +``` +src/main/java/com/eactive/apim/portal/ +├── apps/ # 기능 모듈 (user, app, approval, apis, etc.) +├── common/ # 공통 관심사 +├── config/ # Spring 설정 +└── gateway/ # Gateway DB 접근 +``` + +## 외부 모듈 + +이 프로젝트는 다음 모듈에 의존합니다: +- `elink-online-core-jpa`: Gateway 데이터 모델 +- `elink-portal-common`: 공통 유틸리티 +- `kjb-safedb`: SafeDB 암호화 라이브러리 + +## AI 코딩 어시스턴트 지침 파일 + +이 프로젝트는 여러 AI 코딩 어시스턴트를 지원합니다. 각 도구는 다음 파일을 참조합니다: + +### 지침 파일 위치 + +| AI 도구 | 지침 파일 경로 | 용도 | +|---------|---------------|------| +| **Claude Code** | `CLAUDE.md` | 마스터 지침 파일 (한글) | +| **GitHub Copilot** | `.github/copilot-instructions.md` | Copilot용 지침 | +| **Cursor** | `.cursorrules` | Cursor용 간결한 규칙 | + +### 지침 파일 관리 방법 + +1. **마스터 파일**: `CLAUDE.md` + - 가장 상세한 프로젝트 지침을 포함 + - 한글로 작성되어 한국 개발팀이 이해하기 쉬움 + - 수정 시 이 파일을 먼저 업데이트 + +2. **동기화**: + ```bash + # CLAUDE.md 수정 후 다른 파일들 동기화 + cp CLAUDE.md .github/copilot-instructions.md + # .cursorrules는 간결한 버전이므로 필요시 수동 업데이트 + ``` + +3. **새로운 AI 도구 지원 추가**: + - Windsurf: `.windsurfrules` 파일 생성 + - 기타 도구: 해당 도구의 규칙 파일명 확인 후 생성 + +### 지침 파일에 포함된 내용 + +- 프로젝트 개요 및 기술 스택 +- 빌드 명령어 및 개발 환경 설정 +- 아키텍처 구조 (멀티 모듈, 이중 DB) +- 주요 기능 및 비즈니스 로직 +- 개발 패턴 및 코딩 컨벤션 +- 보안, 트랜잭션, 로깅 가이드 +- 테스트 패턴 +- 배포 방법 + +## 환경 설정 + +### 프로파일 + +- **dev**: 개발 환경 +- **stage**: 스테이징 환경 +- **prod**: 운영 환경 +- **gf63**: 개인 개발 환경 +- **kjb_rinjae**: 개인 개발 환경 + +### 데이터베이스 + +- **EMS Database**: Portal 사용자, 조직, API 키 +- **Gateway Database**: API 명세, 서비스 + +## 주요 기능 + +- 사용자 관리 (내부/기업) +- API 키 관리 및 승인 워크플로우 +- API 카탈로그 및 문서화 +- API 테스트 프록시 +- 감사 추적 (Hibernate Envers) + +## 배포 + +### 지원 서버 +- JEUS +- WebLogic +- Tomcat (내장) + +### Docker +```bash +./build_docker.sh +docker run -p 8080:8080 eapim-portal:latest +``` + +## 관련 프로젝트 + +- `../eapim-admin/` - Admin 포털 +- `../eapim-online/` - Online 포털 +- `../kjb-eapim-sql/` - SQL 스크립트 + +## 문서 + +- **프로젝트 상세 지침**: `CLAUDE.md` (한글) +- **사용자 가이드**: `개발자포탈.md` (한글) +- **빌드 스크립트**: `build-gf63.sh`, `deploy_portal.sh` + +## 라이선스 + +© 광주은행 (Kwangju Bank) diff --git a/src/main/java/com/eactive/apim/portal/apps/auth/service/AuthNumberServiceImpl.java b/src/main/java/com/eactive/apim/portal/apps/auth/service/AuthNumberServiceImpl.java index d873d08..99285ed 100644 --- a/src/main/java/com/eactive/apim/portal/apps/auth/service/AuthNumberServiceImpl.java +++ b/src/main/java/com/eactive/apim/portal/apps/auth/service/AuthNumberServiceImpl.java @@ -22,10 +22,10 @@ public class AuthNumberServiceImpl implements AuthNumberService { private final AuthNumberGenerator generator; private final MessageSender messageSender; - @Value("${auth-ttl:300}") + @Value("${portal.auth-ttl:300}") private int authNumberExpirationTime; - @Value("${auth.resend.limit.seconds:30}") + @Value("${portal.auth.resend_limit_seconds:30}") private int resendLimitSeconds; @Autowired diff --git a/src/main/java/com/eactive/apim/portal/apps/user/controller/AccountController.java b/src/main/java/com/eactive/apim/portal/apps/user/controller/AccountController.java index a57a97b..a3e49dc 100644 --- a/src/main/java/com/eactive/apim/portal/apps/user/controller/AccountController.java +++ b/src/main/java/com/eactive/apim/portal/apps/user/controller/AccountController.java @@ -46,6 +46,7 @@ public class AccountController { private final UserFacade userFacade; private final OrgRegisterFacade orgRegisterFacade; private final AgreementsFacade agreementsFacade; + private final com.eactive.apim.portal.apps.user.facade.AuthFacade authFacade; @PostMapping("/confirm_password") @@ -326,4 +327,115 @@ public class AccountController { return "redirect:/mypage"; } } + + @GetMapping("/mypage/verification-email") + public String showVerificationEmailPage(Model model) { + try { + // 현재 로그인한 사용자 정보 가져오기 + PortalAuthenticatedUser currentUser = SecurityUtil.getPortalAuthenticatedUser(); + + if (currentUser == null) { + return "redirect:/login"; + } + + // 사용자 이메일 주소를 모델에 추가 + model.addAttribute("email", currentUser.getUsername()); + model.addAttribute("userId", currentUser.getId()); + + return "apps/mypage/verificationEmail"; + } catch (Exception e) { + logger.error("이메일 인증 페이지 로드 실패", e); + return "redirect:/"; + } + } + + @PostMapping("/mypage/send-verification-code") + public ResponseEntity sendVerificationCode( + @org.springframework.web.bind.annotation.RequestBody java.util.Map request, + HttpSession session) { + try { + String email = request.get("email"); + + // 현재 로그인한 사용자의 이메일과 일치하는지 확인 + PortalAuthenticatedUser currentUser = SecurityUtil.getPortalAuthenticatedUser(); + if (currentUser == null || !currentUser.getUsername().equals(email)) { + ValidationResponse response = new ValidationResponse(); + response.setValid(false); + response.setMessage("유효하지 않은 요청입니다."); + return ResponseEntity.badRequest().body(response); + } + + // 이전 세션 데이터 정리 + session.removeAttribute("emailVerified"); + + // 이메일 인증 코드 발송 + ValidationResponse response = authFacade.requestAuth(email, "EMAIL"); + + if (response.isValid()) { + session.setAttribute("verificationEmail", email); + } + + return ResponseEntity.ok(response); + + } catch (Exception e) { + logger.error("이메일 인증 코드 발송 실패", e); + ValidationResponse response = new ValidationResponse(); + response.setValid(false); + response.setMessage("인증 코드 발송에 실패했습니다."); + return ResponseEntity.status(500).body(response); + } + } + + @PostMapping("/mypage/verify-email-code") + public ResponseEntity verifyEmailCode( + @org.springframework.web.bind.annotation.RequestBody java.util.Map request, + HttpSession session) { + try { + String email = request.get("email"); + String code = request.get("code"); + + // 현재 로그인한 사용자의 이메일과 일치하는지 확인 + PortalAuthenticatedUser currentUser = SecurityUtil.getPortalAuthenticatedUser(); + if (currentUser == null || !currentUser.getUsername().equals(email)) { + ValidationResponse response = new ValidationResponse(); + response.setValid(false); + response.setMessage("유효하지 않은 요청입니다."); + return ResponseEntity.badRequest().body(response); + } + + // 세션에 저장된 이메일과 일치하는지 확인 + String sessionEmail = (String) session.getAttribute("verificationEmail"); + if (sessionEmail == null || !sessionEmail.equals(email)) { + ValidationResponse response = new ValidationResponse(); + response.setValid(false); + response.setMessage("인증 요청된 이메일과 일치하지 않습니다."); + return ResponseEntity.badRequest().body(response); + } + + // 인증 코드 확인 + ValidationResponse response = authFacade.verifyAuthNumber(email, code); + + if (response.isValid()) { + session.setAttribute("emailVerified", true); + + // 사용자 상태를 ACTIVE로 변경 + userFacade.activateUserByEmail(email); + + // 이메일 인증 관련 세션 정보 제거 + session.removeAttribute("emailVerificationRequired"); + session.removeAttribute("redirectUrl"); + + logger.info("이메일 인증 완료: {}", email); + } + + return ResponseEntity.ok(response); + + } catch (Exception e) { + logger.error("이메일 인증 코드 확인 실패", e); + ValidationResponse response = new ValidationResponse(); + response.setValid(false); + response.setMessage("인증 코드 확인에 실패했습니다."); + return ResponseEntity.status(500).body(response); + } + } } diff --git a/src/main/java/com/eactive/apim/portal/apps/user/facade/UserFacade.java b/src/main/java/com/eactive/apim/portal/apps/user/facade/UserFacade.java index 9ad4952..47cedb2 100644 --- a/src/main/java/com/eactive/apim/portal/apps/user/facade/UserFacade.java +++ b/src/main/java/com/eactive/apim/portal/apps/user/facade/UserFacade.java @@ -15,4 +15,6 @@ public interface UserFacade { void updateCorporateManager(PortalUserDTO portalUserDTO); void withdrawUser(String userId); + + void activateUserByEmail(String email); } \ No newline at end of file diff --git a/src/main/java/com/eactive/apim/portal/apps/user/facade/UserFacadeImpl.java b/src/main/java/com/eactive/apim/portal/apps/user/facade/UserFacadeImpl.java index 33c8b38..66ce1bf 100644 --- a/src/main/java/com/eactive/apim/portal/apps/user/facade/UserFacadeImpl.java +++ b/src/main/java/com/eactive/apim/portal/apps/user/facade/UserFacadeImpl.java @@ -198,4 +198,23 @@ public class UserFacadeImpl implements UserFacade { throw new IllegalArgumentException("새 비밀번호와 확인 비밀번호가 일치하지 않습니다."); } } + + @Override + @Transactional + public void activateUserByEmail(String email) { + PortalUser user = portalUserService.findByEmailAddr(email); + + if (user == null) { + throw new IllegalArgumentException("사용자를 찾을 수 없습니다."); + } + + if (user.getUserStatus() == PortalUserEnums.UserStatus.ACTIVE) { + throw new IllegalArgumentException("이미 활성화된 계정입니다."); + } + + user.setUserStatus(PortalUserEnums.UserStatus.ACTIVE); + portalUserService.save(user); + + log.info("사용자 이메일 인증 완료 - 활성화: {}", email); + } } diff --git a/src/main/java/com/eactive/apim/portal/apps/user/service/PortalUserService.java b/src/main/java/com/eactive/apim/portal/apps/user/service/PortalUserService.java index ba95543..d8b17c5 100644 --- a/src/main/java/com/eactive/apim/portal/apps/user/service/PortalUserService.java +++ b/src/main/java/com/eactive/apim/portal/apps/user/service/PortalUserService.java @@ -49,6 +49,15 @@ public class PortalUserService { return portalUserRepository.findByLoginId(loginId); } + public PortalUser findByEmailAddr(String emailAddr) { + return portalUserRepository.findPortalUserByEmailAddr(emailAddr) + .orElseThrow(() -> new IllegalArgumentException("해당 이메일 주소의 사용자를 찾을 수 없습니다.")); + } + + public PortalUser save(PortalUser user) { + return portalUserRepository.save(user); + } + public boolean existsByLoginId(String loginId) { return portalUserRepository.existsByLoginId(loginId); } diff --git a/src/main/java/com/eactive/apim/portal/config/PortalAuthenticationManager.java b/src/main/java/com/eactive/apim/portal/config/PortalAuthenticationManager.java index d0b3747..b9828a0 100644 --- a/src/main/java/com/eactive/apim/portal/config/PortalAuthenticationManager.java +++ b/src/main/java/com/eactive/apim/portal/config/PortalAuthenticationManager.java @@ -67,9 +67,10 @@ public class PortalAuthenticationManager implements AuthenticationManager { throw new DisabledException("사용자 승인 대기중입니다. "); } - if (user.getUserStatus().equals(PortalUserEnums.UserStatus.READY)) { - throw new DisabledException("이메일 인증이 완료되지 않았습니다. 인증 메일을 확인해주세요."); - } + // 25.11.13 - 이메일 인증 로그인은 허용 +// if (user.getUserStatus().equals(PortalUserEnums.UserStatus.READY)) { +// throw new DisabledException("이메일 인증이 완료되지 않았습니다. 인증 메일을 확인해주세요."); +// } if (user.getUserStatus().equals(PortalUserEnums.UserStatus.ADMINBLOCK)) { throw new DisabledException("법인 관리자에 의해 비활성화된 계정입니다. 법인 관리자에게 문의하시기 바랍니다."); diff --git a/src/main/java/com/eactive/apim/portal/config/PortalAuthenticationSuccessHandler.java b/src/main/java/com/eactive/apim/portal/config/PortalAuthenticationSuccessHandler.java index df91d07..0806640 100644 --- a/src/main/java/com/eactive/apim/portal/config/PortalAuthenticationSuccessHandler.java +++ b/src/main/java/com/eactive/apim/portal/config/PortalAuthenticationSuccessHandler.java @@ -55,7 +55,11 @@ public class PortalAuthenticationSuccessHandler implements AuthenticationSuccess HttpSession session = request.getSession(); // 세션에 상태 저장 - if (isDormantAccount(user)) { + if (isEmailVerificationRequired(user)) { + session.setAttribute("success", "이메일 인증이 완료되지 않았습니다. 이메일을 확인하여 인증을 완료해주세요."); + session.setAttribute("emailVerificationRequired", true); + session.setAttribute("redirectUrl", contextPath + "/mypage/verification-email"); + } else if (isDormantAccount(user)) { session.setAttribute("success", "90일 이상 미접속하여 계정이 잠금 처리되었습니다. 본인인증 후 이용해주세요."); session.setAttribute("dormantAccount", true); session.setAttribute("dormantLoginId", username); @@ -114,4 +118,8 @@ public class PortalAuthenticationSuccessHandler implements AuthenticationSuccess return PortalUserEnums.UserStatus.DORMANT.equals(user.getUserStatus()); } + private boolean isEmailVerificationRequired(PortalUser user) { + return PortalUserEnums.UserStatus.READY.equals(user.getUserStatus()); + } + } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 928b7d6..1bced55 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -75,6 +75,8 @@ portal: log-path: /Log/App/eapim/ auth-ttl: 300 + auth: + resend_limit_seconds: 30 user-approval: true password-expiration-days: 90 diff --git a/src/main/resources/logback-gf63.xml b/src/main/resources/logback-gf63.xml index a0220d0..27f5050 100644 --- a/src/main/resources/logback-gf63.xml +++ b/src/main/resources/logback-gf63.xml @@ -40,6 +40,7 @@ + diff --git a/src/main/resources/templates/views/apps/login/login.html b/src/main/resources/templates/views/apps/login/login.html index b2eb5da..0061ef3 100644 --- a/src/main/resources/templates/views/apps/login/login.html +++ b/src/main/resources/templates/views/apps/login/login.html @@ -145,10 +145,10 @@ } if (document.loginForm.message && document.loginForm.message.value) { if (document.loginForm.message.value.includes("이메일 인증이 완료되지 않았습니다")) { - console.log("이메일 재발송 팝업 트리거"); - let loginId = $('#loginId').val(); - console.log("Retrieved loginId:", loginId); - customPopups.showEmailResendPopup(document.loginForm.message.value, loginId); + console.log("이메일 인증 페이지 이동"); + location.href = '/mypage/verification-email' + // console.log("Retrieved loginId:", loginId); + // customPopups.showEmailResendPopup(document.loginForm.message.value, loginId); } else { customPopups.showAlert(document.loginForm.message.value); } diff --git a/src/main/resources/templates/views/apps/main/index.html b/src/main/resources/templates/views/apps/main/index.html index c6e38d8..30036cd 100644 --- a/src/main/resources/templates/views/apps/main/index.html +++ b/src/main/resources/templates/views/apps/main/index.html @@ -248,6 +248,23 @@ customPopups.showAlert(successMsg); } + // 이메일 인증 체크 + const emailVerificationRequired = [[${session.emailVerificationRequired}]]; + if (emailVerificationRequired) { + const emailVerifyMsg = /*[[${session.success}]]*/ ''; + const redirectUrl = /*[[${session.redirectUrl}]]*/ ''; + + console.log('emailVerifyMsg:', emailVerifyMsg); + console.log('redirectUrl:', redirectUrl); + + customPopups.showAlert(emailVerifyMsg); + $('#customAlertOkButton').off('click.custom').on('click.custom', function () { + customPopups.hideAlert('#customAlert'); + window.location.href = redirectUrl; + }); + return; // 다른 체크 중단 + } + // 장기미사용 계정 체크 const dormantAccount = [[${session.dormantAccount}]]; if (dormantAccount) { @@ -428,7 +445,7 @@ } }); }); - +G const hashtagItems = document.querySelectorAll('.hashtag ul li a'); const searchBox = document.getElementById('search-box'); diff --git a/src/main/resources/templates/views/apps/mypage/verificationEmail.html b/src/main/resources/templates/views/apps/mypage/verificationEmail.html new file mode 100644 index 0000000..85cdf6f --- /dev/null +++ b/src/main/resources/templates/views/apps/mypage/verificationEmail.html @@ -0,0 +1,246 @@ + + + +
+
+ +
+

이메일 인증

+
+ +
+ +
+
+ + + + +
+

+ 이메일 주소 +

+
+
+ +
+
+

+ 위 이메일 주소로 인증코드가 발송됩니다. +

+
+ + + + + + + + +
+ 취소 +
+ +
+
+
+ + + + + + + + + + + + + + + +
+
+
+ + + + +