Merge pull request 'feat(auth): JWT 기반 Google 로그인 인증 API 구현' (#1) from feature/auth-api into develop
Reviewed-on: #1
This commit is contained in:
커밋
cc03aa14ff
14
.claude/scripts/on-commit.sh
Executable file
14
.claude/scripts/on-commit.sh
Executable file
@ -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 <<RESP
|
||||||
|
{
|
||||||
|
"hookSpecificOutput": {
|
||||||
|
"additionalContext": "커밋이 감지되었습니다. 다음을 수행하세요:\n1. docs/CHANGELOG.md에 변경 내역 추가\n2. memory/project-snapshot.md에서 변경된 부분 업데이트\n3. memory/project-history.md에 이번 변경사항 추가\n4. API 인터페이스 변경 시 memory/api-types.md 갱신\n5. 프로젝트에 lint 설정이 있다면 lint 결과를 확인하고 문제를 수정"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RESP
|
||||||
|
else
|
||||||
|
echo '{}'
|
||||||
|
fi
|
||||||
23
.claude/scripts/on-post-compact.sh
Executable file
23
.claude/scripts/on-post-compact.sh
Executable file
@ -0,0 +1,23 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
INPUT=$(cat)
|
||||||
|
CWD=$(echo "$INPUT" | python3 -c "import sys,json;print(json.load(sys.stdin).get('cwd',''))" 2>/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
|
||||||
8
.claude/scripts/on-pre-compact.sh
Executable file
8
.claude/scripts/on-pre-compact.sh
Executable file
@ -0,0 +1,8 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# PreCompact hook: systemMessage만 지원 (hookSpecificOutput 사용 불가)
|
||||||
|
INPUT=$(cat)
|
||||||
|
cat <<RESP
|
||||||
|
{
|
||||||
|
"systemMessage": "컨텍스트 압축이 시작됩니다. 반드시 다음을 수행하세요:\n\n1. memory/MEMORY.md - 핵심 작업 상태 갱신 (200줄 이내)\n2. memory/project-snapshot.md - 변경된 패키지/타입 정보 업데이트\n3. memory/project-history.md - 이번 세션 변경사항 추가\n4. memory/api-types.md - API 인터페이스 변경이 있었다면 갱신\n5. 미완료 작업이 있다면 TodoWrite에 남기고 memory에도 기록"
|
||||||
|
}
|
||||||
|
RESP
|
||||||
@ -43,5 +43,42 @@
|
|||||||
"Read(./**/application-local.yml)",
|
"Read(./**/application-local.yml)",
|
||||||
"Read(./**/application-local.properties)"
|
"Read(./**/application-local.properties)"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"hooks": {
|
||||||
|
"SessionStart": [
|
||||||
|
{
|
||||||
|
"matcher": "compact",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "bash .claude/scripts/on-post-compact.sh",
|
||||||
|
"timeout": 10
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"PreCompact": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "bash .claude/scripts/on-pre-compact.sh",
|
||||||
|
"timeout": 30
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"PostToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": "Bash",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "bash .claude/scripts/on-commit.sh",
|
||||||
|
"timeout": 15
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,22 +22,142 @@ $ARGUMENTS가 "auto"이거나 비어있으면 다음 순서로 감지:
|
|||||||
- 빌드 파일, 설정 파일, 디렉토리 구조 파악
|
- 빌드 파일, 설정 파일, 디렉토리 구조 파악
|
||||||
- 사용 중인 프레임워크, 라이브러리 감지
|
- 사용 중인 프레임워크, 라이브러리 감지
|
||||||
- 기존 `.claude/` 디렉토리 존재 여부 확인
|
- 기존 `.claude/` 디렉토리 존재 여부 확인
|
||||||
|
- eslint, prettier, checkstyle, spotless 등 lint 도구 설치 여부 확인
|
||||||
|
|
||||||
### 2. CLAUDE.md 생성
|
### 2. CLAUDE.md 생성
|
||||||
프로젝트 루트에 CLAUDE.md를 생성하고 다음 내용 포함:
|
프로젝트 루트에 CLAUDE.md를 생성하고 다음 내용 포함:
|
||||||
- 프로젝트 개요 (이름, 타입, 주요 기술 스택)
|
- 프로젝트 개요 (이름, 타입, 주요 기술 스택)
|
||||||
- 빌드/실행 명령어 (감지된 빌드 도구 기반)
|
- 빌드/실행 명령어 (감지된 빌드 도구 기반)
|
||||||
- 테스트 실행 명령어
|
- 테스트 실행 명령어
|
||||||
|
- lint 실행 명령어 (감지된 도구 기반)
|
||||||
- 프로젝트 디렉토리 구조 요약
|
- 프로젝트 디렉토리 구조 요약
|
||||||
- 팀 컨벤션 참조 (`.claude/rules/` 안내)
|
- 팀 컨벤션 참조 (`.claude/rules/` 안내)
|
||||||
|
|
||||||
### 3. .claude/ 디렉토리 구성
|
### Gitea 파일 다운로드 URL 패턴
|
||||||
이미 팀 표준 파일이 존재하면 건너뜀. 없는 경우:
|
⚠️ Gitea raw 파일은 반드시 **web raw URL**을 사용해야 합니다 (`/api/v1/` 경로 사용 불가):
|
||||||
- `.claude/settings.json` — 프로젝트 타입별 표준 권한 설정
|
```bash
|
||||||
- `.claude/rules/` — 팀 규칙 파일 (team-policy, git-workflow, code-style, naming, testing)
|
GITEA_URL="${GITEA_URL:-https://gitea.gc-si.dev}"
|
||||||
- `.claude/skills/` — 팀 스킬 (create-mr, fix-issue, sync-team-workflow)
|
# 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. 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 <<RESP
|
||||||
|
{
|
||||||
|
"systemMessage": "컨텍스트 압축이 시작됩니다. 반드시 다음을 수행하세요:\n\n1. memory/MEMORY.md - 핵심 작업 상태 갱신 (200줄 이내)\n2. memory/project-snapshot.md - 변경된 패키지/타입 정보 업데이트\n3. memory/project-history.md - 이번 세션 변경사항 추가\n4. memory/api-types.md - API 인터페이스 변경이 있었다면 갱신\n5. 미완료 작업이 있다면 TodoWrite에 남기고 memory에도 기록"
|
||||||
|
}
|
||||||
|
RESP
|
||||||
|
```
|
||||||
|
|
||||||
|
- `.claude/scripts/on-post-compact.sh`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
INPUT=$(cat)
|
||||||
|
CWD=$(echo "$INPUT" | python3 -c "import sys,json;print(json.load(sys.stdin).get('cwd',''))" 2>/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 <<RESP
|
||||||
|
{
|
||||||
|
"hookSpecificOutput": {
|
||||||
|
"additionalContext": "커밋이 감지되었습니다. 다음을 수행하세요:\n1. docs/CHANGELOG.md에 변경 내역 추가\n2. memory/project-snapshot.md에서 변경된 부분 업데이트\n3. memory/project-history.md에 이번 변경사항 추가\n4. API 인터페이스 변경 시 memory/api-types.md 갱신\n5. 프로젝트에 lint 설정이 있다면 lint 결과를 확인하고 문제를 수정"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RESP
|
||||||
|
else
|
||||||
|
echo '{}'
|
||||||
|
fi
|
||||||
|
```
|
||||||
|
|
||||||
|
`.claude/settings.json`에 hooks 섹션이 없으면 추가 (기존 settings.json의 내용에 병합):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"hooks": {
|
||||||
|
"SessionStart": [
|
||||||
|
{
|
||||||
|
"matcher": "compact",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "bash .claude/scripts/on-post-compact.sh",
|
||||||
|
"timeout": 10
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"PreCompact": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "bash .claude/scripts/on-pre-compact.sh",
|
||||||
|
"timeout": 30
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"PostToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": "Bash",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "bash .claude/scripts/on-commit.sh",
|
||||||
|
"timeout": 15
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Git Hooks 설정
|
||||||
```bash
|
```bash
|
||||||
git config core.hooksPath .githooks
|
git config core.hooksPath .githooks
|
||||||
```
|
```
|
||||||
@ -46,7 +166,7 @@ git config core.hooksPath .githooks
|
|||||||
chmod +x .githooks/*
|
chmod +x .githooks/*
|
||||||
```
|
```
|
||||||
|
|
||||||
### 5. 프로젝트 타입별 추가 설정
|
### 6. 프로젝트 타입별 추가 설정
|
||||||
|
|
||||||
#### java-maven
|
#### java-maven
|
||||||
- `.sdkmanrc` 생성 (java=17.0.18-amzn 또는 프로젝트에 맞는 버전)
|
- `.sdkmanrc` 생성 (java=17.0.18-amzn 또는 프로젝트에 맞는 버전)
|
||||||
@ -63,7 +183,7 @@ chmod +x .githooks/*
|
|||||||
- `.npmrc` Nexus 레지스트리 설정 확인
|
- `.npmrc` Nexus 레지스트리 설정 확인
|
||||||
- `npm install && npm run build` 성공 확인
|
- `npm install && npm run build` 성공 확인
|
||||||
|
|
||||||
### 6. .gitignore 확인
|
### 7. .gitignore 확인
|
||||||
다음 항목이 .gitignore에 포함되어 있는지 확인하고, 없으면 추가:
|
다음 항목이 .gitignore에 포함되어 있는지 확인하고, 없으면 추가:
|
||||||
```
|
```
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
@ -73,18 +193,54 @@ chmod +x .githooks/*
|
|||||||
*.local
|
*.local
|
||||||
```
|
```
|
||||||
|
|
||||||
### 7. workflow-version.json 생성
|
### 8. Git exclude 설정
|
||||||
`.claude/workflow-version.json` 파일을 생성하여 현재 글로벌 워크플로우 버전 기록:
|
`.git/info/exclude` 파일을 읽고, 기존 내용을 보존하면서 하단에 추가:
|
||||||
|
|
||||||
|
```gitignore
|
||||||
|
|
||||||
|
# Claude Code 워크플로우 (로컬 전용)
|
||||||
|
docs/CHANGELOG.md
|
||||||
|
*.tmp
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9. Memory 초기화
|
||||||
|
프로젝트 memory 디렉토리의 위치를 확인하고 (보통 `~/.claude/projects/<project-hash>/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
|
```json
|
||||||
{
|
{
|
||||||
"applied_global_version": "1.0.0",
|
"applied_global_version": "<조회된 버전>",
|
||||||
"applied_date": "현재날짜",
|
"applied_date": "<현재날짜>",
|
||||||
"project_type": "감지된타입"
|
"project_type": "<감지된타입>",
|
||||||
|
"gitea_url": "https://gitea.gc-si.dev"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 8. 검증 및 요약
|
### 12. 검증 및 요약
|
||||||
- 생성/수정된 파일 목록 출력
|
- 생성/수정된 파일 목록 출력
|
||||||
- `git config core.hooksPath` 확인
|
- `git config core.hooksPath` 확인
|
||||||
- 빌드 명령 실행 가능 확인
|
- 빌드 명령 실행 가능 확인
|
||||||
- 다음 단계 안내 (개발 시작, 첫 커밋 방법 등)
|
- Hook 스크립트 실행 권한 확인
|
||||||
|
- 다음 단계 안내:
|
||||||
|
- 개발 시작, 첫 커밋 방법
|
||||||
|
- 범용 스킬: `/api-registry`, `/changelog`, `/swagger-spec`
|
||||||
|
|||||||
@ -13,11 +13,11 @@ Gitea API로 template-common 리포의 workflow-version.json 조회:
|
|||||||
```bash
|
```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")
|
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. 버전 비교
|
### 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` 필드 확인
|
1. `.claude/workflow-version.json`의 `project_type` 필드 확인
|
||||||
2. 없으면: `pom.xml` → java-maven, `build.gradle` → java-gradle, `package.json` → react-ts
|
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. 파일 다운로드 및 적용
|
### 4. 파일 다운로드 및 적용
|
||||||
Gitea API로 해당 타입 + common 템플릿 파일 다운로드:
|
위의 URL 패턴으로 해당 타입 + common 템플릿 파일 다운로드:
|
||||||
|
|
||||||
#### 4-1. 규칙 파일 (덮어쓰기)
|
#### 4-1. 규칙 파일 (덮어쓰기)
|
||||||
팀 규칙은 로컬 수정 불가 — 항상 글로벌 최신으로 교체:
|
팀 규칙은 로컬 수정 불가 — 항상 글로벌 최신으로 교체:
|
||||||
@ -42,13 +53,17 @@ Gitea API로 해당 타입 + common 템플릿 파일 다운로드:
|
|||||||
#### 4-2. settings.json (부분 갱신)
|
#### 4-2. settings.json (부분 갱신)
|
||||||
- `deny` 목록: 글로벌 최신으로 교체
|
- `deny` 목록: 글로벌 최신으로 교체
|
||||||
- `allow` 목록: 기존 사용자 커스텀 유지 + 글로벌 기본값 병합
|
- `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. 스킬 파일 (덮어쓰기)
|
#### 4-3. 스킬 파일 (덮어쓰기)
|
||||||
```
|
```
|
||||||
.claude/skills/create-mr/SKILL.md
|
.claude/skills/create-mr/SKILL.md
|
||||||
.claude/skills/fix-issue/SKILL.md
|
.claude/skills/fix-issue/SKILL.md
|
||||||
.claude/skills/sync-team-workflow/SKILL.md
|
.claude/skills/sync-team-workflow/SKILL.md
|
||||||
|
.claude/skills/init-project/SKILL.md
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 4-4. Git Hooks (덮어쓰기 + 실행 권한)
|
#### 4-4. Git Hooks (덮어쓰기 + 실행 권한)
|
||||||
@ -56,13 +71,23 @@ Gitea API로 해당 타입 + common 템플릿 파일 다운로드:
|
|||||||
chmod +x .githooks/*
|
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. 로컬 버전 업데이트
|
### 5. 로컬 버전 업데이트
|
||||||
`.claude/workflow-version.json` 갱신:
|
`.claude/workflow-version.json` 갱신:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"applied_global_version": "새버전",
|
"applied_global_version": "새버전",
|
||||||
"applied_date": "오늘날짜",
|
"applied_date": "오늘날짜",
|
||||||
"project_type": "감지된타입"
|
"project_type": "감지된타입",
|
||||||
|
"gitea_url": "https://gitea.gc-si.dev"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"applied_global_version": "1.1.0",
|
"applied_global_version": "1.2.0",
|
||||||
"applied_date": "2026-02-14",
|
"applied_date": "2026-02-14",
|
||||||
"project_type": "java-maven",
|
"project_type": "java-maven",
|
||||||
"gitea_url": "https://gitea.gc-si.dev"
|
"gitea_url": "https://gitea.gc-si.dev"
|
||||||
|
|||||||
@ -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")
|
FIRST_LINE=$(head -1 "$COMMIT_MSG_FILE")
|
||||||
|
|
||||||
if ! echo "$FIRST_LINE" | grep -qE "$PATTERN"; then
|
if ! [[ "$FIRST_LINE" =~ $PATTERN ]]; then
|
||||||
echo ""
|
echo ""
|
||||||
echo "╔══════════════════════════════════════════════════════════════╗"
|
echo "╔══════════════════════════════════════════════════════════════╗"
|
||||||
echo "║ 커밋 메시지가 Conventional Commits 형식에 맞지 않습니다 ║"
|
echo "║ 커밋 메시지가 Conventional Commits 형식에 맞지 않습니다 ║"
|
||||||
|
|||||||
96
src/main/java/com/gcsc/guide/auth/AuthController.java
Normal file
96
src/main/java/com/gcsc/guide/auth/AuthController.java
Normal file
@ -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<AuthResponse> 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<UserResponse> 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<Void> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
57
src/main/java/com/gcsc/guide/auth/GoogleTokenVerifier.java
Normal file
57
src/main/java/com/gcsc/guide/auth/GoogleTokenVerifier.java
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
66
src/main/java/com/gcsc/guide/auth/JwtTokenProvider.java
Normal file
66
src/main/java/com/gcsc/guide/auth/JwtTokenProvider.java
Normal file
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,28 +1,65 @@
|
|||||||
package com.gcsc.guide.config;
|
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.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||||
import org.springframework.security.web.SecurityFilterChain;
|
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
|
@Configuration
|
||||||
@EnableWebSecurity
|
@EnableWebSecurity
|
||||||
|
@RequiredArgsConstructor
|
||||||
public class SecurityConfig {
|
public class SecurityConfig {
|
||||||
|
|
||||||
|
private final JwtAuthenticationFilter jwtAuthenticationFilter;
|
||||||
|
|
||||||
|
@Value("${app.cors.allowed-origins:http://localhost:5173,https://guide.gc-si.dev}")
|
||||||
|
private List<String> allowedOrigins;
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||||
http
|
http
|
||||||
|
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
|
||||||
.csrf(csrf -> csrf.disable())
|
.csrf(csrf -> csrf.disable())
|
||||||
.sessionManagement(session ->
|
.sessionManagement(session ->
|
||||||
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||||
.authorizeHttpRequests(auth -> auth
|
.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()
|
.anyRequest().authenticated()
|
||||||
)
|
)
|
||||||
.headers(headers -> headers.frameOptions(frame -> frame.sameOrigin()));
|
.headers(headers -> headers.frameOptions(frame -> frame.sameOrigin()))
|
||||||
|
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
|
||||||
|
|
||||||
return http.build();
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
7
src/main/java/com/gcsc/guide/dto/AuthResponse.java
Normal file
7
src/main/java/com/gcsc/guide/dto/AuthResponse.java
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
package com.gcsc.guide.dto;
|
||||||
|
|
||||||
|
public record AuthResponse(
|
||||||
|
String token,
|
||||||
|
UserResponse user
|
||||||
|
) {
|
||||||
|
}
|
||||||
8
src/main/java/com/gcsc/guide/dto/GoogleLoginRequest.java
Normal file
8
src/main/java/com/gcsc/guide/dto/GoogleLoginRequest.java
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
package com.gcsc.guide.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
|
||||||
|
public record GoogleLoginRequest(
|
||||||
|
@NotBlank String idToken
|
||||||
|
) {
|
||||||
|
}
|
||||||
27
src/main/java/com/gcsc/guide/dto/RoleResponse.java
Normal file
27
src/main/java/com/gcsc/guide/dto/RoleResponse.java
Normal file
@ -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<String> urlPatterns
|
||||||
|
) {
|
||||||
|
|
||||||
|
public static RoleResponse from(Role role) {
|
||||||
|
List<String> patterns = role.getUrlPatterns().stream()
|
||||||
|
.map(RoleUrlPattern::getUrlPattern)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
return new RoleResponse(
|
||||||
|
role.getId(),
|
||||||
|
role.getName(),
|
||||||
|
role.getDescription(),
|
||||||
|
patterns
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/main/java/com/gcsc/guide/dto/UserResponse.java
Normal file
37
src/main/java/com/gcsc/guide/dto/UserResponse.java
Normal file
@ -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<RoleResponse> roles,
|
||||||
|
LocalDateTime createdAt,
|
||||||
|
LocalDateTime lastLoginAt
|
||||||
|
) {
|
||||||
|
|
||||||
|
public static UserResponse from(User user) {
|
||||||
|
List<RoleResponse> 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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
47
src/main/java/com/gcsc/guide/entity/Role.java
Normal file
47
src/main/java/com/gcsc/guide/entity/Role.java
Normal file
@ -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<RoleUrlPattern> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
38
src/main/java/com/gcsc/guide/entity/RoleUrlPattern.java
Normal file
38
src/main/java/com/gcsc/guide/entity/RoleUrlPattern.java
Normal file
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
103
src/main/java/com/gcsc/guide/entity/User.java
Normal file
103
src/main/java/com/gcsc/guide/entity/User.java
Normal file
@ -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<Role> 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<Role> roles) {
|
||||||
|
this.roles = roles;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PrePersist
|
||||||
|
protected void onCreate() {
|
||||||
|
this.createdAt = LocalDateTime.now();
|
||||||
|
this.updatedAt = LocalDateTime.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreUpdate
|
||||||
|
protected void onUpdate() {
|
||||||
|
this.updatedAt = LocalDateTime.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
8
src/main/java/com/gcsc/guide/entity/UserStatus.java
Normal file
8
src/main/java/com/gcsc/guide/entity/UserStatus.java
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
package com.gcsc.guide.entity;
|
||||||
|
|
||||||
|
public enum UserStatus {
|
||||||
|
PENDING,
|
||||||
|
ACTIVE,
|
||||||
|
REJECTED,
|
||||||
|
DISABLED
|
||||||
|
}
|
||||||
19
src/main/java/com/gcsc/guide/repository/RoleRepository.java
Normal file
19
src/main/java/com/gcsc/guide/repository/RoleRepository.java
Normal file
@ -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<Role, Long> {
|
||||||
|
|
||||||
|
Optional<Role> findByName(String name);
|
||||||
|
|
||||||
|
@Query("SELECT DISTINCT r FROM Role r LEFT JOIN FETCH r.urlPatterns")
|
||||||
|
List<Role> findAllWithUrlPatterns();
|
||||||
|
|
||||||
|
@Query("SELECT r FROM Role r LEFT JOIN FETCH r.urlPatterns WHERE r.id = :id")
|
||||||
|
Optional<Role> findByIdWithUrlPatterns(Long id);
|
||||||
|
}
|
||||||
24
src/main/java/com/gcsc/guide/repository/UserRepository.java
Normal file
24
src/main/java/com/gcsc/guide/repository/UserRepository.java
Normal file
@ -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<User, Long> {
|
||||||
|
|
||||||
|
Optional<User> 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<User> 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<User> findByEmailWithRoles(String email);
|
||||||
|
|
||||||
|
List<User> findByStatus(UserStatus status);
|
||||||
|
|
||||||
|
long countByStatus(UserStatus status);
|
||||||
|
}
|
||||||
@ -8,10 +8,15 @@ spring:
|
|||||||
|
|
||||||
jpa:
|
jpa:
|
||||||
open-in-view: false
|
open-in-view: false
|
||||||
|
defer-datasource-initialization: true
|
||||||
properties:
|
properties:
|
||||||
hibernate:
|
hibernate:
|
||||||
format_sql: true
|
format_sql: true
|
||||||
|
|
||||||
|
jackson:
|
||||||
|
serialization:
|
||||||
|
write-dates-as-timestamps: false
|
||||||
|
|
||||||
server:
|
server:
|
||||||
port: ${SERVER_PORT:8080}
|
port: ${SERVER_PORT:8080}
|
||||||
|
|
||||||
@ -23,6 +28,8 @@ app:
|
|||||||
google:
|
google:
|
||||||
client-id: ${GOOGLE_CLIENT_ID:}
|
client-id: ${GOOGLE_CLIENT_ID:}
|
||||||
allowed-email-domain: gcsc.co.kr
|
allowed-email-domain: gcsc.co.kr
|
||||||
|
cors:
|
||||||
|
allowed-origins: ${CORS_ORIGINS:http://localhost:5173,https://guide.gc-si.dev}
|
||||||
|
|
||||||
# Actuator
|
# Actuator
|
||||||
management:
|
management:
|
||||||
|
|||||||
11
src/main/resources/data.sql
Normal file
11
src/main/resources/data.sql
Normal file
@ -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());
|
||||||
불러오는 중...
Reference in New Issue
Block a user