Google 계정 로그인 연동 추가
This commit is contained in:
@@ -11,6 +11,10 @@ DB_PORT=43081
|
||||
ADMIN_EMAIL=admin@example.com
|
||||
ADMIN_PASSWORD=replace-with-random-password
|
||||
MEMBER_SESSION_SECRET=replace-with-random-password
|
||||
# Google 로그인/계정 연동(optional)
|
||||
# GOOGLE_OAUTH_CLIENT_ID=
|
||||
# GOOGLE_OAUTH_CLIENT_SECRET=
|
||||
# GOOGLE_OAUTH_REDIRECT_URI=https://sori.studio/api/auth/google/callback
|
||||
# ANALYTICS_HASH_SECRET= ← 선택. 일별 방문자 해시용 비밀. 비우면 MEMBER_SESSION_SECRET을 대신 사용.
|
||||
# ANALYTICS_COLLECTED_SINCE=2026-06-01 ← 선택. 관리자 통계 집계 시작일(YYYY-MM-DD).
|
||||
|
||||
|
||||
14
db/migrations/071_users_google_oauth.sql
Normal file
14
db/migrations/071_users_google_oauth.sql
Normal file
@@ -0,0 +1,14 @@
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS google_sub TEXT,
|
||||
ADD COLUMN IF NOT EXISTS google_email TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS google_name TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS google_avatar_url TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS google_linked_at TIMESTAMPTZ;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS users_google_sub_unique_idx
|
||||
ON users (google_sub)
|
||||
WHERE google_sub IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS users_google_email_idx
|
||||
ON users (google_email)
|
||||
WHERE google_email <> '';
|
||||
@@ -1,5 +1,10 @@
|
||||
# 업데이트 요약
|
||||
|
||||
## v1.12.1
|
||||
|
||||
- Google 계정 로그인과 기존 계정 연결 흐름을 추가했다.
|
||||
- 관리자는 기존 관리자 계정에서 Google 계정을 연결한 경우에만 Google로 관리자 로그인할 수 있게 했다.
|
||||
|
||||
## v1.12.0
|
||||
|
||||
- v1.11 개발 구간의 글쓰기·미디어·가져오기 안정화와 내부 정리를 묶어 다음 minor 기준점으로 정리했다.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# 배포 가이드
|
||||
|
||||
> v1.11.0은 v1.10 개발 구간을 마감한 minor 릴리스다. 운영 배포 전 `068_csp_violation_aggregates.sql`, `069_media_catalog.sql`, `070_post_import_jobs.sql`을 순서대로 적용하고 미디어 카탈로그는 reconcile 완료 후 `MEDIA_CATALOG_MODE=database` 고정 순서를 지킨다. 게시물 Export·Import·일반 업로드는 디스크·임시 디렉터리 스트리밍을 사용하므로 NAS 앱 컨테이너의 임시 저장 공간과 `public/uploads/exports`, `public/uploads/_import-jobs` 쓰기 권한을 확인한다.
|
||||
> 로컬 기준 v1.12.0에서 `npm run lint`, `npm test`, `npm run build`, `npm audit` 검증을 통과했다.
|
||||
> v1.12.1은 Google 로그인/계정 연동을 추가한다. 운영 배포 전 `068_csp_violation_aggregates.sql`, `069_media_catalog.sql`, `070_post_import_jobs.sql`, `071_users_google_oauth.sql`을 순서대로 적용하고 미디어 카탈로그는 reconcile 완료 후 `MEDIA_CATALOG_MODE=database` 고정 순서를 지킨다. 게시물 Export·Import·일반 업로드는 디스크·임시 디렉터리 스트리밍을 사용하므로 NAS 앱 컨테이너의 임시 저장 공간과 `public/uploads/exports`, `public/uploads/_import-jobs` 쓰기 권한을 확인한다.
|
||||
> 로컬 기준 v1.12.1에서 `npm run lint`, `npm test`, `npm run build`, `npm audit` 검증을 통과했다.
|
||||
|
||||
## 빌드 유형
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
> `npm run dev`는 프로젝트 전용 실행 스크립트를 통해 개발 서버, Admin, Tailwind Viewer 링크만 요약 출력한다.
|
||||
|
||||
## v1.11.0 배포 전 검증
|
||||
## v1.12.1 배포 전 검증
|
||||
|
||||
Playwright API 테스트도 브라우저 바이너리는 필요하지 않지만 `verify:ci`에는 Chromium E2E가 포함된다. 최초 1회 의존성과 Chromium을 설치하고 Docker daemon이 실행 중인지 확인한다.
|
||||
|
||||
@@ -40,6 +40,8 @@ sh scripts/migrate-production-db.sh status
|
||||
|
||||
- `068_csp_violation_aggregates.sql`은 CSP 보고 집계 테이블을 만든다.
|
||||
- `069_media_catalog.sql`은 미디어 카탈로그·참조·상태 계약을 만든다. 최초 상태는 `ready=false`이므로 마이그레이션 직후 곧바로 `MEDIA_CATALOG_MODE=database`만 켠 채 운영하지 않는다. 반드시 reconcile을 먼저 끝낸다.
|
||||
- `070_post_import_jobs.sql`은 게시물 Import 작업/항목 상태 테이블을 만든다.
|
||||
- `071_users_google_oauth.sql`은 회원 Google 로그인·계정 연동용 식별자 컬럼과 고유 인덱스를 만든다.
|
||||
- 배포 직후(또는 `filesystem`) 상태에서 관리자 인증 후 `POST /admin/api/media/reconcile`을 1회 실행한다. 이 API는 파일을 삭제하거나 이동하지 않고 발견 파일 upsert, 참조 백필, 사라진 파일 `missing` 표시만 수행한다.
|
||||
- `media_catalog_state`의 `ready=true`, 파일시스템/카탈로그 `count`와 `bytes` 일치, `staging`·`error` 행 0을 확인한다.
|
||||
- 확인이 끝나면 운영 env를 `MEDIA_CATALOG_MODE=database`로 고정하고 재기동한다. 개발 환경도 동일하게 `database`로 고정한다. 준비 미완료·불일치·DB 조회 오류가 발생하면 애플리케이션은 자동으로 파일시스템 목록으로 fallback한다.
|
||||
@@ -169,6 +171,8 @@ cp .env.example .env.production
|
||||
# .env.production 파일에 운영 DB 연결 정보, 운영 전용 랜덤 비밀번호, APP_PORT=43080, DB_PORT=43081 입력
|
||||
# 운영 DB에 owner/admin이 없으면 /admin/login에서 ADMIN_EMAIL/ADMIN_PASSWORD로 최초 owner 계정이 생성됨
|
||||
# MEMBER_SESSION_SECRET은 ADMIN_PASSWORD와 다른 긴 난수 문자열로 반드시 입력
|
||||
# Google 로그인을 사용하려면 GOOGLE_OAUTH_CLIENT_ID, GOOGLE_OAUTH_CLIENT_SECRET,
|
||||
# GOOGLE_OAUTH_REDIRECT_URI=https://sori.studio/api/auth/google/callback 입력
|
||||
# AdSense 통계를 대시보드에 연동하려면 GOOGLE_ADSENSE_CLIENT_ID, GOOGLE_ADSENSE_CLIENT_SECRET,
|
||||
# GOOGLE_ADSENSE_REFRESH_TOKEN, GOOGLE_ADSENSE_ACCOUNT_NAME, GOOGLE_ADSENSE_CURRENCY_CODE를 추가로 입력
|
||||
# Docker 네트워크 대역이 NAS 기존 컨테이너와 겹치면 DOCKER_SUBNET을 다른 사설 대역으로 변경
|
||||
@@ -226,6 +230,23 @@ docker compose --env-file .env.production up -d
|
||||
|
||||
코드 변경 없이 `.env.production`만 수정했다면 `--build` 없이 `up -d`만으로 충분하다.
|
||||
|
||||
### Google 로그인 설정
|
||||
|
||||
Google 계정 로그인과 계정 연동을 사용하려면 Google Cloud Console의 OAuth 클라이언트에 아래 리다이렉트 URI를 등록한다.
|
||||
|
||||
- 로컬 개발: `http://127.0.0.1:43080/api/auth/google/callback`
|
||||
- 운영: `https://sori.studio/api/auth/google/callback`
|
||||
|
||||
운영 `.env.production`에는 아래 값을 추가한다. AdSense와 같은 OAuth 클라이언트를 써도 되지만, 장기적으로는 로그인용 클라이언트를 별도로 두는 편이 관리하기 쉽다.
|
||||
|
||||
```bash
|
||||
GOOGLE_OAUTH_CLIENT_ID=...
|
||||
GOOGLE_OAUTH_CLIENT_SECRET=...
|
||||
GOOGLE_OAUTH_REDIRECT_URI=https://sori.studio/api/auth/google/callback
|
||||
```
|
||||
|
||||
관리자 Google 로그인은 자동으로 열리지 않는다. 먼저 기존 관리자 이메일/비밀번호로 로그인한 뒤 `/settings`에서 Google 계정을 연결해야 하며, 연결된 `owner` 또는 `admin` 계정만 `/admin/login`의 Google 로그인을 사용할 수 있다.
|
||||
|
||||
### AdSense refresh token 발급
|
||||
|
||||
AdSense Management API를 관리자 대시보드에서 조회하려면 OAuth 클라이언트 ID/시크릿 외에 1회 발급한 refresh token이 필요하다.
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
## 2026-07-29 v1.12.1 — Google 로그인은 기존 계정 연결 방식으로 추가한다
|
||||
|
||||
Google 계정은 사이트 계정을 대체하지 않고 외부 식별자(`sub`)로 연결한다. 일반 회원은 Google 로그인 시 연결된 계정이 있으면 그 계정으로 로그인하고, 없으면 이메일 중복이 없을 때 사이트 전용 회원을 새로 만든다. 관리자는 보안상 자동 생성이나 이메일 매칭을 허용하지 않고, 기존 관리자 계정으로 먼저 로그인한 뒤 설정 화면에서 직접 Google 계정을 연결한 경우에만 관리자 Google 로그인을 허용한다.
|
||||
|
||||
## 2026-07-29 v1.12.0 — v1.11 내부 개선 구간을 마감한다
|
||||
|
||||
v1.11에서는 에디터·미디어·Import/Export·보안 의존성처럼 패치 단위 개선이 길게 이어졌다. 상세 작업 이력을 한 문서에 계속 누적하면 최신 상태 확인보다 과거 항목 검색 비용이 커지므로, `docs/update.md`는 v1.11 이후의 최신 이력 중심으로 유지하고 v1.10 이하 상세 기록은 `docs/update-archive.md`로 분리한다. 방문자에게 공유할 변화는 v1.11 개발 구간을 묶어 `docs/update-log/v1.12.md`로 별도 정리한다.
|
||||
|
||||
@@ -97,6 +97,7 @@
|
||||
| lib/analytics-traffic.js | referrer·User-Agent 기반 유입원·디바이스·검색 키워드 축약 분류 |
|
||||
| lib/admin-pagination.js | 관리자 번호형 페이지네이션 페이지 보정·ellipsis 항목 계산 |
|
||||
| lib/csp-report.js | CSP 보고 Content-Type·32KB/20건 제한 상수, payload 검증, 문서 경로·차단 origin·브라우저 계열 최소화 |
|
||||
| server/utils/google-oauth.js | Google 로그인·계정 연동 OAuth 설정, state 서명·검증, 토큰 교환, 내부 redirect 보정 |
|
||||
| lib/site-route-transition.js | 공개 라우트 전환 베일 사용 여부와 query/hash 이동 제외, 스크롤 복원·최상단 이동 helper |
|
||||
| lib/media-catalog.js | 업로드 URL·저장 키 정규화, 미디어 종류·원본/파생 역할 판별, 콘텐츠 미디어 참조 추출, PostgreSQL LIKE 리터럴 이스케이프 |
|
||||
| lib/social-links.js | 사이트 설정 SNS 링크 아이콘 프리셋·사용자 SVG 정리·URL 자동 보정·레거시 JSON 문자열 복구·공개 노출 목록 생성 |
|
||||
@@ -393,9 +394,9 @@
|
||||
| pages/tag/[slug].vue | 태그별 글 목록, 존재하지 않는 태그 404, 사이트 설정 태그 게시물 표시개수 기준 서버 태그 필터·공용 번호형 페이지네이션, 태그 헤더 이미지가 있으면 상단 이미지 헤더 표시, 없으면 기본 태그명·설명 + 공통 섹션 패딩을 쓰는 리스트형 게시물 카드, 카드 태그 배지와 오른쪽 아래 화살표 링크 |
|
||||
| pages/pages/[slug].vue | 고정 페이지 상세, HTML 문서 모드는 직접 진입 시 서버 미들웨어 원문 응답·클라이언트 내부 이동 시 현재 URL reload로 서버 원문 응답 재진입 |
|
||||
| pages/signup.vue | 회원가입 3단계, `emailOtpConfigured`일 때 이메일 OTP·인증번호 받기, 모바일 OTP 입력창 높이 보정, `POST /api/auth/email-otp/request` |
|
||||
| pages/signin.vue | 로그인, `/forgot-password` 링크 |
|
||||
| pages/signin.vue | 로그인, Google 로그인, `/forgot-password` 링크 |
|
||||
| pages/forgot-password.vue | 비밀번호 찾기(Resend 설정 시 OTP 발송·`POST /api/auth/password-reset/confirm`) |
|
||||
| pages/settings/index.vue | 회원 설정(상단 프로필 요약, 가입 정보, 댓글 참여도, 하단 프로필 입력·이전 로그인 활동, 썸네일 변경·제거, 닉네임 저장, 설정 메뉴 모달 비밀번호 변경·회원 탈퇴) |
|
||||
| pages/settings/index.vue | 회원 설정(상단 프로필 요약, 가입 정보, 댓글 참여도, 하단 프로필 입력·이전 로그인 활동, Google 계정 연결 상태·연결 버튼, 썸네일 변경·제거, 닉네임 저장, 설정 메뉴 모달 비밀번호 변경·회원 탈퇴) |
|
||||
|
||||
## 서버 API
|
||||
|
||||
@@ -415,6 +416,8 @@
|
||||
| server/routes/admin/api/posts/[id]/featured.put.js | 관리자 글 목록 추천 상태 전용 수정 API |
|
||||
| server/api/navigation.get.js | 공개 네비게이션 API |
|
||||
| server/api/auth/signup.post.js | 회원 가입 API(선택 `emailOtp`, Resend 설정 시 일반 가입에 필수) |
|
||||
| server/api/auth/google/start.get.js | Google 로그인·계정 연동 OAuth 시작 API |
|
||||
| server/api/auth/google/callback.get.js | Google OAuth callback, 회원/관리자 세션 발급 또는 기존 계정 연결 |
|
||||
| server/api/auth/bootstrap-status.get.js | 최초 관리자 여부 + `emailOtpConfigured` |
|
||||
| server/api/auth/email-otp/request.post.js | Resend로 OTP 발송(`signup` / `password_reset`), 발송 실패 챌린지 삭제 |
|
||||
| server/api/auth/password-reset/confirm.post.js | OTP 검증 후 비밀번호 재설정 |
|
||||
|
||||
@@ -212,6 +212,11 @@
|
||||
- 인증 화면 상태 메시지는 오류/안내를 분리해 `aria-live`로 노출한다.
|
||||
- 회원가입 1단계의 타이틀/설명은 `GET /api/site-settings`의 `title`, `description` 값을 우선 사용한다.
|
||||
- 회원 세션 쿠키 서명에는 `MEMBER_SESSION_SECRET`만 사용하며, 관리자 비밀번호를 fallback으로 쓰지 않는다.
|
||||
- Google 로그인은 `/api/auth/google/start`에서 시작하고 `/api/auth/google/callback`에서 처리한다. 서버는 `GOOGLE_OAUTH_CLIENT_ID`, `GOOGLE_OAUTH_CLIENT_SECRET`, 선택값 `GOOGLE_OAUTH_REDIRECT_URI`를 사용하며, 값이 없으면 기존 AdSense OAuth 클라이언트 값(`GOOGLE_ADSENSE_CLIENT_ID`, `GOOGLE_ADSENSE_CLIENT_SECRET`)을 fallback으로 사용할 수 있다.
|
||||
- Google OAuth state는 `MEMBER_SESSION_SECRET` 기반 HMAC 서명과 10분 nonce 쿠키로 검증한다. redirect는 `/`로 시작하는 내부 경로만 허용한다.
|
||||
- 일반 회원 Google 로그인은 `google_sub`로 기존 회원을 찾고, 없으면 이메일 중복이 없을 때 새 회원을 생성한다. 이미 같은 이메일로 가입된 계정이 있으면 기존 계정 로그인 후 Google 계정 연결을 요청한다.
|
||||
- 관리자 Google 로그인은 이미 `google_sub`가 연결된 `owner` 또는 `admin` 계정만 허용한다. 관리자 계정은 먼저 기존 비밀번호 로그인으로 들어간 뒤 회원 설정 화면에서 Google 계정을 연결해야 한다.
|
||||
- 회원 설정 화면은 Google 계정 연결 상태를 표시하고 `/api/auth/google/start?mode=link`로 현재 로그인 계정에 Google 식별자를 연결한다. 같은 Google 계정이 다른 회원에 이미 연결되어 있으면 연결하지 않는다.
|
||||
|
||||
### 레이아웃 파일
|
||||
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# 업데이트 이력
|
||||
|
||||
## v1.12.1
|
||||
|
||||
- Google 계정 로그인과 기존 계정 Google 연동 흐름 추가.
|
||||
- `users` 테이블에 Google OAuth 식별자·이메일·이름·프로필 이미지·연결 시각 컬럼을 추가하는 `071_users_google_oauth.sql` 마이그레이션 추가.
|
||||
- 일반 로그인 화면과 관리자 로그인 화면에 Google 로그인 버튼 추가.
|
||||
- 회원 설정 화면에 Google 계정 연결 상태와 연결 버튼 추가.
|
||||
- Google OAuth redirect 내부 경로 보정 테스트 추가.
|
||||
- Google OAuth 환경 변수와 redirect URI 등록 절차 문서화.
|
||||
- 로컬 개발 DB에 `071_users_google_oauth.sql` 마이그레이션 적용 확인.
|
||||
|
||||
## v1.12.0
|
||||
|
||||
- v1.11 내부 개선 구간을 마감하고 관리 버전을 `1.12.0`으로 상향.
|
||||
|
||||
@@ -66,6 +66,9 @@ export default defineNuxtConfig({
|
||||
resendApiKey: process.env.RESEND_API_KEY || '',
|
||||
resendFromEmail: process.env.RESEND_FROM_EMAIL || '',
|
||||
emailOtpPepper: process.env.EMAIL_OTP_PEPPER || '',
|
||||
googleOAuthClientId: process.env.GOOGLE_OAUTH_CLIENT_ID || process.env.GOOGLE_ADSENSE_CLIENT_ID || '',
|
||||
googleOAuthClientSecret: process.env.GOOGLE_OAUTH_CLIENT_SECRET || process.env.GOOGLE_ADSENSE_CLIENT_SECRET || '',
|
||||
googleOAuthRedirectUri: process.env.GOOGLE_OAUTH_REDIRECT_URI || '',
|
||||
uploadDir: process.env.UPLOAD_DIR || '/uploads',
|
||||
maxFileSize: Number(process.env.MAX_FILE_SIZE || 10485760),
|
||||
maxVideoFileSize: Number(process.env.MAX_VIDEO_FILE_SIZE || 209715200),
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.12.0",
|
||||
"version": "1.12.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sori.studio",
|
||||
"version": "1.12.0",
|
||||
"version": "1.12.1",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.12.0",
|
||||
"version": "1.12.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"imports": {
|
||||
|
||||
@@ -13,6 +13,7 @@ const errorMessage = ref('')
|
||||
const showPassword = ref(false)
|
||||
const emailInput = ref(null)
|
||||
const passwordInput = ref(null)
|
||||
const route = useRoute()
|
||||
|
||||
const { data: bootstrapStatus } = await useFetch('/api/auth/bootstrap-status', {
|
||||
default: () => ({
|
||||
@@ -27,6 +28,30 @@ const { data: bootstrapStatus } = await useFetch('/api/auth/bootstrap-status', {
|
||||
*/
|
||||
const canSubmitAdminLogin = computed(() => Boolean(form.email.trim()) && Boolean(form.password))
|
||||
|
||||
const googleStatusMessage = computed(() => {
|
||||
const status = String(route.query.google || '')
|
||||
if (status === 'admin-not-linked') {
|
||||
return '연결된 관리자 Google 계정을 찾을 수 없습니다. 먼저 관리자 계정에서 Google 계정을 연결해 주세요.'
|
||||
}
|
||||
if (status === 'email-unverified') {
|
||||
return 'Google 계정의 이메일 확인 상태를 확인할 수 없습니다.'
|
||||
}
|
||||
if (status === 'cancelled') {
|
||||
return 'Google 로그인이 취소되었습니다.'
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
/**
|
||||
* 관리자 Google 로그인으로 이동한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const startGoogleAdminLogin = () => {
|
||||
if (import.meta.client) {
|
||||
window.location.href = '/api/auth/google/start?mode=admin&redirect=/admin'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 브라우저/비밀번호 관리자 자동완성 값을 로그인 폼 상태에 반영한다.
|
||||
* @returns {void}
|
||||
@@ -158,6 +183,20 @@ onMounted(() => {
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-5 border-t border-[#1a212a] pt-5">
|
||||
<button
|
||||
class="admin-login__google-button flex h-10 w-full items-center justify-center gap-2 rounded-[8px] border border-[#1a212a] bg-white text-sm font-semibold text-[#15171a] transition hover:bg-[#f1f3f4]"
|
||||
type="button"
|
||||
@click="startGoogleAdminLogin"
|
||||
>
|
||||
<span class="text-lg font-bold text-[#4285f4]">G</span>
|
||||
Google로 로그인
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="googleStatusMessage" class="mt-4 text-right text-xs text-[#e5acb1]" aria-live="polite">
|
||||
{{ googleStatusMessage }}
|
||||
</p>
|
||||
|
||||
<NuxtLink class="mt-6 flex justify-end text-xs text-[#9ba3af] hover:opacity-80" to="/">
|
||||
사이트로 돌아가기
|
||||
</NuxtLink>
|
||||
|
||||
@@ -8,9 +8,11 @@ const removingAvatar = ref(false)
|
||||
const profileMessage = ref('')
|
||||
const passwordMessage = ref('')
|
||||
const deleteMessage = ref('')
|
||||
const googleMessage = ref('')
|
||||
const actionMenuOpen = ref(false)
|
||||
const passwordModalOpen = ref(false)
|
||||
const deleteModalOpen = ref(false)
|
||||
const route = useRoute()
|
||||
|
||||
const profileForm = reactive({
|
||||
email: '',
|
||||
@@ -24,6 +26,13 @@ const profileForm = reactive({
|
||||
commentCount: 0
|
||||
})
|
||||
|
||||
const googleProfile = reactive({
|
||||
email: '',
|
||||
name: '',
|
||||
avatarUrl: '',
|
||||
linkedAt: ''
|
||||
})
|
||||
|
||||
const avatarInputRef = ref(null)
|
||||
|
||||
const passwordForm = reactive({
|
||||
@@ -39,6 +48,10 @@ const deleteForm = reactive({
|
||||
const displayName = computed(() => profileForm.username || profileForm.email || '멤버')
|
||||
const avatarInitial = computed(() => String(displayName.value || '?').slice(0, 1).toUpperCase())
|
||||
const previousLoginText = computed(() => formatRelativeTime(profileForm.previousLastSeenAt, '이전 로그인 기록 없음'))
|
||||
const googleLinked = computed(() => Boolean(googleProfile.linkedAt))
|
||||
const googleLinkedText = computed(() => googleLinked.value
|
||||
? `${googleProfile.email || googleProfile.name || 'Google 계정'} 연결됨`
|
||||
: 'Google 계정이 연결되지 않았습니다.')
|
||||
|
||||
/**
|
||||
* 날짜 표시 형식을 변환한다.
|
||||
@@ -118,6 +131,10 @@ const loadProfile = async () => {
|
||||
profileForm.previousLastSeenAt = profile.previousLastSeenAt || ''
|
||||
profileForm.previousLastSeenIp = profile.previousLastSeenIp || ''
|
||||
profileForm.commentCount = Number(profile.commentCount || 0)
|
||||
googleProfile.email = profile.googleEmail || ''
|
||||
googleProfile.name = profile.googleName || ''
|
||||
googleProfile.avatarUrl = profile.googleAvatarUrl || ''
|
||||
googleProfile.linkedAt = profile.googleLinkedAt || ''
|
||||
} catch {
|
||||
await navigateTo('/signin')
|
||||
} finally {
|
||||
@@ -125,6 +142,34 @@ const loadProfile = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Google 계정 연결을 시작한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const startGoogleLink = () => {
|
||||
if (import.meta.client) {
|
||||
window.location.href = '/api/auth/google/start?mode=link&redirect=/settings'
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.query.google,
|
||||
(status) => {
|
||||
const value = String(status || '')
|
||||
if (value === 'linked') {
|
||||
googleMessage.value = 'Google 계정이 연결되었습니다.'
|
||||
loadProfile()
|
||||
} else if (value === 'already-linked') {
|
||||
googleMessage.value = '이미 다른 계정에 연결된 Google 계정입니다.'
|
||||
} else if (value === 'email-unverified') {
|
||||
googleMessage.value = 'Google 계정의 이메일 확인 상태를 확인할 수 없습니다.'
|
||||
} else if (value === 'session-mismatch') {
|
||||
googleMessage.value = '로그인 세션이 바뀌었습니다. 다시 로그인해 주세요.'
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
/**
|
||||
* 닉네임 중복 여부를 확인한다.
|
||||
* @returns {Promise<boolean>} 사용 가능 여부
|
||||
@@ -505,6 +550,26 @@ onMounted(loadProfile)
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-page__google">
|
||||
<h2 class="settings-page__section-title mb-4 text-xs font-semibold uppercase tracking-[0.04em]">Google 계정</h2>
|
||||
<div class="settings-page__google-card rounded-[12px] border border-[var(--site-line)] bg-[var(--site-bg)] p-5 sm:p-6">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-semibold">{{ googleLinkedText }}</p>
|
||||
<p class="mt-1 text-sm site-muted">
|
||||
연결하면 다음 로그인부터 Google 계정으로 로그인할 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
<button class="settings-page__google-link-button h-10 rounded-[8px] border border-[var(--site-line)] px-4 text-sm font-semibold transition hover:bg-[var(--site-panel)]" type="button" @click="startGoogleLink">
|
||||
{{ googleLinked ? '다시 연결' : 'Google 계정 연결' }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="googleMessage" class="mt-4 text-sm site-muted" aria-live="polite">
|
||||
{{ googleMessage }}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ const isSubmitting = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const statusMessage = ref('')
|
||||
const showPassword = ref(false)
|
||||
const route = useRoute()
|
||||
|
||||
const form = reactive({
|
||||
email: '',
|
||||
@@ -19,6 +20,33 @@ const form = reactive({
|
||||
*/
|
||||
const canSubmitSignIn = computed(() => Boolean(form.email.trim()) && Boolean(form.password))
|
||||
|
||||
const googleStatusMessage = computed(() => {
|
||||
const status = String(route.query.google || '')
|
||||
if (status === 'email-exists') {
|
||||
return '이미 가입된 이메일입니다. 기존 계정으로 로그인한 뒤 Google 계정을 연결해 주세요.'
|
||||
}
|
||||
if (status === 'email-unverified') {
|
||||
return 'Google 계정의 이메일 확인 상태를 확인할 수 없습니다.'
|
||||
}
|
||||
if (status === 'session-mismatch') {
|
||||
return '로그인 세션이 바뀌었습니다. 다시 로그인해 주세요.'
|
||||
}
|
||||
if (status === 'cancelled') {
|
||||
return 'Google 로그인이 취소되었습니다.'
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
/**
|
||||
* Google 로그인으로 이동한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const startGoogleSignIn = () => {
|
||||
if (import.meta.client) {
|
||||
window.location.href = '/api/auth/google/start?mode=member&redirect=/'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그인 입력값을 검증한다.
|
||||
* @returns {boolean} 검증 통과 여부
|
||||
@@ -113,9 +141,23 @@ const submitSignIn = async () => {
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-5 border-t border-[#1a212a] pt-5">
|
||||
<button
|
||||
class="auth-signin__google-button flex h-10 w-full items-center justify-center gap-2 rounded-[8px] border border-[#1a212a] bg-white text-sm font-semibold text-[#15171a] transition hover:bg-[#f1f3f4]"
|
||||
type="button"
|
||||
@click="startGoogleSignIn"
|
||||
>
|
||||
<span class="text-lg font-bold text-[#4285f4]">G</span>
|
||||
Google로 로그인
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="errorMessage" class="mt-4 text-xs text-[#e5acb1]" aria-live="polite">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
<p v-if="googleStatusMessage" class="mt-4 text-xs text-[#e5acb1]" aria-live="polite">
|
||||
{{ googleStatusMessage }}
|
||||
</p>
|
||||
<p v-if="statusMessage" class="mt-4 text-xs text-[#9fc4ff]" aria-live="polite">
|
||||
{{ statusMessage }}
|
||||
</p>
|
||||
|
||||
179
server/api/auth/google/callback.get.js
Normal file
179
server/api/auth/google/callback.get.js
Normal file
@@ -0,0 +1,179 @@
|
||||
import bcrypt from 'bcrypt'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { createError, getQuery, sendRedirect } from 'h3'
|
||||
import {
|
||||
createGoogleUser,
|
||||
getUserByEmail,
|
||||
getUserByGoogleSub,
|
||||
isPrivilegedMemberSession,
|
||||
linkGoogleIdentityToUser,
|
||||
touchUserActivity
|
||||
} from '../../../repositories/member-repository'
|
||||
import { clearAdminSession, setAdminSession } from '../../../utils/admin-auth'
|
||||
import { clearMemberSession, requireMemberSession, setMemberSession } from '../../../utils/member-auth'
|
||||
import { getClientIp } from '../../../utils/request-ip'
|
||||
import {
|
||||
exchangeGoogleOAuthCode,
|
||||
normalizeGoogleOAuthRedirect,
|
||||
verifyGoogleOAuthState
|
||||
} from '../../../utils/google-oauth'
|
||||
|
||||
/**
|
||||
* Google 콜백 redirect 경로에 상태 query를 붙인다.
|
||||
* @param {string} redirectTo - redirect 경로
|
||||
* @param {string} status - 상태 코드
|
||||
* @returns {string} redirect URL
|
||||
*/
|
||||
const appendGoogleStatus = (redirectTo, status) => {
|
||||
const url = new URL(normalizeGoogleOAuthRedirect(redirectTo, '/'), 'http://local')
|
||||
url.searchParams.set('google', status)
|
||||
return `${url.pathname}${url.search}${url.hash}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Google 이름에서 사이트 닉네임을 만든다.
|
||||
* @param {{ name: string, email: string }} profile - Google 프로필
|
||||
* @returns {string} 닉네임
|
||||
*/
|
||||
const createGoogleUsername = (profile) => {
|
||||
const localPart = String(profile.email || '').split('@')[0] || 'google'
|
||||
return String(profile.name || localPart)
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, 60) || localPart
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원 세션을 발급하고 활동을 기록한다.
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @param {Object} user - 회원 정보
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const signInMember = async (event, user) => {
|
||||
setMemberSession(event, {
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
sessionVersion: user.sessionVersion
|
||||
})
|
||||
await touchUserActivity({
|
||||
userId: user.id,
|
||||
ip: getClientIp(event)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 세션을 발급하고 활동을 기록한다.
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @param {Object} user - 관리자 회원 정보
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const signInAdmin = async (event, user) => {
|
||||
setAdminSession(event, {
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
sessionVersion: user.sessionVersion
|
||||
})
|
||||
await signInMember(event, user)
|
||||
}
|
||||
|
||||
/**
|
||||
* Google OAuth 콜백 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<void>} 완료 후 화면 리다이렉트
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const query = getQuery(event)
|
||||
const code = String(query.code || '')
|
||||
const stateValue = String(query.state || '')
|
||||
|
||||
if (query.error) {
|
||||
let cancelledRedirect = '/signin'
|
||||
if (stateValue) {
|
||||
try {
|
||||
const cancelledState = verifyGoogleOAuthState(event, stateValue)
|
||||
cancelledRedirect = cancelledState.mode === 'admin' ? '/admin/login' : cancelledState.redirectTo
|
||||
} catch {
|
||||
cancelledRedirect = '/signin'
|
||||
}
|
||||
}
|
||||
return sendRedirect(event, appendGoogleStatus(cancelledRedirect, 'cancelled'), 302)
|
||||
}
|
||||
|
||||
if (!code || !stateValue) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: 'Google 로그인 응답이 올바르지 않습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const state = verifyGoogleOAuthState(event, stateValue)
|
||||
const googleProfile = await exchangeGoogleOAuthCode(event, code)
|
||||
|
||||
if (!googleProfile.sub || !googleProfile.email || !googleProfile.emailVerified) {
|
||||
return sendRedirect(event, appendGoogleStatus(state.redirectTo, 'email-unverified'), 302)
|
||||
}
|
||||
|
||||
const linkedUser = await getUserByGoogleSub(googleProfile.sub)
|
||||
|
||||
if (state.mode === 'link') {
|
||||
const session = await requireMemberSession(event)
|
||||
if (state.userId && String(state.userId) !== String(session.userId)) {
|
||||
clearMemberSession(event)
|
||||
clearAdminSession(event)
|
||||
return sendRedirect(event, appendGoogleStatus('/signin', 'session-mismatch'), 302)
|
||||
}
|
||||
|
||||
if (linkedUser && String(linkedUser.id) !== String(session.userId)) {
|
||||
return sendRedirect(event, appendGoogleStatus(state.redirectTo, 'already-linked'), 302)
|
||||
}
|
||||
|
||||
await linkGoogleIdentityToUser({
|
||||
userId: session.userId,
|
||||
googleSub: googleProfile.sub,
|
||||
googleEmail: googleProfile.email,
|
||||
googleName: googleProfile.name,
|
||||
googleAvatarUrl: googleProfile.picture
|
||||
})
|
||||
|
||||
return sendRedirect(event, appendGoogleStatus(state.redirectTo, 'linked'), 302)
|
||||
}
|
||||
|
||||
if (state.mode === 'admin') {
|
||||
if (!linkedUser || !(await isPrivilegedMemberSession(linkedUser.id, linkedUser.sessionVersion))) {
|
||||
return sendRedirect(event, appendGoogleStatus('/admin/login', 'admin-not-linked'), 302)
|
||||
}
|
||||
|
||||
await signInAdmin(event, linkedUser)
|
||||
return sendRedirect(event, appendGoogleStatus(state.redirectTo, 'signed-in'), 302)
|
||||
}
|
||||
|
||||
if (linkedUser) {
|
||||
await signInMember(event, linkedUser)
|
||||
return sendRedirect(event, appendGoogleStatus(state.redirectTo, 'signed-in'), 302)
|
||||
}
|
||||
|
||||
const existingEmailUser = await getUserByEmail(googleProfile.email)
|
||||
if (existingEmailUser) {
|
||||
return sendRedirect(event, appendGoogleStatus('/signin', 'email-exists'), 302)
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(randomBytes(32).toString('base64url'), 12)
|
||||
const created = await createGoogleUser({
|
||||
username: createGoogleUsername(googleProfile),
|
||||
email: googleProfile.email,
|
||||
passwordHash,
|
||||
avatarUrl: googleProfile.picture,
|
||||
googleSub: googleProfile.sub,
|
||||
googleEmail: googleProfile.email,
|
||||
googleName: googleProfile.name,
|
||||
googleAvatarUrl: googleProfile.picture
|
||||
})
|
||||
|
||||
if (created.isAdmin) {
|
||||
await signInAdmin(event, created)
|
||||
} else {
|
||||
await signInMember(event, created)
|
||||
}
|
||||
|
||||
return sendRedirect(event, appendGoogleStatus(state.redirectTo, 'signed-in'), 302)
|
||||
})
|
||||
43
server/api/auth/google/start.get.js
Normal file
43
server/api/auth/google/start.get.js
Normal file
@@ -0,0 +1,43 @@
|
||||
import { createError, getQuery, sendRedirect } from 'h3'
|
||||
import {
|
||||
createGoogleAuthorizationUrl,
|
||||
createGoogleOAuthState,
|
||||
normalizeGoogleOAuthRedirect
|
||||
} from '../../../utils/google-oauth'
|
||||
import { requireMemberSession } from '../../../utils/member-auth'
|
||||
|
||||
/**
|
||||
* Google OAuth 시작 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<void>} Google 인증 화면 리다이렉트
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const query = getQuery(event)
|
||||
const mode = String(query.mode || 'member')
|
||||
|
||||
if (!['member', 'admin', 'link'].includes(mode)) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: 'Google 로그인 모드가 올바르지 않습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const redirectTo = normalizeGoogleOAuthRedirect(
|
||||
query.redirect,
|
||||
mode === 'admin' ? '/admin' : mode === 'link' ? '/settings' : '/'
|
||||
)
|
||||
let userId = ''
|
||||
|
||||
if (mode === 'link') {
|
||||
const session = await requireMemberSession(event)
|
||||
userId = session.userId
|
||||
}
|
||||
|
||||
const state = createGoogleOAuthState(event, {
|
||||
mode,
|
||||
redirectTo,
|
||||
userId
|
||||
})
|
||||
|
||||
return sendRedirect(event, createGoogleAuthorizationUrl(event, state), 302)
|
||||
})
|
||||
@@ -5,7 +5,7 @@ import { createError } from 'h3'
|
||||
/**
|
||||
* 회원 프로필 조회 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<{ id: string, email: string, username: string, avatarUrl: string, createdAt: string, updatedAt: string, lastSeenAt: string | null, previousLastSeenAt: string | null, previousLastSeenIp: string, commentCount: number }>} 회원 프로필
|
||||
* @returns {Promise<{ id: string, email: string, username: string, avatarUrl: string, googleEmail: string, googleName: string, googleAvatarUrl: string, googleLinkedAt: string | null, createdAt: string, updatedAt: string, lastSeenAt: string | null, previousLastSeenAt: string | null, previousLastSeenIp: string, commentCount: number }>} 회원 프로필
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const session = await requireMemberSession(event)
|
||||
@@ -23,6 +23,10 @@ export default defineEventHandler(async (event) => {
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
avatarUrl: user.avatarUrl || '',
|
||||
googleEmail: user.googleEmail || '',
|
||||
googleName: user.googleName || '',
|
||||
googleAvatarUrl: user.googleAvatarUrl || '',
|
||||
googleLinkedAt: user.googleLinkedAt ? user.googleLinkedAt.toISOString() : null,
|
||||
createdAt: user.createdAt.toISOString(),
|
||||
updatedAt: user.updatedAt.toISOString(),
|
||||
lastSeenAt: user.lastSeenAt ? user.lastSeenAt.toISOString() : null,
|
||||
|
||||
@@ -131,6 +131,11 @@ const mapAdminMemberRow = (row) => {
|
||||
* @property {boolean} isAdmin - 관리자 여부
|
||||
* @property {'owner' | 'admin' | 'vip' | 'member'} role - 권한 코드
|
||||
* @property {number} sessionVersion - 세션 무효화 버전
|
||||
* @property {string} googleSub - Google 사용자 고유 ID
|
||||
* @property {string} googleEmail - Google 계정 이메일
|
||||
* @property {string} googleName - Google 계정 이름
|
||||
* @property {string} googleAvatarUrl - Google 계정 프로필 이미지
|
||||
* @property {string | null} googleLinkedAt - Google 계정 연결 시각
|
||||
* @property {string} createdAt - 생성 시각(ISO)
|
||||
* @property {string} updatedAt - 수정 시각(ISO)
|
||||
* @property {string | null} lastSeenAt - 최근 접속 시각(ISO)
|
||||
@@ -171,6 +176,11 @@ export const getUserByEmail = async (email) => {
|
||||
is_admin AS "isAdmin",
|
||||
user_role AS "role",
|
||||
session_version AS "sessionVersion",
|
||||
google_sub AS "googleSub",
|
||||
google_email AS "googleEmail",
|
||||
google_name AS "googleName",
|
||||
google_avatar_url AS "googleAvatarUrl",
|
||||
google_linked_at AS "googleLinkedAt",
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
last_seen_at AS "lastSeenAt",
|
||||
@@ -185,6 +195,42 @@ export const getUserByEmail = async (email) => {
|
||||
return rows?.[0] || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Google 사용자 고유 ID로 회원을 조회한다.
|
||||
* @param {string} googleSub - Google 사용자 고유 ID
|
||||
* @returns {Promise<MemberUser | null>} 회원
|
||||
*/
|
||||
export const getUserByGoogleSub = async (googleSub) => {
|
||||
const sql = requireSql()
|
||||
const rows = await sql`
|
||||
SELECT
|
||||
id,
|
||||
username,
|
||||
email,
|
||||
password_hash AS "passwordHash",
|
||||
avatar_url AS "avatarUrl",
|
||||
is_admin AS "isAdmin",
|
||||
user_role AS "role",
|
||||
session_version AS "sessionVersion",
|
||||
google_sub AS "googleSub",
|
||||
google_email AS "googleEmail",
|
||||
google_name AS "googleName",
|
||||
google_avatar_url AS "googleAvatarUrl",
|
||||
google_linked_at AS "googleLinkedAt",
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
last_seen_at AS "lastSeenAt",
|
||||
last_seen_ip AS "lastSeenIp",
|
||||
previous_last_seen_at AS "previousLastSeenAt",
|
||||
previous_last_seen_ip AS "previousLastSeenIp"
|
||||
FROM users
|
||||
WHERE google_sub = ${googleSub}
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
return rows?.[0] || null
|
||||
}
|
||||
|
||||
/**
|
||||
* ID로 회원 조회
|
||||
* @param {string} id - 사용자 ID
|
||||
@@ -200,6 +246,11 @@ export const getUserById = async (id) => {
|
||||
avatar_url AS "avatarUrl",
|
||||
is_admin AS "isAdmin",
|
||||
user_role AS "role",
|
||||
google_sub AS "googleSub",
|
||||
google_email AS "googleEmail",
|
||||
google_name AS "googleName",
|
||||
google_avatar_url AS "googleAvatarUrl",
|
||||
google_linked_at AS "googleLinkedAt",
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
last_seen_at AS "lastSeenAt",
|
||||
@@ -229,6 +280,11 @@ export const getUserProfileById = async (id) => {
|
||||
users.avatar_url AS "avatarUrl",
|
||||
users.is_admin AS "isAdmin",
|
||||
users.user_role AS "role",
|
||||
users.google_sub AS "googleSub",
|
||||
users.google_email AS "googleEmail",
|
||||
users.google_name AS "googleName",
|
||||
users.google_avatar_url AS "googleAvatarUrl",
|
||||
users.google_linked_at AS "googleLinkedAt",
|
||||
users.created_at AS "createdAt",
|
||||
users.updated_at AS "updatedAt",
|
||||
users.last_seen_at AS "lastSeenAt",
|
||||
@@ -262,6 +318,12 @@ export const getUserByIdWithPassword = async (id) => {
|
||||
avatar_url AS "avatarUrl",
|
||||
is_admin AS "isAdmin",
|
||||
user_role AS "role",
|
||||
session_version AS "sessionVersion",
|
||||
google_sub AS "googleSub",
|
||||
google_email AS "googleEmail",
|
||||
google_name AS "googleName",
|
||||
google_avatar_url AS "googleAvatarUrl",
|
||||
google_linked_at AS "googleLinkedAt",
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
last_seen_at AS "lastSeenAt",
|
||||
@@ -336,6 +398,135 @@ export const createUser = async (input) => {
|
||||
return created
|
||||
}
|
||||
|
||||
/**
|
||||
* Google 계정 기반 회원을 생성한다.
|
||||
* @param {{ username: string, email: string, passwordHash: string, avatarUrl: string, googleSub: string, googleEmail: string, googleName: string, googleAvatarUrl: string }} input - 입력
|
||||
* @returns {Promise<Omit<MemberUser, 'passwordHash'>>} 생성된 회원
|
||||
*/
|
||||
export const createGoogleUser = async (input) => {
|
||||
const sql = requireSql()
|
||||
|
||||
const rows = await sql.begin(async (tx) => {
|
||||
await tx`LOCK TABLE users IN SHARE ROW EXCLUSIVE MODE`
|
||||
const privilegedRows = await tx`
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM users
|
||||
WHERE user_role = ANY(${PRIVILEGED_ROLES})
|
||||
) AS "hasPrivilegedUsers"
|
||||
`
|
||||
const shouldCreateOwner = !Boolean(privilegedRows?.[0]?.hasPrivilegedUsers)
|
||||
|
||||
return tx`
|
||||
INSERT INTO users (
|
||||
username,
|
||||
email,
|
||||
password_hash,
|
||||
avatar_url,
|
||||
is_admin,
|
||||
user_role,
|
||||
google_sub,
|
||||
google_email,
|
||||
google_name,
|
||||
google_avatar_url,
|
||||
google_linked_at
|
||||
)
|
||||
VALUES (
|
||||
${input.username},
|
||||
${input.email},
|
||||
${input.passwordHash},
|
||||
${input.avatarUrl},
|
||||
${shouldCreateOwner},
|
||||
CASE
|
||||
WHEN ${shouldCreateOwner} THEN ${MEMBER_ROLE.OWNER}
|
||||
ELSE ${MEMBER_ROLE.MEMBER}
|
||||
END,
|
||||
${input.googleSub},
|
||||
${input.googleEmail},
|
||||
${input.googleName},
|
||||
${input.googleAvatarUrl},
|
||||
now()
|
||||
)
|
||||
RETURNING
|
||||
id,
|
||||
username,
|
||||
email,
|
||||
avatar_url AS "avatarUrl",
|
||||
is_admin AS "isAdmin",
|
||||
user_role AS "role",
|
||||
session_version AS "sessionVersion",
|
||||
google_sub AS "googleSub",
|
||||
google_email AS "googleEmail",
|
||||
google_name AS "googleName",
|
||||
google_avatar_url AS "googleAvatarUrl",
|
||||
google_linked_at AS "googleLinkedAt",
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
last_seen_at AS "lastSeenAt",
|
||||
last_seen_ip AS "lastSeenIp",
|
||||
previous_last_seen_at AS "previousLastSeenAt",
|
||||
previous_last_seen_ip AS "previousLastSeenIp"
|
||||
`
|
||||
})
|
||||
|
||||
const created = rows?.[0]
|
||||
if (!created) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: 'Google 회원 생성에 실패했습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
return created
|
||||
}
|
||||
|
||||
/**
|
||||
* 기존 회원에 Google 계정을 연결한다.
|
||||
* @param {{ userId: string, googleSub: string, googleEmail: string, googleName: string, googleAvatarUrl: string }} input - 연결 정보
|
||||
* @returns {Promise<MemberUser | null>} 수정된 회원
|
||||
*/
|
||||
export const linkGoogleIdentityToUser = async (input) => {
|
||||
const sql = requireSql()
|
||||
|
||||
const rows = await sql`
|
||||
UPDATE users
|
||||
SET
|
||||
google_sub = ${input.googleSub},
|
||||
google_email = ${input.googleEmail},
|
||||
google_name = ${input.googleName},
|
||||
google_avatar_url = ${input.googleAvatarUrl},
|
||||
google_linked_at = now(),
|
||||
avatar_url = CASE
|
||||
WHEN avatar_url = '' THEN ${input.googleAvatarUrl}
|
||||
ELSE avatar_url
|
||||
END,
|
||||
updated_at = now()
|
||||
WHERE id = ${input.userId}
|
||||
RETURNING
|
||||
id,
|
||||
username,
|
||||
email,
|
||||
password_hash AS "passwordHash",
|
||||
avatar_url AS "avatarUrl",
|
||||
is_admin AS "isAdmin",
|
||||
user_role AS "role",
|
||||
session_version AS "sessionVersion",
|
||||
google_sub AS "googleSub",
|
||||
google_email AS "googleEmail",
|
||||
google_name AS "googleName",
|
||||
google_avatar_url AS "googleAvatarUrl",
|
||||
google_linked_at AS "googleLinkedAt",
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
last_seen_at AS "lastSeenAt",
|
||||
last_seen_ip AS "lastSeenIp",
|
||||
previous_last_seen_at AS "previousLastSeenAt",
|
||||
previous_last_seen_ip AS "previousLastSeenIp"
|
||||
`
|
||||
|
||||
return rows?.[0] || null
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 권한이 없을 때 환경 변수 기반 owner 계정을 생성하거나 기존 회원을 승격한다.
|
||||
* @param {{ username: string, email: string, passwordHash: string }} input - 입력
|
||||
|
||||
245
server/utils/google-oauth.js
Normal file
245
server/utils/google-oauth.js
Normal file
@@ -0,0 +1,245 @@
|
||||
import { createHmac, randomBytes } from 'node:crypto'
|
||||
import { createError, deleteCookie, getCookie, getRequestURL, setCookie } from 'h3'
|
||||
import { getRuntimeEnvValue } from './runtime-env.js'
|
||||
import { shouldUseSecureSessionCookie } from './session-cookie.js'
|
||||
|
||||
const googleAuthorizationUrl = 'https://accounts.google.com/o/oauth2/v2/auth'
|
||||
const googleTokenUrl = 'https://oauth2.googleapis.com/token'
|
||||
const googleUserInfoUrl = 'https://openidconnect.googleapis.com/v1/userinfo'
|
||||
const googleOAuthStateCookieName = 'sori_google_oauth_nonce'
|
||||
const googleOAuthStateMaxAge = 60 * 10
|
||||
const googleOAuthCallbackPath = '/api/auth/google/callback'
|
||||
|
||||
/**
|
||||
* Google OAuth 클라이언트 설정을 조회한다.
|
||||
* @returns {{ clientId: string, clientSecret: string }} 클라이언트 설정
|
||||
*/
|
||||
export const getGoogleOAuthClientConfig = () => {
|
||||
const clientId = getRuntimeEnvValue('GOOGLE_OAUTH_CLIENT_ID', 'googleOAuthClientId', getRuntimeEnvValue('GOOGLE_ADSENSE_CLIENT_ID', 'googleOAuthClientId')).trim()
|
||||
const clientSecret = getRuntimeEnvValue('GOOGLE_OAUTH_CLIENT_SECRET', 'googleOAuthClientSecret', getRuntimeEnvValue('GOOGLE_ADSENSE_CLIENT_SECRET', 'googleOAuthClientSecret')).trim()
|
||||
|
||||
if (!clientId || !clientSecret) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: 'Google 로그인 환경 변수가 없습니다. GOOGLE_OAUTH_CLIENT_ID와 GOOGLE_OAUTH_CLIENT_SECRET을 설정해 주세요.'
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
clientId,
|
||||
clientSecret
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Google OAuth callback URL을 만든다.
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {string} callback URL
|
||||
*/
|
||||
export const getGoogleOAuthRedirectUri = (event) => {
|
||||
const configuredUri = getRuntimeEnvValue('GOOGLE_OAUTH_REDIRECT_URI', 'googleOAuthRedirectUri').trim()
|
||||
if (configuredUri) {
|
||||
return configuredUri
|
||||
}
|
||||
|
||||
return `${getRequestURL(event).origin}${googleOAuthCallbackPath}`
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth state 서명 비밀값을 조회한다.
|
||||
* @returns {string} 서명 비밀값
|
||||
*/
|
||||
const getGoogleOAuthStateSecret = () => {
|
||||
const secret = getRuntimeEnvValue('MEMBER_SESSION_SECRET', 'memberSessionSecret').trim()
|
||||
if (!secret) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: '회원 세션 비밀값 환경 변수가 없습니다. MEMBER_SESSION_SECRET을 설정해 주세요.'
|
||||
})
|
||||
}
|
||||
|
||||
return secret
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth state payload를 서명한다.
|
||||
* @param {string} payload - base64url payload
|
||||
* @returns {string} 서명
|
||||
*/
|
||||
const signGoogleOAuthStatePayload = (payload) => createHmac('sha256', getGoogleOAuthStateSecret())
|
||||
.update(payload)
|
||||
.digest('base64url')
|
||||
|
||||
/**
|
||||
* 내부 경로 redirect만 허용한다.
|
||||
* @param {unknown} redirectTo - 요청 redirect 값
|
||||
* @param {string} fallback - 기본 경로
|
||||
* @returns {string} 안전한 redirect 경로
|
||||
*/
|
||||
export const normalizeGoogleOAuthRedirect = (redirectTo, fallback = '/') => {
|
||||
const value = String(redirectTo || '').trim()
|
||||
if (!value || !value.startsWith('/') || value.startsWith('//') || value.includes('\\')) {
|
||||
return fallback
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* Google OAuth state를 만들고 nonce 쿠키를 저장한다.
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @param {{ mode: 'member' | 'admin' | 'link', redirectTo: string, userId?: string }} input - state 입력
|
||||
* @returns {string} state 문자열
|
||||
*/
|
||||
export const createGoogleOAuthState = (event, input) => {
|
||||
const nonce = randomBytes(24).toString('base64url')
|
||||
const payload = Buffer.from(JSON.stringify({
|
||||
mode: input.mode,
|
||||
redirectTo: input.redirectTo,
|
||||
userId: input.userId || '',
|
||||
nonce,
|
||||
createdAt: Date.now()
|
||||
})).toString('base64url')
|
||||
const state = `${payload}.${signGoogleOAuthStatePayload(payload)}`
|
||||
|
||||
setCookie(event, googleOAuthStateCookieName, nonce, {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: shouldUseSecureSessionCookie(event),
|
||||
path: '/',
|
||||
maxAge: googleOAuthStateMaxAge
|
||||
})
|
||||
|
||||
return state
|
||||
}
|
||||
|
||||
/**
|
||||
* Google OAuth state를 검증한다.
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @param {string} state - state 문자열
|
||||
* @returns {{ mode: 'member' | 'admin' | 'link', redirectTo: string, userId: string }} state payload
|
||||
*/
|
||||
export const verifyGoogleOAuthState = (event, state) => {
|
||||
const [payload, signature] = String(state || '').split('.')
|
||||
const expectedNonce = getCookie(event, googleOAuthStateCookieName)
|
||||
|
||||
deleteCookie(event, googleOAuthStateCookieName, {
|
||||
path: '/'
|
||||
})
|
||||
|
||||
if (!payload || !signature || signature !== signGoogleOAuthStatePayload(payload)) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: 'Google 로그인 상태값이 올바르지 않습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
let parsed = null
|
||||
try {
|
||||
parsed = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'))
|
||||
} catch {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: 'Google 로그인 상태값을 읽을 수 없습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
if (!expectedNonce || parsed.nonce !== expectedNonce || Date.now() - Number(parsed.createdAt || 0) > googleOAuthStateMaxAge * 1000) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: 'Google 로그인 요청이 만료되었습니다. 다시 시도해 주세요.'
|
||||
})
|
||||
}
|
||||
|
||||
if (!['member', 'admin', 'link'].includes(parsed.mode)) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: 'Google 로그인 모드가 올바르지 않습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
mode: parsed.mode,
|
||||
redirectTo: normalizeGoogleOAuthRedirect(parsed.redirectTo, parsed.mode === 'admin' ? '/admin' : '/'),
|
||||
userId: String(parsed.userId || '')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Google OAuth 인증 URL을 만든다.
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @param {string} state - state 문자열
|
||||
* @returns {string} Google 인증 URL
|
||||
*/
|
||||
export const createGoogleAuthorizationUrl = (event, state) => {
|
||||
const { clientId } = getGoogleOAuthClientConfig()
|
||||
const url = new URL(googleAuthorizationUrl)
|
||||
url.searchParams.set('client_id', clientId)
|
||||
url.searchParams.set('redirect_uri', getGoogleOAuthRedirectUri(event))
|
||||
url.searchParams.set('response_type', 'code')
|
||||
url.searchParams.set('scope', 'openid email profile')
|
||||
url.searchParams.set('state', state)
|
||||
url.searchParams.set('prompt', 'select_account')
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Google OAuth code를 사용자 정보로 교환한다.
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @param {string} code - OAuth code
|
||||
* @returns {Promise<{ sub: string, email: string, emailVerified: boolean, name: string, picture: string }>} Google 사용자 정보
|
||||
*/
|
||||
export const exchangeGoogleOAuthCode = async (event, code) => {
|
||||
const { clientId, clientSecret } = getGoogleOAuthClientConfig()
|
||||
const tokenResponse = await fetch(googleTokenUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
code,
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
redirect_uri: getGoogleOAuthRedirectUri(event),
|
||||
grant_type: 'authorization_code'
|
||||
})
|
||||
})
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
throw createError({
|
||||
statusCode: 502,
|
||||
message: 'Google 로그인 토큰을 발급받지 못했습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const tokenData = await tokenResponse.json()
|
||||
const accessToken = tokenData?.access_token
|
||||
if (!accessToken) {
|
||||
throw createError({
|
||||
statusCode: 502,
|
||||
message: 'Google access token이 없습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const profileResponse = await fetch(googleUserInfoUrl, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`
|
||||
}
|
||||
})
|
||||
|
||||
if (!profileResponse.ok) {
|
||||
throw createError({
|
||||
statusCode: 502,
|
||||
message: 'Google 사용자 정보를 조회하지 못했습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const profile = await profileResponse.json()
|
||||
return {
|
||||
sub: String(profile?.sub || ''),
|
||||
email: String(profile?.email || '').trim().toLowerCase(),
|
||||
emailVerified: Boolean(profile?.email_verified),
|
||||
name: String(profile?.name || '').trim(),
|
||||
picture: String(profile?.picture || '').trim()
|
||||
}
|
||||
}
|
||||
11
tests/google-oauth.test.js
Normal file
11
tests/google-oauth.test.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { normalizeGoogleOAuthRedirect } from '../server/utils/google-oauth.js'
|
||||
|
||||
test('Google OAuth redirect는 내부 경로만 허용한다', () => {
|
||||
assert.equal(normalizeGoogleOAuthRedirect('/settings', '/'), '/settings')
|
||||
assert.equal(normalizeGoogleOAuthRedirect('/admin?google=linked', '/'), '/admin?google=linked')
|
||||
assert.equal(normalizeGoogleOAuthRedirect('https://example.com', '/'), '/')
|
||||
assert.equal(normalizeGoogleOAuthRedirect('//example.com', '/'), '/')
|
||||
assert.equal(normalizeGoogleOAuthRedirect('/\\evil', '/'), '/')
|
||||
})
|
||||
Reference in New Issue
Block a user