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.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/` 디렉토리 존재 여부 확인
|
||||
- eslint, prettier, checkstyle, spotless 등 lint 도구 설치 여부 확인
|
||||
|
||||
### 2. CLAUDE.md 생성
|
||||
프로젝트 루트에 CLAUDE.md를 생성하고 다음 내용 포함:
|
||||
- 프로젝트 개요 (이름, 타입, 주요 기술 스택)
|
||||
- 빌드/실행 명령어 (감지된 빌드 도구 기반)
|
||||
- 테스트 실행 명령어
|
||||
- lint 실행 명령어 (감지된 도구 기반)
|
||||
- 프로젝트 디렉토리 구조 요약
|
||||
- 팀 컨벤션 참조 (`.claude/rules/` 안내)
|
||||
|
||||
### 3. .claude/ 디렉토리 구성
|
||||
이미 팀 표준 파일이 존재하면 건너뜀. 없는 경우:
|
||||
- `.claude/settings.json` — 프로젝트 타입별 표준 권한 설정
|
||||
- `.claude/rules/` — 팀 규칙 파일 (team-policy, git-workflow, code-style, naming, testing)
|
||||
- `.claude/skills/` — 팀 스킬 (create-mr, fix-issue, sync-team-workflow)
|
||||
### 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. 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
|
||||
git config core.hooksPath .githooks
|
||||
```
|
||||
@ -46,7 +166,7 @@ git config core.hooksPath .githooks
|
||||
chmod +x .githooks/*
|
||||
```
|
||||
|
||||
### 5. 프로젝트 타입별 추가 설정
|
||||
### 6. 프로젝트 타입별 추가 설정
|
||||
|
||||
#### java-maven
|
||||
- `.sdkmanrc` 생성 (java=17.0.18-amzn 또는 프로젝트에 맞는 버전)
|
||||
@ -63,7 +183,7 @@ chmod +x .githooks/*
|
||||
- `.npmrc` Nexus 레지스트리 설정 확인
|
||||
- `npm install && npm run build` 성공 확인
|
||||
|
||||
### 6. .gitignore 확인
|
||||
### 7. .gitignore 확인
|
||||
다음 항목이 .gitignore에 포함되어 있는지 확인하고, 없으면 추가:
|
||||
```
|
||||
.claude/settings.local.json
|
||||
@ -73,18 +193,54 @@ chmod +x .githooks/*
|
||||
*.local
|
||||
```
|
||||
|
||||
### 7. workflow-version.json 생성
|
||||
`.claude/workflow-version.json` 파일을 생성하여 현재 글로벌 워크플로우 버전 기록:
|
||||
### 8. Git exclude 설정
|
||||
`.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
|
||||
{
|
||||
"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`
|
||||
|
||||
@ -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"
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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 형식에 맞지 않습니다 ║"
|
||||
|
||||
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;
|
||||
|
||||
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<String> 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;
|
||||
}
|
||||
}
|
||||
|
||||
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:
|
||||
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:
|
||||
|
||||
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