From 9e4ea798676df9ff19e0a91dbd7f784f61e62be3 Mon Sep 17 00:00:00 2001 From: htlee Date: Sat, 14 Feb 2026 17:28:33 +0900 Subject: [PATCH 1/2] =?UTF-8?q?chore:=20=ED=8C=80=20=EC=9B=8C=ED=81=AC?= =?UTF-8?q?=ED=94=8C=EB=A1=9C=EC=9A=B0=20v1.2.0=20=EB=8F=99=EA=B8=B0?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - hook 스크립트 추가 (on-commit, on-pre-compact, on-post-compact) - settings.json 갱신 - init-project, sync-team-workflow 스킬 업데이트 - commit-msg hook bash regex로 전환 (macOS 호환) - workflow-version.json 1.2.0 적용 Co-Authored-By: Claude Opus 4.6 --- .claude/scripts/on-commit.sh | 14 ++ .claude/scripts/on-post-compact.sh | 23 +++ .claude/scripts/on-pre-compact.sh | 8 + .claude/settings.json | 37 ++++ .claude/skills/init-project/SKILL.md | 186 +++++++++++++++++++-- .claude/skills/sync-team-workflow/SKILL.md | 35 +++- .claude/workflow-version.json | 2 +- .githooks/commit-msg | 2 +- 8 files changed, 285 insertions(+), 22 deletions(-) create mode 100755 .claude/scripts/on-commit.sh create mode 100755 .claude/scripts/on-post-compact.sh create mode 100755 .claude/scripts/on-pre-compact.sh diff --git a/.claude/scripts/on-commit.sh b/.claude/scripts/on-commit.sh new file mode 100755 index 0000000..f473403 --- /dev/null +++ b/.claude/scripts/on-commit.sh @@ -0,0 +1,14 @@ +#!/bin/bash +INPUT=$(cat) +COMMAND=$(echo "$INPUT" | python3 -c "import sys,json;print(json.load(sys.stdin).get('tool_input',{}).get('command',''))" 2>/dev/null || echo "") +if echo "$COMMAND" | grep -qE 'git commit'; then + cat </dev/null || echo "") +if [ -z "$CWD" ]; then + CWD=$(pwd) +fi +PROJECT_HASH=$(echo "$CWD" | sed 's|/|-|g') +MEMORY_DIR="$HOME/.claude/projects/$PROJECT_HASH/memory" +CONTEXT="" +if [ -f "$MEMORY_DIR/MEMORY.md" ]; then + SUMMARY=$(head -100 "$MEMORY_DIR/MEMORY.md" | python3 -c "import sys;print(sys.stdin.read().replace('\\\\','\\\\\\\\').replace('\"','\\\\\"').replace('\n','\\\\n'))" 2>/dev/null) + CONTEXT="컨텍스트가 압축되었습니다.\\n\\n[세션 요약]\\n${SUMMARY}" +fi +if [ -f "$MEMORY_DIR/project-snapshot.md" ]; then + SNAP=$(head -50 "$MEMORY_DIR/project-snapshot.md" | python3 -c "import sys;print(sys.stdin.read().replace('\\\\','\\\\\\\\').replace('\"','\\\\\"').replace('\n','\\\\n'))" 2>/dev/null) + CONTEXT="${CONTEXT}\\n\\n[프로젝트 최신 상태]\\n${SNAP}" +fi +if [ -n "$CONTEXT" ]; then + CONTEXT="${CONTEXT}\\n\\n위 내용을 참고하여 작업을 이어가세요. 상세 내용은 memory/ 디렉토리의 각 파일을 참조하세요." + echo "{\"hookSpecificOutput\":{\"additionalContext\":\"${CONTEXT}\"}}" +else + echo "{\"hookSpecificOutput\":{\"additionalContext\":\"컨텍스트가 압축되었습니다. memory 파일이 없으므로 사용자에게 이전 작업 내용을 확인하세요.\"}}" +fi diff --git a/.claude/scripts/on-pre-compact.sh b/.claude/scripts/on-pre-compact.sh new file mode 100755 index 0000000..3f52f09 --- /dev/null +++ b/.claude/scripts/on-pre-compact.sh @@ -0,0 +1,8 @@ +#!/bin/bash +# PreCompact hook: systemMessage만 지원 (hookSpecificOutput 사용 불가) +INPUT=$(cat) +cat < +# 타입별 파일: ${GITEA_URL}/gc/template-<타입>/raw/branch/develop/<파일경로> +# 예시: +curl -sf "${GITEA_URL}/gc/template-common/raw/branch/develop/.claude/rules/team-policy.md" +curl -sf "${GITEA_URL}/gc/template-react-ts/raw/branch/develop/.editorconfig" +``` -### 4. Git Hooks 설정 +### 3. .claude/ 디렉토리 구성 +이미 팀 표준 파일이 존재하면 건너뜀. 없는 경우 위의 URL 패턴으로 Gitea에서 다운로드: +- `.claude/settings.json` — 프로젝트 타입별 표준 권한 설정 + hooks 섹션 (4단계 참조) +- `.claude/rules/` — 팀 규칙 파일 (team-policy, git-workflow, code-style, naming, testing) +- `.claude/skills/` — 팀 스킬 (create-mr, fix-issue, sync-team-workflow, init-project) + +### 4. Hook 스크립트 생성 +`.claude/scripts/` 디렉토리를 생성하고 다음 스크립트 파일 생성 (chmod +x): + +- `.claude/scripts/on-pre-compact.sh`: + +```bash +#!/bin/bash +# PreCompact hook: systemMessage만 지원 (hookSpecificOutput 사용 불가) +INPUT=$(cat) +cat </dev/null || echo "") +if [ -z "$CWD" ]; then + CWD=$(pwd) +fi +PROJECT_HASH=$(echo "$CWD" | sed 's|/|-|g') +MEMORY_DIR="$HOME/.claude/projects/$PROJECT_HASH/memory" +CONTEXT="" +if [ -f "$MEMORY_DIR/MEMORY.md" ]; then + SUMMARY=$(head -100 "$MEMORY_DIR/MEMORY.md" | python3 -c "import sys;print(sys.stdin.read().replace('\\\\','\\\\\\\\').replace('\"','\\\\\"').replace('\n','\\\\n'))" 2>/dev/null) + CONTEXT="컨텍스트가 압축되었습니다.\\n\\n[세션 요약]\\n${SUMMARY}" +fi +if [ -f "$MEMORY_DIR/project-snapshot.md" ]; then + SNAP=$(head -50 "$MEMORY_DIR/project-snapshot.md" | python3 -c "import sys;print(sys.stdin.read().replace('\\\\','\\\\\\\\').replace('\"','\\\\\"').replace('\n','\\\\n'))" 2>/dev/null) + CONTEXT="${CONTEXT}\\n\\n[프로젝트 최신 상태]\\n${SNAP}" +fi +if [ -n "$CONTEXT" ]; then + CONTEXT="${CONTEXT}\\n\\n위 내용을 참고하여 작업을 이어가세요. 상세 내용은 memory/ 디렉토리의 각 파일을 참조하세요." + echo "{\"hookSpecificOutput\":{\"additionalContext\":\"${CONTEXT}\"}}" +else + echo "{\"hookSpecificOutput\":{\"additionalContext\":\"컨텍스트가 압축되었습니다. memory 파일이 없으므로 사용자에게 이전 작업 내용을 확인하세요.\"}}" +fi +``` + +- `.claude/scripts/on-commit.sh`: + +```bash +#!/bin/bash +INPUT=$(cat) +COMMAND=$(echo "$INPUT" | python3 -c "import sys,json;print(json.load(sys.stdin).get('tool_input',{}).get('command',''))" 2>/dev/null || echo "") +if echo "$COMMAND" | grep -qE 'git commit'; then + cat </memory/`) 다음 파일들을 생성: + +- `memory/MEMORY.md` — 프로젝트 분석 결과 기반 핵심 요약 (200줄 이내) + - 현재 상태, 프로젝트 개요, 기술 스택, 주요 패키지 구조, 상세 참조 링크 +- `memory/project-snapshot.md` — 디렉토리 구조, 패키지 구성, 주요 의존성, API 엔드포인트 +- `memory/project-history.md` — "초기 팀 워크플로우 구성" 항목으로 시작 +- `memory/api-types.md` — 주요 인터페이스/DTO/Entity 타입 요약 +- `memory/decisions.md` — 빈 템플릿 (# 의사결정 기록) +- `memory/debugging.md` — 빈 템플릿 (# 디버깅 경험 & 패턴) + +### 10. Lint 도구 확인 +- TypeScript: eslint, prettier 설치 여부 확인. 미설치 시 사용자에게 설치 제안 +- Java: checkstyle, spotless 등 설정 확인 +- CLAUDE.md에 lint 실행 명령어가 이미 기록되었는지 확인 + +### 11. workflow-version.json 생성 +Gitea API로 최신 팀 워크플로우 버전을 조회: +```bash +curl -sf --max-time 5 "https://gitea.gc-si.dev/gc/template-common/raw/branch/develop/workflow-version.json" +``` +조회 성공 시 해당 `version` 값 사용, 실패 시 "1.0.0" 기본값 사용. + +`.claude/workflow-version.json` 파일 생성: ```json { - "applied_global_version": "1.0.0", - "applied_date": "현재날짜", - "project_type": "감지된타입" + "applied_global_version": "<조회된 버전>", + "applied_date": "<현재날짜>", + "project_type": "<감지된타입>", + "gitea_url": "https://gitea.gc-si.dev" } ``` -### 8. 검증 및 요약 +### 12. 검증 및 요약 - 생성/수정된 파일 목록 출력 - `git config core.hooksPath` 확인 - 빌드 명령 실행 가능 확인 -- 다음 단계 안내 (개발 시작, 첫 커밋 방법 등) +- Hook 스크립트 실행 권한 확인 +- 다음 단계 안내: + - 개발 시작, 첫 커밋 방법 + - 범용 스킬: `/api-registry`, `/changelog`, `/swagger-spec` diff --git a/.claude/skills/sync-team-workflow/SKILL.md b/.claude/skills/sync-team-workflow/SKILL.md index 43dd367..930d04d 100644 --- a/.claude/skills/sync-team-workflow/SKILL.md +++ b/.claude/skills/sync-team-workflow/SKILL.md @@ -13,11 +13,11 @@ Gitea API로 template-common 리포의 workflow-version.json 조회: ```bash GITEA_URL=$(python3 -c "import json; print(json.load(open('.claude/workflow-version.json')).get('gitea_url', 'https://gitea.gc-si.dev'))" 2>/dev/null || echo "https://gitea.gc-si.dev") -curl -sf "${GITEA_URL}/api/v1/repos/gc/template-common/raw/workflow-version.json" +curl -sf "${GITEA_URL}/gc/template-common/raw/branch/develop/workflow-version.json" ``` ### 2. 버전 비교 -로컬 `.claude/workflow-version.json`과 비교: +로컬 `.claude/workflow-version.json`의 `applied_global_version` 필드와 비교: - 버전 일치 → "최신 버전입니다" 안내 후 종료 - 버전 불일치 → 미적용 변경 항목 추출하여 표시 @@ -26,8 +26,19 @@ curl -sf "${GITEA_URL}/api/v1/repos/gc/template-common/raw/workflow-version.json 1. `.claude/workflow-version.json`의 `project_type` 필드 확인 2. 없으면: `pom.xml` → java-maven, `build.gradle` → java-gradle, `package.json` → react-ts +### Gitea 파일 다운로드 URL 패턴 +⚠️ Gitea raw 파일은 반드시 **web raw URL**을 사용해야 합니다 (`/api/v1/` 경로 사용 불가): +```bash +GITEA_URL="${GITEA_URL:-https://gitea.gc-si.dev}" +# common 파일: ${GITEA_URL}/gc/template-common/raw/branch/develop/<파일경로> +# 타입별 파일: ${GITEA_URL}/gc/template-<타입>/raw/branch/develop/<파일경로> +# 예시: +curl -sf "${GITEA_URL}/gc/template-common/raw/branch/develop/.claude/rules/team-policy.md" +curl -sf "${GITEA_URL}/gc/template-react-ts/raw/branch/develop/.editorconfig" +``` + ### 4. 파일 다운로드 및 적용 -Gitea API로 해당 타입 + common 템플릿 파일 다운로드: +위의 URL 패턴으로 해당 타입 + common 템플릿 파일 다운로드: #### 4-1. 규칙 파일 (덮어쓰기) 팀 규칙은 로컬 수정 불가 — 항상 글로벌 최신으로 교체: @@ -42,13 +53,17 @@ Gitea API로 해당 타입 + common 템플릿 파일 다운로드: #### 4-2. settings.json (부분 갱신) - `deny` 목록: 글로벌 최신으로 교체 - `allow` 목록: 기존 사용자 커스텀 유지 + 글로벌 기본값 병합 -- `hooks`: 글로벌 최신으로 교체 +- `hooks`: init-project SKILL.md의 hooks JSON 블록을 참조하여 교체 (없으면 추가) + - SessionStart(compact) → on-post-compact.sh + - PreCompact → on-pre-compact.sh + - PostToolUse(Bash) → on-commit.sh #### 4-3. 스킬 파일 (덮어쓰기) ``` .claude/skills/create-mr/SKILL.md .claude/skills/fix-issue/SKILL.md .claude/skills/sync-team-workflow/SKILL.md +.claude/skills/init-project/SKILL.md ``` #### 4-4. Git Hooks (덮어쓰기 + 실행 권한) @@ -56,13 +71,23 @@ Gitea API로 해당 타입 + common 템플릿 파일 다운로드: chmod +x .githooks/* ``` +#### 4-5. Hook 스크립트 갱신 +init-project SKILL.md의 코드 블록에서 최신 스크립트를 추출하여 덮어쓰기: +``` +.claude/scripts/on-pre-compact.sh +.claude/scripts/on-post-compact.sh +.claude/scripts/on-commit.sh +``` +실행 권한 부여: `chmod +x .claude/scripts/*.sh` + ### 5. 로컬 버전 업데이트 `.claude/workflow-version.json` 갱신: ```json { "applied_global_version": "새버전", "applied_date": "오늘날짜", - "project_type": "감지된타입" + "project_type": "감지된타입", + "gitea_url": "https://gitea.gc-si.dev" } ``` diff --git a/.claude/workflow-version.json b/.claude/workflow-version.json index 5925a2d..1e2b661 100644 --- a/.claude/workflow-version.json +++ b/.claude/workflow-version.json @@ -1,5 +1,5 @@ { - "applied_global_version": "1.1.0", + "applied_global_version": "1.2.0", "applied_date": "2026-02-14", "project_type": "java-maven", "gitea_url": "https://gitea.gc-si.dev" diff --git a/.githooks/commit-msg b/.githooks/commit-msg index 93bb350..20260d9 100755 --- a/.githooks/commit-msg +++ b/.githooks/commit-msg @@ -26,7 +26,7 @@ PATTERN='^(feat|fix|docs|style|refactor|test|chore|ci|perf)(\([a-zA-Z0-9가-힣. FIRST_LINE=$(head -1 "$COMMIT_MSG_FILE") -if ! echo "$FIRST_LINE" | grep -qE "$PATTERN"; then +if ! [[ "$FIRST_LINE" =~ $PATTERN ]]; then echo "" echo "╔══════════════════════════════════════════════════════════════╗" echo "║ 커밋 메시지가 Conventional Commits 형식에 맞지 않습니다 ║" -- 2.45.2 From ef667db990028295906c7fa717924c4df537debf Mon Sep 17 00:00:00 2001 From: htlee Date: Sat, 14 Feb 2026 17:28:51 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(auth):=20JWT=20=EA=B8=B0=EB=B0=98=20Go?= =?UTF-8?q?ogle=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=9D=B8=EC=A6=9D=20API?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Entity: User, Role, RoleUrlPattern, UserStatus enum - Repository: UserRepository, RoleRepository (fetch join 쿼리) - Auth: GoogleTokenVerifier, JwtTokenProvider, JwtAuthenticationFilter - API: POST /api/auth/google, GET /api/auth/me, POST /api/auth/logout - DTO: AuthResponse, UserResponse, RoleResponse, GoogleLoginRequest - SecurityConfig: JWT 필터 등록, CORS 설정, 공개 엔드포인트 정의 - 초기 데이터: roles + role_url_patterns 시드 (data.sql) Co-Authored-By: Claude Opus 4.6 --- .../com/gcsc/guide/auth/AuthController.java | 96 ++++++++++++++++ .../gcsc/guide/auth/GoogleTokenVerifier.java | 57 ++++++++++ .../guide/auth/JwtAuthenticationFilter.java | 57 ++++++++++ .../com/gcsc/guide/auth/JwtTokenProvider.java | 66 +++++++++++ .../com/gcsc/guide/config/SecurityConfig.java | 41 ++++++- .../java/com/gcsc/guide/dto/AuthResponse.java | 7 ++ .../gcsc/guide/dto/GoogleLoginRequest.java | 8 ++ .../java/com/gcsc/guide/dto/RoleResponse.java | 27 +++++ .../java/com/gcsc/guide/dto/UserResponse.java | 37 +++++++ src/main/java/com/gcsc/guide/entity/Role.java | 47 ++++++++ .../com/gcsc/guide/entity/RoleUrlPattern.java | 38 +++++++ src/main/java/com/gcsc/guide/entity/User.java | 103 ++++++++++++++++++ .../com/gcsc/guide/entity/UserStatus.java | 8 ++ .../gcsc/guide/repository/RoleRepository.java | 19 ++++ .../gcsc/guide/repository/UserRepository.java | 24 ++++ src/main/resources/application.yml | 7 ++ src/main/resources/data.sql | 11 ++ 17 files changed, 651 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/gcsc/guide/auth/AuthController.java create mode 100644 src/main/java/com/gcsc/guide/auth/GoogleTokenVerifier.java create mode 100644 src/main/java/com/gcsc/guide/auth/JwtAuthenticationFilter.java create mode 100644 src/main/java/com/gcsc/guide/auth/JwtTokenProvider.java create mode 100644 src/main/java/com/gcsc/guide/dto/AuthResponse.java create mode 100644 src/main/java/com/gcsc/guide/dto/GoogleLoginRequest.java create mode 100644 src/main/java/com/gcsc/guide/dto/RoleResponse.java create mode 100644 src/main/java/com/gcsc/guide/dto/UserResponse.java create mode 100644 src/main/java/com/gcsc/guide/entity/Role.java create mode 100644 src/main/java/com/gcsc/guide/entity/RoleUrlPattern.java create mode 100644 src/main/java/com/gcsc/guide/entity/User.java create mode 100644 src/main/java/com/gcsc/guide/entity/UserStatus.java create mode 100644 src/main/java/com/gcsc/guide/repository/RoleRepository.java create mode 100644 src/main/java/com/gcsc/guide/repository/UserRepository.java create mode 100644 src/main/resources/data.sql diff --git a/src/main/java/com/gcsc/guide/auth/AuthController.java b/src/main/java/com/gcsc/guide/auth/AuthController.java new file mode 100644 index 0000000..174467d --- /dev/null +++ b/src/main/java/com/gcsc/guide/auth/AuthController.java @@ -0,0 +1,96 @@ +package com.gcsc.guide.auth; + +import com.gcsc.guide.dto.AuthResponse; +import com.gcsc.guide.dto.GoogleLoginRequest; +import com.gcsc.guide.dto.UserResponse; +import com.gcsc.guide.entity.User; +import com.gcsc.guide.repository.UserRepository; +import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; + +@Slf4j +@RestController +@RequestMapping("/api/auth") +@RequiredArgsConstructor +public class AuthController { + + private static final String AUTO_ADMIN_EMAIL = "htlee@gcsc.co.kr"; + + private final GoogleTokenVerifier googleTokenVerifier; + private final JwtTokenProvider jwtTokenProvider; + private final UserRepository userRepository; + + /** + * Google ID Token으로 로그인/회원가입 처리 후 JWT 발급 + */ + @PostMapping("/google") + public ResponseEntity googleLogin(@Valid @RequestBody GoogleLoginRequest request) { + GoogleIdToken.Payload payload = googleTokenVerifier.verify(request.idToken()); + if (payload == null) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "유효하지 않은 Google 토큰입니다"); + } + + String email = payload.getEmail(); + String name = (String) payload.get("name"); + String avatarUrl = (String) payload.get("picture"); + + userRepository.findByEmail(email) + .ifPresentOrElse( + existingUser -> { + existingUser.updateProfile(name, avatarUrl); + existingUser.updateLastLogin(); + userRepository.save(existingUser); + }, + () -> createNewUser(email, name, avatarUrl) + ); + + User userWithRoles = userRepository.findByEmailWithRoles(email) + .orElseThrow(); + + String token = jwtTokenProvider.generateToken( + userWithRoles.getId(), userWithRoles.getEmail(), userWithRoles.isAdmin()); + + return ResponseEntity.ok(new AuthResponse(token, UserResponse.from(userWithRoles))); + } + + /** + * 현재 인증된 사용자 정보 조회 + */ + @GetMapping("/me") + public ResponseEntity getCurrentUser(Authentication authentication) { + Long userId = (Long) authentication.getPrincipal(); + + User user = userRepository.findByIdWithRoles(userId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "사용자를 찾을 수 없습니다")); + + return ResponseEntity.ok(UserResponse.from(user)); + } + + /** + * 로그아웃 (Stateless JWT이므로 서버 측 처리 없음, 프론트에서 토큰 삭제) + */ + @PostMapping("/logout") + public ResponseEntity logout() { + return ResponseEntity.noContent().build(); + } + + private User createNewUser(String email, String name, String avatarUrl) { + User newUser = new User(email, name, avatarUrl); + + if (AUTO_ADMIN_EMAIL.equals(email)) { + newUser.activate(); + newUser.grantAdmin(); + log.info("관리자 자동 승인: {}", email); + } + + newUser.updateLastLogin(); + return userRepository.save(newUser); + } +} diff --git a/src/main/java/com/gcsc/guide/auth/GoogleTokenVerifier.java b/src/main/java/com/gcsc/guide/auth/GoogleTokenVerifier.java new file mode 100644 index 0000000..60dbd44 --- /dev/null +++ b/src/main/java/com/gcsc/guide/auth/GoogleTokenVerifier.java @@ -0,0 +1,57 @@ +package com.gcsc.guide.auth; + +import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken; +import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier; +import com.google.api.client.http.javanet.NetHttpTransport; +import com.google.api.client.json.gson.GsonFactory; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.util.Collections; + +@Slf4j +@Component +public class GoogleTokenVerifier { + + private final GoogleIdTokenVerifier verifier; + private final String allowedEmailDomain; + + public GoogleTokenVerifier( + @Value("${app.google.client-id}") String clientId, + @Value("${app.allowed-email-domain}") String allowedEmailDomain + ) { + this.verifier = new GoogleIdTokenVerifier.Builder( + new NetHttpTransport(), GsonFactory.getDefaultInstance()) + .setAudience(Collections.singletonList(clientId)) + .build(); + this.allowedEmailDomain = allowedEmailDomain; + } + + /** + * Google ID Token을 검증하고 페이로드를 반환한다. + * 검증 실패 또는 허용되지 않은 이메일 도메인이면 null을 반환한다. + */ + public GoogleIdToken.Payload verify(String idTokenString) { + try { + GoogleIdToken idToken = verifier.verify(idTokenString); + if (idToken == null) { + log.warn("Google ID Token 검증 실패: 유효하지 않은 토큰"); + return null; + } + + GoogleIdToken.Payload payload = idToken.getPayload(); + String email = payload.getEmail(); + + if (email == null || !email.endsWith("@" + allowedEmailDomain)) { + log.warn("허용되지 않은 이메일 도메인: {}", email); + return null; + } + + return payload; + } catch (Exception e) { + log.error("Google ID Token 검증 중 오류: {}", e.getMessage()); + return null; + } + } +} diff --git a/src/main/java/com/gcsc/guide/auth/JwtAuthenticationFilter.java b/src/main/java/com/gcsc/guide/auth/JwtAuthenticationFilter.java new file mode 100644 index 0000000..b3898fb --- /dev/null +++ b/src/main/java/com/gcsc/guide/auth/JwtAuthenticationFilter.java @@ -0,0 +1,57 @@ +package com.gcsc.guide.auth; + +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.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.List; + +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private static final String AUTHORIZATION_HEADER = "Authorization"; + private static final String BEARER_PREFIX = "Bearer "; + + private final JwtTokenProvider jwtTokenProvider; + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws ServletException, IOException { + + String token = extractToken(request); + + if (token != null && jwtTokenProvider.validateToken(token)) { + Long userId = jwtTokenProvider.getUserIdFromToken(token); + + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken(userId, token, List.of(new SimpleGrantedAuthority("ROLE_USER"))); + + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + filterChain.doFilter(request, response); + } + + private String extractToken(HttpServletRequest request) { + String bearerToken = request.getHeader(AUTHORIZATION_HEADER); + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) { + return bearerToken.substring(BEARER_PREFIX.length()); + } + return null; + } +} diff --git a/src/main/java/com/gcsc/guide/auth/JwtTokenProvider.java b/src/main/java/com/gcsc/guide/auth/JwtTokenProvider.java new file mode 100644 index 0000000..58a0127 --- /dev/null +++ b/src/main/java/com/gcsc/guide/auth/JwtTokenProvider.java @@ -0,0 +1,66 @@ +package com.gcsc.guide.auth; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.util.Date; + +@Slf4j +@Component +public class JwtTokenProvider { + + private final SecretKey secretKey; + private final long expirationMs; + + public JwtTokenProvider( + @Value("${app.jwt.secret}") String secret, + @Value("${app.jwt.expiration-ms}") long expirationMs + ) { + this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); + this.expirationMs = expirationMs; + } + + public String generateToken(Long userId, String email, boolean isAdmin) { + Date now = new Date(); + Date expiry = new Date(now.getTime() + expirationMs); + + return Jwts.builder() + .subject(userId.toString()) + .claim("email", email) + .claim("isAdmin", isAdmin) + .issuedAt(now) + .expiration(expiry) + .signWith(secretKey) + .compact(); + } + + public Long getUserIdFromToken(String token) { + Claims claims = parseToken(token); + return Long.parseLong(claims.getSubject()); + } + + public boolean validateToken(String token) { + try { + parseToken(token); + return true; + } catch (JwtException | IllegalArgumentException e) { + log.debug("JWT 토큰 검증 실패: {}", e.getMessage()); + return false; + } + } + + private Claims parseToken(String token) { + return Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(token) + .getPayload(); + } +} diff --git a/src/main/java/com/gcsc/guide/config/SecurityConfig.java b/src/main/java/com/gcsc/guide/config/SecurityConfig.java index 6fb7b2a..b1386a6 100644 --- a/src/main/java/com/gcsc/guide/config/SecurityConfig.java +++ b/src/main/java/com/gcsc/guide/config/SecurityConfig.java @@ -1,28 +1,65 @@ package com.gcsc.guide.config; +import com.gcsc.guide.auth.JwtAuthenticationFilter; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; 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.configuration.EnableWebSecurity; import org.springframework.security.config.http.SessionCreationPolicy; 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.List; @Configuration @EnableWebSecurity +@RequiredArgsConstructor public class SecurityConfig { + private final JwtAuthenticationFilter jwtAuthenticationFilter; + + @Value("${app.cors.allowed-origins:http://localhost:5173,https://guide.gc-si.dev}") + private List allowedOrigins; + @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http + .cors(cors -> cors.configurationSource(corsConfigurationSource())) .csrf(csrf -> csrf.disable()) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth - .requestMatchers("/api/auth/**", "/actuator/health", "/h2-console/**").permitAll() + .requestMatchers( + "/api/auth/**", + "/api/health", + "/actuator/health", + "/h2-console/**" + ).permitAll() + .requestMatchers("/api/admin/**").authenticated() .anyRequest().authenticated() ) - .headers(headers -> headers.frameOptions(frame -> frame.sameOrigin())); + .headers(headers -> headers.frameOptions(frame -> frame.sameOrigin())) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration config = new CorsConfiguration(); + config.setAllowedOrigins(allowedOrigins); + config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); + config.setAllowedHeaders(List.of("*")); + config.setAllowCredentials(true); + config.setMaxAge(3600L); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/api/**", config); + return source; + } } diff --git a/src/main/java/com/gcsc/guide/dto/AuthResponse.java b/src/main/java/com/gcsc/guide/dto/AuthResponse.java new file mode 100644 index 0000000..ecde172 --- /dev/null +++ b/src/main/java/com/gcsc/guide/dto/AuthResponse.java @@ -0,0 +1,7 @@ +package com.gcsc.guide.dto; + +public record AuthResponse( + String token, + UserResponse user +) { +} diff --git a/src/main/java/com/gcsc/guide/dto/GoogleLoginRequest.java b/src/main/java/com/gcsc/guide/dto/GoogleLoginRequest.java new file mode 100644 index 0000000..af0a48e --- /dev/null +++ b/src/main/java/com/gcsc/guide/dto/GoogleLoginRequest.java @@ -0,0 +1,8 @@ +package com.gcsc.guide.dto; + +import jakarta.validation.constraints.NotBlank; + +public record GoogleLoginRequest( + @NotBlank String idToken +) { +} diff --git a/src/main/java/com/gcsc/guide/dto/RoleResponse.java b/src/main/java/com/gcsc/guide/dto/RoleResponse.java new file mode 100644 index 0000000..ef8ede5 --- /dev/null +++ b/src/main/java/com/gcsc/guide/dto/RoleResponse.java @@ -0,0 +1,27 @@ +package com.gcsc.guide.dto; + +import com.gcsc.guide.entity.Role; +import com.gcsc.guide.entity.RoleUrlPattern; + +import java.util.List; + +public record RoleResponse( + Long id, + String name, + String description, + List urlPatterns +) { + + public static RoleResponse from(Role role) { + List patterns = role.getUrlPatterns().stream() + .map(RoleUrlPattern::getUrlPattern) + .toList(); + + return new RoleResponse( + role.getId(), + role.getName(), + role.getDescription(), + patterns + ); + } +} diff --git a/src/main/java/com/gcsc/guide/dto/UserResponse.java b/src/main/java/com/gcsc/guide/dto/UserResponse.java new file mode 100644 index 0000000..ec386f2 --- /dev/null +++ b/src/main/java/com/gcsc/guide/dto/UserResponse.java @@ -0,0 +1,37 @@ +package com.gcsc.guide.dto; + +import com.gcsc.guide.entity.User; + +import java.time.LocalDateTime; +import java.util.List; + +public record UserResponse( + Long id, + String email, + String name, + String avatarUrl, + String status, + boolean isAdmin, + List roles, + LocalDateTime createdAt, + LocalDateTime lastLoginAt +) { + + public static UserResponse from(User user) { + List roles = user.getRoles().stream() + .map(RoleResponse::from) + .toList(); + + return new UserResponse( + user.getId(), + user.getEmail(), + user.getName(), + user.getAvatarUrl(), + user.getStatus().name(), + user.isAdmin(), + roles, + user.getCreatedAt(), + user.getLastLoginAt() + ); + } +} diff --git a/src/main/java/com/gcsc/guide/entity/Role.java b/src/main/java/com/gcsc/guide/entity/Role.java new file mode 100644 index 0000000..37f92d9 --- /dev/null +++ b/src/main/java/com/gcsc/guide/entity/Role.java @@ -0,0 +1,47 @@ +package com.gcsc.guide.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Entity +@Table(name = "roles") +@Getter +@NoArgsConstructor +public class Role { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true, length = 50) + private String name; + + @Column(length = 255) + private String description; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @OneToMany(mappedBy = "role", fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true) + private List urlPatterns = new ArrayList<>(); + + public Role(String name, String description) { + this.name = name; + this.description = description; + } + + public void update(String name, String description) { + this.name = name; + this.description = description; + } + + @PrePersist + protected void onCreate() { + this.createdAt = LocalDateTime.now(); + } +} diff --git a/src/main/java/com/gcsc/guide/entity/RoleUrlPattern.java b/src/main/java/com/gcsc/guide/entity/RoleUrlPattern.java new file mode 100644 index 0000000..2d02d80 --- /dev/null +++ b/src/main/java/com/gcsc/guide/entity/RoleUrlPattern.java @@ -0,0 +1,38 @@ +package com.gcsc.guide.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "role_url_patterns") +@Getter +@NoArgsConstructor +public class RoleUrlPattern { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "role_id", nullable = false) + private Role role; + + @Column(name = "url_pattern", nullable = false, length = 255) + private String urlPattern; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + public RoleUrlPattern(Role role, String urlPattern) { + this.role = role; + this.urlPattern = urlPattern; + } + + @PrePersist + protected void onCreate() { + this.createdAt = LocalDateTime.now(); + } +} diff --git a/src/main/java/com/gcsc/guide/entity/User.java b/src/main/java/com/gcsc/guide/entity/User.java new file mode 100644 index 0000000..57ff158 --- /dev/null +++ b/src/main/java/com/gcsc/guide/entity/User.java @@ -0,0 +1,103 @@ +package com.gcsc.guide.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.Set; + +@Entity +@Table(name = "users") +@Getter +@NoArgsConstructor +public class User { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true) + private String email; + + @Column(nullable = false, length = 100) + private String name; + + @Column(name = "avatar_url", length = 500) + private String avatarUrl; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private UserStatus status = UserStatus.PENDING; + + @Column(name = "is_admin", nullable = false) + private boolean isAdmin = false; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + @Column(name = "last_login_at") + private LocalDateTime lastLoginAt; + + @ManyToMany(fetch = FetchType.LAZY) + @JoinTable( + name = "user_roles", + joinColumns = @JoinColumn(name = "user_id"), + inverseJoinColumns = @JoinColumn(name = "role_id") + ) + private Set roles = new HashSet<>(); + + public User(String email, String name, String avatarUrl) { + this.email = email; + this.name = name; + this.avatarUrl = avatarUrl; + } + + public void activate() { + this.status = UserStatus.ACTIVE; + } + + public void reject() { + this.status = UserStatus.REJECTED; + } + + public void disable() { + this.status = UserStatus.DISABLED; + } + + public void grantAdmin() { + this.isAdmin = true; + } + + public void revokeAdmin() { + this.isAdmin = false; + } + + public void updateLastLogin() { + this.lastLoginAt = LocalDateTime.now(); + } + + public void updateProfile(String name, String avatarUrl) { + this.name = name; + this.avatarUrl = avatarUrl; + } + + public void updateRoles(Set roles) { + this.roles = roles; + } + + @PrePersist + protected void onCreate() { + this.createdAt = LocalDateTime.now(); + this.updatedAt = LocalDateTime.now(); + } + + @PreUpdate + protected void onUpdate() { + this.updatedAt = LocalDateTime.now(); + } +} diff --git a/src/main/java/com/gcsc/guide/entity/UserStatus.java b/src/main/java/com/gcsc/guide/entity/UserStatus.java new file mode 100644 index 0000000..f2f1946 --- /dev/null +++ b/src/main/java/com/gcsc/guide/entity/UserStatus.java @@ -0,0 +1,8 @@ +package com.gcsc.guide.entity; + +public enum UserStatus { + PENDING, + ACTIVE, + REJECTED, + DISABLED +} diff --git a/src/main/java/com/gcsc/guide/repository/RoleRepository.java b/src/main/java/com/gcsc/guide/repository/RoleRepository.java new file mode 100644 index 0000000..c8e5409 --- /dev/null +++ b/src/main/java/com/gcsc/guide/repository/RoleRepository.java @@ -0,0 +1,19 @@ +package com.gcsc.guide.repository; + +import com.gcsc.guide.entity.Role; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import java.util.List; +import java.util.Optional; + +public interface RoleRepository extends JpaRepository { + + Optional findByName(String name); + + @Query("SELECT DISTINCT r FROM Role r LEFT JOIN FETCH r.urlPatterns") + List findAllWithUrlPatterns(); + + @Query("SELECT r FROM Role r LEFT JOIN FETCH r.urlPatterns WHERE r.id = :id") + Optional findByIdWithUrlPatterns(Long id); +} diff --git a/src/main/java/com/gcsc/guide/repository/UserRepository.java b/src/main/java/com/gcsc/guide/repository/UserRepository.java new file mode 100644 index 0000000..7f2dfbe --- /dev/null +++ b/src/main/java/com/gcsc/guide/repository/UserRepository.java @@ -0,0 +1,24 @@ +package com.gcsc.guide.repository; + +import com.gcsc.guide.entity.User; +import com.gcsc.guide.entity.UserStatus; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import java.util.List; +import java.util.Optional; + +public interface UserRepository extends JpaRepository { + + Optional findByEmail(String email); + + @Query("SELECT u FROM User u LEFT JOIN FETCH u.roles r LEFT JOIN FETCH r.urlPatterns WHERE u.id = :id") + Optional findByIdWithRoles(Long id); + + @Query("SELECT u FROM User u LEFT JOIN FETCH u.roles r LEFT JOIN FETCH r.urlPatterns WHERE u.email = :email") + Optional findByEmailWithRoles(String email); + + List findByStatus(UserStatus status); + + long countByStatus(UserStatus status); +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index bdaa7a8..b625f85 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -8,10 +8,15 @@ spring: jpa: open-in-view: false + defer-datasource-initialization: true properties: hibernate: format_sql: true + jackson: + serialization: + write-dates-as-timestamps: false + server: port: ${SERVER_PORT:8080} @@ -23,6 +28,8 @@ app: google: client-id: ${GOOGLE_CLIENT_ID:} allowed-email-domain: gcsc.co.kr + cors: + allowed-origins: ${CORS_ORIGINS:http://localhost:5173,https://guide.gc-si.dev} # Actuator management: diff --git a/src/main/resources/data.sql b/src/main/resources/data.sql new file mode 100644 index 0000000..475970f --- /dev/null +++ b/src/main/resources/data.sql @@ -0,0 +1,11 @@ +-- 초기 롤 시드 데이터 +INSERT INTO roles (name, description, created_at) VALUES + ('ADMIN', '전체 접근 권한 (관리자 페이지 포함)', NOW()), + ('DEVELOPER', '전체 개발 가이드 접근', NOW()), + ('FRONT_DEV', '프론트엔드 개발 가이드만', NOW()); + +-- 롤별 URL 패턴 +INSERT INTO role_url_patterns (role_id, url_pattern, created_at) VALUES + ((SELECT id FROM roles WHERE name = 'ADMIN'), '/**', NOW()), + ((SELECT id FROM roles WHERE name = 'DEVELOPER'), '/dev/**', NOW()), + ((SELECT id FROM roles WHERE name = 'FRONT_DEV'), '/dev/front/**', NOW()); -- 2.45.2