소유자 관리자 진입 권한 보강

This commit is contained in:
2026-08-13 16:11:19 +09:00
parent 1453026e60
commit 2b232d0b51
14 changed files with 73 additions and 20 deletions

View File

@@ -8,6 +8,15 @@ const member = ref(null)
const { data: siteSettings } = await usePublicSiteSettings()
/**
* 현재 회원이 공개 헤더에서 관리자 화면으로 이동할 수 있는지 확인한다.
* @returns {boolean} 관리자 화면 이동 가능 여부
*/
const canOpenSiteAdmin = computed(() => {
const role = String(member.value?.role || '').toLowerCase()
return Boolean(member.value?.isAdmin) || role === 'owner' || role === 'admin'
})
/**
* 사용자 메뉴를 닫는다.
* @returns {void}
@@ -267,7 +276,7 @@ onBeforeUnmount(() => {
</div>
<template v-if="member">
<NuxtLink v-if="member?.isAdmin" class="site-header__user-link flex items-center gap-1.5 rounded-[8px] px-2.5 py-1.5 transition-colors duration-150 hover:bg-[var(--site-panel)]" to="/admin" @click="closeUserMenu">
<NuxtLink v-if="canOpenSiteAdmin" class="site-header__user-link flex items-center gap-1.5 rounded-[8px] px-2.5 py-1.5 transition-colors duration-150 hover:bg-[var(--site-panel)]" to="/admin" @click="closeUserMenu">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" fill="currentColor" class="h-5 w-5" aria-hidden="true">
<path d="M160-120q-33 0-56.5-23.5T80-200v-480q0-33 23.5-56.5T160-760h240l80 80h320q33 0 56.5 23.5T880-600v400q0 33-23.5 56.5T800-120H160Zm0-80h640v-400H447l-80-80H160v480Zm280-80h80v-120h120v-80H520v-120h-80v120H320v80h120v120ZM160-200v-480 480Z" />
</svg>

View File

@@ -34,6 +34,7 @@ services:
volumes:
sori-studio-postgres:
name: ${POSTGRES_VOLUME_NAME:-soristudio_sori-studio-postgres}
external: ${POSTGRES_VOLUME_EXTERNAL:-true}
networks:
sori-studio-network:

View File

@@ -32,13 +32,15 @@ Linux CI 이미지에서 브라우저 시스템 의존성도 함께 준비해야
### 운영 마이그레이션과 미디어 카탈로그 전환
운영 PostgreSQL 볼륨은 Compose 프로젝트명 변화에 영향받지 않도록 `POSTGRES_VOLUME_NAME` 또는 기본값 `soristudio_sori-studio-postgres`로 실제 이름을 고정한다. UGREEN Docker 앱에서 프로젝트 이름이 달라지면 `sori-studio_sori-studio-postgres`처럼 새 빈 볼륨이 붙고 PostgreSQL init 스크립트가 다시 실행될 수 있으므로, 배포 전후 아래 명령으로 DB 컨테이너가 기존 운영 볼륨을 물고 있는지 확인한다.
운영 PostgreSQL 볼륨은 Compose 프로젝트명 변화에 영향받지 않도록 `POSTGRES_VOLUME_NAME` 또는 기본값 `soristudio_sori-studio-postgres`로 실제 이름을 고정하고, 기본적으로 외부 볼륨(`POSTGRES_VOLUME_EXTERNAL=true`)으로 선언한다. UGREEN Docker 앱에서 프로젝트 이름이 달라지면 `sori-studio_sori-studio-postgres`처럼 새 빈 볼륨이 붙고 PostgreSQL init 스크립트가 다시 실행될 수 있으므로, 배포 전후 아래 명령으로 DB 컨테이너가 기존 운영 볼륨을 물고 있는지 확인한다.
```bash
sudo docker inspect sori-studio-db --format '{{range .Mounts}}{{println .Name .Destination}}{{end}}'
sudo docker volume ls | grep -i sori
```
운영 볼륨이 이미 존재하지 않는 완전 신규 설치에서는 먼저 Docker 앱에서 빈 PostgreSQL 볼륨을 만들거나, Compose 변수 `POSTGRES_VOLUME_EXTERNAL=false`로 최초 생성한 뒤 이후 운영 고정 볼륨으로 사용한다.
```bash
sh scripts/migrate-production-db.sh status
sh scripts/migrate-production-db.sh migrate

View File

@@ -1,3 +1,7 @@
## 2026-08-13 v1.12.18 — 관리자 권한 판별은 role을 우선 신뢰한다
회원 권한의 실제 기준은 `user_role`의 owner/admin이며, 레거시 `is_admin` 플래그만 공개 헤더와 공개 로그인에서 보면 소유자 계정이 관리자 진입 링크를 보지 못할 수 있다. 따라서 공개 인증 응답과 로그인 관리자 세션 발급은 `is_admin`과 `user_role` owner/admin을 함께 보고, 헤더도 같은 기준으로 `사이트 관리`를 표시한다. NAS 운영 볼륨은 이미 존재하는 고정 볼륨을 재사용하는 구조이므로 Compose에는 외부 볼륨 선언을 추가해 Docker 앱의 프로젝트명 불일치 경고를 줄인다.
## 2026-08-13 v1.12.17 — 공개 관리자 로그인은 관리자 진입으로 이어져야 한다
공개 사이트에서 owner/admin 계정으로 로그인한 사용자는 이미 같은 회원 계정으로 인증된 상태이므로, `/admin` 진입 때 별도 관리자 로그인을 다시 요구하면 계정 모델이 사용자에게 어긋나 보인다. 공개 로그인 시 관리자 권한 계정에는 관리자 세션을 함께 발급하고, 기존 공개 세션만 남은 경우에도 `/admin/api/auth/me`가 현재 권한과 세션 버전을 확인한 뒤 관리자 세션을 발급하도록 한다. 공개 헤더에는 관리자 권한 회원에게만 `사이트 관리` 링크를 노출해 관리자 화면으로 바로 이동할 수 있게 하고, 공개 로그아웃은 두 세션을 함께 정리한다.

View File

@@ -71,6 +71,7 @@
| lib/admin-brand-settings-section.js | 관리자 설정 브랜드 설정 필드 목록·기본 스냅샷·정규화·스냅샷 복사·변경 비교 helper |
| lib/admin-announcement-settings-section.js | 관리자 설정 어나운스 바 설정 필드 목록·기본 스냅샷·정규화·스냅샷 복사·변경 비교 helper |
| lib/post-card-thumbnail-url.js | 게시물 카드 썸네일 `thumbs/*-card.webp`, Pages 커버 썸네일 `thumbs/*-page-cover.webp`, 본문 표시용 `body-1600`/`body-720`·라이트박스용 `body-2560` 파생 이미지 URL 판별과 레거시 `body`/`lightbox` 판별, 자동 생성 이미지 캐시 버전 URL 생성(대표 이미지·본문 이미지·갤러리·Pages 커버 선택 및 서버 검증 공용) |
| lib/member-role.js | 회원 권한 코드와 owner/admin 관리자 권한 판별 |
| lib/external-favicon-url.js | 외부 URL origin 기준 Google `gstatic.com/faviconV2` 파비콘 URL 생성(내부 경로는 빈 문자열) |
| lib/navigation-url.js | 네비게이션 URL 접두사 분리·합성·정규화(`https://` 자동 보정, `/` 내부 경로) |
| lib/recommended-site-icon-url.js | 추천 사이트 아이콘 URL 우선순위(수동 썸네일 > 서버 캐시 > 외부 프록시) |
@@ -135,7 +136,7 @@
| 파일 | 용도 |
|------|------|
| Dockerfile | Nuxt 프로덕션 앱 이미지 빌드 |
| docker-compose.yml | Docker CLI·Buildx 가능 환경의 앱 빌드 포함 Compose 실행 |
| docker-compose.yml | Docker CLI·Buildx 가능 환경의 앱 빌드 포함 Compose 실행, 운영 PostgreSQL 고정 외부 볼륨 연결 |
## Nuxt 모듈

View File

@@ -47,7 +47,7 @@
- `Escape` 키는 통합 검색 모달이 열려 있으면 최우선으로 닫고, 그다음 사용자 드롭다운, 이어서 모바일에서만 좌측 슬라이드 메뉴를 닫는다.
- `/` 키는 `INPUT`·`TEXTAREA`·`SELECT`·`contenteditable`에 포커스가 없고 `Ctrl`/`Meta`/`Alt`와 함께 눌리지 않을 때 통합 검색 모달을 연다. 헤더 검색 영역(`md+`) 클릭으로도 동일하게 연다.
- 통합 검색 입력은 한글 등 IME 조합 중에도 입력창에 표시되는 현재 값을 디바운스로 검색에 반영하고, 조합 종료 시 최종 입력값을 즉시 검색한다. 검색어는 앞뒤 공백 제거, 연속 공백 단일화, Unicode NFC 정규화를 거쳐 태그·게시물·페이지 검색에 사용한다.
- 헤더 우측 사용자 아이콘 버튼은 로그인 상태면 회원 아바타/닉네임과 설정·로그아웃 메뉴를, 비로그인 상태면 Guest·Sign up·Sign in 메뉴를 표시한다. 로그인 회원이 owner/admin 권한이면 설정 위에 `사이트 관리` 링크를 표시해 `/admin`으로 이동한다. 회원 아바타 이미지가 없거나 비로그인 상태인 경우 사용자 메뉴 버튼에는 사람 아이콘을 표시한다.
- 헤더 우측 사용자 아이콘 버튼은 로그인 상태면 회원 아바타/닉네임과 설정·로그아웃 메뉴를, 비로그인 상태면 Guest·Sign up·Sign in 메뉴를 표시한다. 로그인 회원이 `is_admin=true`이거나 `user_role`이 owner/admin이면 설정 위에 `사이트 관리` 링크를 표시해 `/admin`으로 이동한다. 회원 아바타 이미지가 없거나 비로그인 상태인 경우 사용자 메뉴 버튼에는 사람 아이콘을 표시한다.
- 오른쪽 사이드바의 FOLLOW 영역은 사이트 설정의 SNS 링크 목록을 기준으로 표시한다. 관리자가 아이콘 프리셋 또는 직접 SVG 아이콘과 주소를 등록한 항목만 노출하며, 등록된 항목이 없으면 FOLLOW 영역 자체를 숨긴다. SNS 주소는 `https://`를 생략해도 저장 시 자동 보정한다. 관리자 편집 화면은 아이콘과 주소를 기본 입력으로 사용하며, 직접 SVG 프리셋을 선택했을 때만 SVG 코드 입력을 추가로 표시한다. 공개 FOLLOW 아이콘은 프리셋·직접 SVG 모두 16px 아이콘을 20px 버튼 중앙에 배치한다.
### 공개 화면 색상
@@ -213,7 +213,7 @@
- 인증 화면 상태 메시지는 오류/안내를 분리해 `aria-live`로 노출한다.
- 회원가입 1단계의 타이틀/설명은 `GET /api/site-settings`의 `title`, `description` 값을 우선 사용한다.
- 회원 세션 쿠키 서명에는 `MEMBER_SESSION_SECRET`만 사용하며, 관리자 비밀번호를 fallback으로 쓰지 않는다.
- 공개 로그인으로 owner/admin 계정에 로그인하면 회원 세션과 함께 관리자 세션을 발급한다. 기존 공개 회원 세션만 남아 있는 owner/admin 계정도 `/admin` 진입 시 `/admin/api/auth/me` 조회에서 현재 권한과 세션 버전을 확인한 뒤 관리자 세션으로 승격한다. 공개 로그아웃은 회원 세션과 관리자 세션을 함께 삭제한다.
- 공개 로그인으로 `is_admin=true`이거나 `user_role`이 owner/admin인 계정에 로그인하면 회원 세션과 함께 관리자 세션을 발급한다. 기존 공개 회원 세션만 남아 있는 owner/admin 계정도 `/admin` 진입 시 `/admin/api/auth/me` 조회에서 현재 권한과 세션 버전을 확인한 뒤 관리자 세션으로 승격한다. 공개 로그아웃은 회원 세션과 관리자 세션을 함께 삭제한다.
- 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 계정 연결을 요청한다.

View File

@@ -1,5 +1,11 @@
# 업데이트 이력
## v1.12.18
- 공개 회원 관리자 권한 판별을 `is_admin`과 `user_role` owner/admin 기준으로 통일.
- 소유자 role 계정도 공개 헤더 `사이트 관리` 링크와 공개 로그인 관리자 세션 발급 대상에 포함되도록 수정.
- 운영 PostgreSQL 고정 볼륨을 외부 볼륨으로 선언해 NAS Docker 앱의 프로젝트명 불일치 경고를 정리.
## v1.12.17
- 공개 헤더 사용자 메뉴에 관리자 권한 회원용 `사이트 관리` 링크 추가.

15
lib/member-role.js Normal file
View File

@@ -0,0 +1,15 @@
export const MEMBER_ROLE = {
OWNER: 'owner',
ADMIN: 'admin',
VIP: 'vip',
MEMBER: 'member'
}
export const PRIVILEGED_MEMBER_ROLES = [MEMBER_ROLE.OWNER, MEMBER_ROLE.ADMIN]
/**
* 관리자 권한 코드 여부를 확인한다.
* @param {string | undefined | null} roleCode - 권한 코드
* @returns {boolean} 관리자 권한 여부
*/
export const isPrivilegedMemberRole = (roleCode) => PRIVILEGED_MEMBER_ROLES.includes(String(roleCode || ''))

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "sori.studio",
"version": "1.12.17",
"version": "1.12.18",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sori.studio",
"version": "1.12.17",
"version": "1.12.18",
"hasInstallScript": true,
"dependencies": {
"@nuxtjs/tailwindcss": "^6.14.0",

View File

@@ -1,6 +1,6 @@
{
"name": "sori.studio",
"version": "1.12.17",
"version": "1.12.18",
"private": true,
"type": "module",
"imports": {

View File

@@ -1,7 +1,7 @@
import bcrypt from 'bcrypt'
import { z } from 'zod'
import { createError, readBody } from 'h3'
import { getUserByEmail, touchUserActivity } from '../../repositories/member-repository'
import { getUserByEmail, isPrivilegedMemberRole, touchUserActivity } from '../../repositories/member-repository'
import { setMemberSession } from '../../utils/member-auth'
import { setAdminSession } from '../../utils/admin-auth'
import { getClientIp } from '../../utils/request-ip'
@@ -16,6 +16,13 @@ const loginSchema = z.object({
password: z.string().min(1)
})
/**
* 공개 회원이 관리자 진입 권한을 갖는지 확인한다.
* @param {{ isAdmin?: boolean, role?: string } | null} user - 회원 정보
* @returns {boolean} 관리자 진입 가능 여부
*/
const canOpenSiteAdmin = (user) => Boolean(user?.isAdmin) || isPrivilegedMemberRole(user?.role)
/**
* 회원 로그인 API
* @param {import('h3').H3Event} event - 요청 이벤트
@@ -58,7 +65,8 @@ export default defineEventHandler(async (event) => {
email: user.email,
sessionVersion: user.sessionVersion
})
if (user.isAdmin) {
const isAdminUser = canOpenSiteAdmin(user)
if (isAdminUser) {
setAdminSession(event, {
userId: user.id,
email: user.email,
@@ -75,6 +83,6 @@ export default defineEventHandler(async (event) => {
username: user.username,
email: user.email,
avatarUrl: user.avatarUrl || '',
isAdmin: Boolean(user.isAdmin)
isAdmin: isAdminUser
}
})

View File

@@ -1,4 +1,4 @@
import { getUserById } from '../../repositories/member-repository'
import { getUserById, isPrivilegedMemberRole } from '../../repositories/member-repository'
import { getVerifiedMemberSession, requireMemberSession } from '../../utils/member-auth'
/**
@@ -34,7 +34,7 @@ export default defineEventHandler(async (event) => {
username: user.username,
email: user.email,
avatarUrl: user.avatarUrl || '',
isAdmin: Boolean(user.isAdmin),
isAdmin: Boolean(user.isAdmin) || isPrivilegedMemberRole(user.role),
role: user.role || 'member'
}
})

View File

@@ -1,14 +1,10 @@
import { createError } from 'h3'
import { getPostgresClient } from './postgres-client'
import { MEMBER_ROLE, PRIVILEGED_MEMBER_ROLES, isPrivilegedMemberRole } from '../../lib/member-role'
export const MEMBER_ROLE = {
OWNER: 'owner',
ADMIN: 'admin',
VIP: 'vip',
MEMBER: 'member'
}
export { MEMBER_ROLE, isPrivilegedMemberRole }
const PRIVILEGED_ROLES = [MEMBER_ROLE.OWNER, MEMBER_ROLE.ADMIN]
const PRIVILEGED_ROLES = PRIVILEGED_MEMBER_ROLES
const MEMBERSHIP_ACCESS_ROLES = [MEMBER_ROLE.OWNER, MEMBER_ROLE.ADMIN, MEMBER_ROLE.VIP]
const BELOW_ADMIN_ROLES = [MEMBER_ROLE.VIP, MEMBER_ROLE.MEMBER]

11
tests/member-role.test.js Normal file
View File

@@ -0,0 +1,11 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { isPrivilegedMemberRole } from '../lib/member-role.js'
test('owner/admin role은 관리자 권한으로 판별한다', () => {
assert.equal(isPrivilegedMemberRole('owner'), true)
assert.equal(isPrivilegedMemberRole('admin'), true)
assert.equal(isPrivilegedMemberRole('member'), false)
assert.equal(isPrivilegedMemberRole('vip'), false)
assert.equal(isPrivilegedMemberRole(undefined), false)
})