관리자 최초 로그인 보강
This commit is contained in:
@@ -1,5 +1,10 @@
|
|||||||
# 업데이트 요약
|
# 업데이트 요약
|
||||||
|
|
||||||
|
## v1.0.2
|
||||||
|
|
||||||
|
- 운영 DB 최초 상태에서 환경 변수 관리자 계정으로 첫 owner 계정을 만들고 로그인할 수 있도록 보강.
|
||||||
|
- 배포 문서의 운영 환경 변수 생성 안내를 정리.
|
||||||
|
|
||||||
## v1.0.1
|
## v1.0.1
|
||||||
|
|
||||||
- Docker Compose 네트워크 충돌 대응을 위해 전용 브리지 네트워크와 `DOCKER_SUBNET` 설정 추가.
|
- Docker Compose 네트워크 충돌 대응을 위해 전용 브리지 네트워크와 `DOCKER_SUBNET` 설정 추가.
|
||||||
|
|||||||
@@ -127,8 +127,9 @@ cd sori.studio
|
|||||||
|
|
||||||
# 운영 환경 변수 설정
|
# 운영 환경 변수 설정
|
||||||
# .env.production은 Git에 올리지 않는 운영 전용 파일
|
# .env.production은 Git에 올리지 않는 운영 전용 파일
|
||||||
cp .env.production
|
cp .env.example .env.production
|
||||||
# .env.production 파일에 운영 DB 연결 정보, 운영 전용 랜덤 비밀번호, APP_PORT=43118 입력
|
# .env.production 파일에 운영 DB 연결 정보, 운영 전용 랜덤 비밀번호, APP_PORT=43118 입력
|
||||||
|
# 운영 DB가 비어 있으면 /admin/login에서 ADMIN_EMAIL/ADMIN_PASSWORD로 최초 owner 계정이 생성됨
|
||||||
# MEMBER_SESSION_SECRET은 ADMIN_PASSWORD와 다른 긴 난수 문자열로 반드시 입력
|
# MEMBER_SESSION_SECRET은 ADMIN_PASSWORD와 다른 긴 난수 문자열로 반드시 입력
|
||||||
# Docker 네트워크 대역이 NAS 기존 컨테이너와 겹치면 DOCKER_SUBNET을 다른 사설 대역으로 변경
|
# Docker 네트워크 대역이 NAS 기존 컨테이너와 겹치면 DOCKER_SUBNET을 다른 사설 대역으로 변경
|
||||||
# Docker 내부 앱에서 PostgreSQL에 접근할 때는 sori-studio-db:5432 사용
|
# Docker 내부 앱에서 PostgreSQL에 접근할 때는 sori-studio-db:5432 사용
|
||||||
@@ -165,6 +166,7 @@ docker compose --env-file .env.production up -d --build
|
|||||||
- NAS Docker 예시: `postgres://sori_studio:비밀번호@sori-studio-db:5432/sori_studio`
|
- NAS Docker 예시: `postgres://sori_studio:비밀번호@sori-studio-db:5432/sori_studio`
|
||||||
- `.env.example`에는 실제 비밀번호나 개인 이메일을 기록하지 않음
|
- `.env.example`에는 실제 비밀번호나 개인 이메일을 기록하지 않음
|
||||||
- 개발/운영 DB 비밀번호와 관리자 비밀번호는 서로 다른 랜덤 값을 사용
|
- 개발/운영 DB 비밀번호와 관리자 비밀번호는 서로 다른 랜덤 값을 사용
|
||||||
|
- `ADMIN_EMAIL`/`ADMIN_PASSWORD`는 운영 DB가 비어 있는 최초 관리자 생성에만 사용한다. 첫 owner 계정이 DB에 생성된 뒤에는 관리자 로그인도 DB의 bcrypt 비밀번호를 기준으로 검증한다.
|
||||||
- 회원 세션 서명용 `MEMBER_SESSION_SECRET`은 관리자 비밀번호와 분리된 긴 난수 문자열을 사용
|
- 회원 세션 서명용 `MEMBER_SESSION_SECRET`은 관리자 비밀번호와 분리된 긴 난수 문자열을 사용
|
||||||
- 개발 DB와 운영 DB는 반드시 별도 인스턴스 또는 별도 데이터베이스로 분리
|
- 개발 DB와 운영 DB는 반드시 별도 인스턴스 또는 별도 데이터베이스로 분리
|
||||||
- 운영 DB는 로컬 개발 서버에서 직접 연결하지 않음
|
- 운영 DB는 로컬 개발 서버에서 직접 연결하지 않음
|
||||||
|
|||||||
@@ -567,6 +567,7 @@ components/content/
|
|||||||
|
|
||||||
- 관리자 인증은 `users.is_admin=true` 회원의 이메일/비밀번호(bcrypt 해시) 기반으로 처리한다.
|
- 관리자 인증은 `users.is_admin=true` 회원의 이메일/비밀번호(bcrypt 해시) 기반으로 처리한다.
|
||||||
- DB에 사용자가 없으면 `/signup`에서 관리자 등록 모드(관리자 이름/이메일/비밀번호/비밀번호 확인)로 첫 계정을 생성한다.
|
- DB에 사용자가 없으면 `/signup`에서 관리자 등록 모드(관리자 이름/이메일/비밀번호/비밀번호 확인)로 첫 계정을 생성한다.
|
||||||
|
- DB에 사용자가 없는 최초 상태에서 `/admin/login`에 `ADMIN_EMAIL`/`ADMIN_PASSWORD`와 같은 값을 입력하면 첫 owner 계정을 DB에 생성한 뒤 로그인한다. 이미 사용자가 있으면 환경 변수 계정으로 우회 로그인하지 않고 DB의 관리자 계정만 사용한다.
|
||||||
- 첫 계정 생성 시 `is_admin=true`, `user_role=owner`를 자동 부여한다. 최초 가입 판정은 동시 요청에서 중복 owner가 생기지 않도록 `users` 테이블 잠금 안에서 처리한다.
|
- 첫 계정 생성 시 `is_admin=true`, `user_role=owner`를 자동 부여한다. 최초 가입 판정은 동시 요청에서 중복 owner가 생기지 않도록 `users` 테이블 잠금 안에서 처리한다.
|
||||||
- 관리자 로그인 성공 시 httpOnly 세션 쿠키를 `/admin` 경로에 설정한다.
|
- 관리자 로그인 성공 시 httpOnly 세션 쿠키를 `/admin` 경로에 설정한다.
|
||||||
- `/admin/login` 로그인 제출 버튼은 이메일·비밀번호가 모두 입력된 경우에만 활성화한다.
|
- `/admin/login` 로그인 제출 버튼은 이메일·비밀번호가 모두 입력된 경우에만 활성화한다.
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# 업데이트 이력
|
# 업데이트 이력
|
||||||
|
|
||||||
|
## v1.0.2
|
||||||
|
|
||||||
|
- 운영 DB가 비어 있을 때 `/admin/login`에서 `ADMIN_EMAIL`/`ADMIN_PASSWORD`로 최초 owner 계정을 생성하도록 수정.
|
||||||
|
- 배포 문서의 `.env.production` 생성 명령과 최초 관리자 생성 기준 정리.
|
||||||
|
- 패키지 버전 `1.0.2`로 갱신.
|
||||||
|
|
||||||
## v1.0.1
|
## v1.0.1
|
||||||
|
|
||||||
- Docker Compose 기본 네트워크 주소 풀 충돌을 피하기 위해 전용 브리지 네트워크와 `DOCKER_SUBNET` 설정 추가.
|
- Docker Compose 기본 네트워크 주소 풀 충돌을 피하기 위해 전용 브리지 네트워크와 `DOCKER_SUBNET` 설정 추가.
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "1.0.1",
|
"version": "1.0.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "1.0.1",
|
"version": "1.0.2",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "1.0.1",
|
"version": "1.0.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"imports": {
|
"imports": {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { createError, getRequestIP, readBody } from 'h3'
|
import { createError, getRequestIP, readBody } from 'h3'
|
||||||
import bcrypt from 'bcrypt'
|
import bcrypt from 'bcrypt'
|
||||||
import { setAdminSession } from '../../../../utils/admin-auth'
|
import { safeCompare, setAdminSession } from '../../../../utils/admin-auth'
|
||||||
import { getAdminUserByEmail, touchUserActivity } from '../../../../repositories/member-repository'
|
import { createUser, getAdminUserByEmail, getMemberBootstrapState, touchUserActivity } from '../../../../repositories/member-repository'
|
||||||
import { setMemberSession } from '../../../../utils/member-auth'
|
import { setMemberSession } from '../../../../utils/member-auth'
|
||||||
|
|
||||||
const loginSchema = z.object({
|
const loginSchema = z.object({
|
||||||
@@ -10,6 +10,48 @@ const loginSchema = z.object({
|
|||||||
password: z.string().min(1)
|
password: z.string().min(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이메일에서 최초 관리자 닉네임을 만든다.
|
||||||
|
* @param {string} email - 관리자 이메일
|
||||||
|
* @returns {string} 닉네임
|
||||||
|
*/
|
||||||
|
const createBootstrapUsername = (email) => {
|
||||||
|
const localPart = String(email).split('@')[0] || 'admin'
|
||||||
|
return localPart.replace(/[^a-zA-Z0-9._-]/g, '').trim() || 'admin'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 운영 환경 변수 기반 최초 관리자 계정을 생성한다.
|
||||||
|
* @param {{ email: string, password: string }} credentials - 로그인 입력값
|
||||||
|
* @returns {Promise<import('../../../../repositories/member-repository').MemberUser | null>} 생성된 관리자
|
||||||
|
*/
|
||||||
|
const createBootstrapAdminUser = async (credentials) => {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const adminEmail = String(config.adminEmail || '').trim().toLowerCase()
|
||||||
|
const adminPassword = String(config.adminPassword || '')
|
||||||
|
|
||||||
|
if (!adminEmail || !adminPassword || credentials.email.trim().toLowerCase() !== adminEmail || !safeCompare(credentials.password, adminPassword)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const bootstrap = await getMemberBootstrapState()
|
||||||
|
if (!bootstrap.needsAdminSetup) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(credentials.password, 12)
|
||||||
|
const created = await createUser({
|
||||||
|
username: createBootstrapUsername(adminEmail),
|
||||||
|
email: adminEmail,
|
||||||
|
passwordHash
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
...created,
|
||||||
|
passwordHash
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 관리자 로그인 API
|
* 관리자 로그인 API
|
||||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||||
@@ -27,7 +69,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
|
|
||||||
const body = parsedBody.data
|
const body = parsedBody.data
|
||||||
|
|
||||||
const adminUser = await getAdminUserByEmail(body.email)
|
const adminUser = await getAdminUserByEmail(body.email) || await createBootstrapAdminUser(body)
|
||||||
const passwordMatched = adminUser
|
const passwordMatched = adminUser
|
||||||
? await bcrypt.compare(body.password, adminUser.passwordHash)
|
? await bcrypt.compare(body.password, adminUser.passwordHash)
|
||||||
: false
|
: false
|
||||||
|
|||||||
Reference in New Issue
Block a user