작업계획서대로 진행

This commit is contained in:
2026-01-11 10:50:51 +09:00
parent 5cda181cc5
commit d4620dc1fa
39 changed files with 3344 additions and 120 deletions

View File

@@ -0,0 +1,66 @@
import { query, execute } from '../../utils/db'
import { getClientIp } from '../../utils/ip'
import { getCurrentUser } from '../../utils/session'
import { hashPassword, verifyPassword } from '../../utils/password'
interface ChangePasswordBody {
currentPassword?: string
newPassword: string
confirmPassword: string
}
/**
* 비밀번호 변경
* POST /api/auth/change-password
*/
export default defineEventHandler(async (event) => {
const user = await getCurrentUser(event)
if (!user) {
throw createError({ statusCode: 401, message: '로그인이 필요합니다.' })
}
const body = await readBody<ChangePasswordBody>(event)
const clientIp = getClientIp(event)
if (!body.newPassword || !body.confirmPassword) {
throw createError({ statusCode: 400, message: '새 비밀번호를 입력해주세요.' })
}
if (body.newPassword !== body.confirmPassword) {
throw createError({ statusCode: 400, message: '비밀번호가 일치하지 않습니다.' })
}
if (body.newPassword.length < 8) {
throw createError({ statusCode: 400, message: '비밀번호는 8자 이상이어야 합니다.' })
}
// 현재 직원 정보 조회
const employees = await query<any>(`
SELECT password_hash FROM wr_employee_info WHERE employee_id = $1
`, [user.employeeId])
const employee = employees[0]
// 기존 비밀번호가 있으면 현재 비밀번호 검증
if (employee?.password_hash) {
if (!body.currentPassword) {
throw createError({ statusCode: 400, message: '현재 비밀번호를 입력해주세요.' })
}
const isValid = await verifyPassword(body.currentPassword, employee.password_hash)
if (!isValid) {
throw createError({ statusCode: 401, message: '현재 비밀번호가 올바르지 않습니다.' })
}
}
// 새 비밀번호 해시
const newHash = await hashPassword(body.newPassword)
// 비밀번호 업데이트
await execute(`
UPDATE wr_employee_info
SET password_hash = $1, updated_at = NOW(), updated_ip = $2, updated_email = $3
WHERE employee_id = $4
`, [newHash, clientIp, user.employeeEmail, user.employeeId])
return { success: true, message: '비밀번호가 변경되었습니다.' }
})

View File

@@ -0,0 +1,127 @@
import { query, execute, insertReturning } from '../../../utils/db'
import { getClientIp } from '../../../utils/ip'
import { createSession, setSessionCookie } from '../../../utils/session'
/**
* Google OAuth 콜백
* GET /api/auth/google/callback
*/
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
const params = getQuery(event)
const clientIp = getClientIp(event)
const userAgent = getHeader(event, 'user-agent') || null
const code = params.code as string
const state = params.state as string
const error = params.error as string
if (error) {
return sendRedirect(event, '/login?error=oauth_denied')
}
// State 검증
const savedState = getCookie(event, 'oauth_state')
if (!savedState || savedState !== state) {
return sendRedirect(event, '/login?error=invalid_state')
}
deleteCookie(event, 'oauth_state')
const clientId = config.googleClientId || process.env.GOOGLE_CLIENT_ID
const clientSecret = config.googleClientSecret || process.env.GOOGLE_CLIENT_SECRET
const redirectUri = config.googleRedirectUri || process.env.GOOGLE_REDIRECT_URI || 'http://localhost:3000/api/auth/google/callback'
if (!clientId || !clientSecret) {
return sendRedirect(event, '/login?error=oauth_not_configured')
}
try {
// 토큰 교환
const tokenRes = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
code,
client_id: clientId,
client_secret: clientSecret,
redirect_uri: redirectUri,
grant_type: 'authorization_code'
})
})
const tokenData = await tokenRes.json()
if (!tokenData.access_token) {
console.error('Token error:', tokenData)
return sendRedirect(event, '/login?error=token_failed')
}
// 사용자 정보 조회
const userRes = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', {
headers: { Authorization: `Bearer ${tokenData.access_token}` }
})
const googleUser = await userRes.json()
if (!googleUser.email) {
return sendRedirect(event, '/login?error=no_email')
}
const googleId = googleUser.id
const googleEmail = googleUser.email.toLowerCase()
const googleName = googleUser.name || googleEmail.split('@')[0]
// 기존 사용자 조회 (이메일 기준)
let employees = await query<any>(`
SELECT * FROM wr_employee_info WHERE employee_email = $1
`, [googleEmail])
let employee = employees[0]
if (employee) {
// 기존 사용자 - Google 정보 연결
await execute(`
UPDATE wr_employee_info SET
google_id = $1,
google_email = $2,
google_linked_at = NOW(),
google_access_token = $3,
google_refresh_token = $4,
google_token_expires_at = NOW() + INTERVAL '${tokenData.expires_in} seconds',
last_login_at = NOW(),
last_login_ip = $5,
updated_at = NOW()
WHERE employee_id = $6
`, [googleId, googleEmail, tokenData.access_token, tokenData.refresh_token || null, clientIp, employee.employee_id])
} else {
// 신규 사용자 자동 등록
employee = await insertReturning(`
INSERT INTO wr_employee_info (
employee_name, employee_email, google_id, google_email, google_linked_at,
google_access_token, google_refresh_token, google_token_expires_at,
last_login_at, last_login_ip, created_ip, created_email
) VALUES ($1, $2, $3, $2, NOW(), $4, $5, NOW() + INTERVAL '${tokenData.expires_in} seconds', NOW(), $6, $6, $2)
RETURNING *
`, [googleName, googleEmail, googleId, tokenData.access_token, tokenData.refresh_token || null, clientIp])
}
// 로그인 이력 추가
const loginHistory = await insertReturning(`
INSERT INTO wr_login_history (employee_id, login_ip, login_email, login_type)
VALUES ($1, $2, $3, 'GOOGLE')
RETURNING history_id
`, [employee.employee_id, clientIp, googleEmail])
// 세션 생성
const sessionId = await createSession(
employee.employee_id,
loginHistory.history_id,
clientIp,
userAgent
)
setSessionCookie(event, sessionId)
return sendRedirect(event, '/')
} catch (e) {
console.error('Google OAuth error:', e)
return sendRedirect(event, '/login?error=oauth_failed')
}
})

View File

@@ -0,0 +1,35 @@
/**
* Google OAuth 시작
* GET /api/auth/google
*/
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
const clientId = config.googleClientId || process.env.GOOGLE_CLIENT_ID
const redirectUri = config.googleRedirectUri || process.env.GOOGLE_REDIRECT_URI || 'http://localhost:3000/api/auth/google/callback'
if (!clientId) {
throw createError({ statusCode: 500, message: 'Google OAuth가 설정되지 않았습니다.' })
}
const scope = encodeURIComponent('openid email profile')
const state = Math.random().toString(36).substring(7) // CSRF 방지
// state를 쿠키에 저장
setCookie(event, 'oauth_state', state, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
maxAge: 300 // 5분
})
const authUrl = `https://accounts.google.com/o/oauth2/v2/auth?` +
`client_id=${clientId}` +
`&redirect_uri=${encodeURIComponent(redirectUri)}` +
`&response_type=code` +
`&scope=${scope}` +
`&state=${state}` +
`&access_type=offline` +
`&prompt=consent`
return sendRedirect(event, authUrl)
})

View File

@@ -0,0 +1,82 @@
import { query, execute, insertReturning } from '../../utils/db'
import { getClientIp } from '../../utils/ip'
import { createSession, setSessionCookie } from '../../utils/session'
import { verifyPassword } from '../../utils/password'
interface LoginBody {
email: string
password: string
}
/**
* 비밀번호 로그인
* POST /api/auth/login-password
*/
export default defineEventHandler(async (event) => {
const body = await readBody<LoginBody>(event)
const clientIp = getClientIp(event)
const userAgent = getHeader(event, 'user-agent') || null
if (!body.email || !body.password) {
throw createError({ statusCode: 400, message: '이메일과 비밀번호를 입력해주세요.' })
}
const emailLower = body.email.toLowerCase()
// 직원 조회
const employees = await query<any>(`
SELECT * FROM wr_employee_info WHERE employee_email = $1 AND is_active = true
`, [emailLower])
if (employees.length === 0) {
throw createError({ statusCode: 401, message: '이메일 또는 비밀번호가 올바르지 않습니다.' })
}
const employee = employees[0]
// 비밀번호 미설정
if (!employee.password_hash) {
throw createError({ statusCode: 401, message: '비밀번호가 설정되지 않았습니다. 관리자에게 문의하세요.' })
}
// 비밀번호 검증
const isValid = await verifyPassword(body.password, employee.password_hash)
if (!isValid) {
throw createError({ statusCode: 401, message: '이메일 또는 비밀번호가 올바르지 않습니다.' })
}
// 마지막 로그인 시간 업데이트
await execute(`
UPDATE wr_employee_info
SET last_login_at = NOW(), last_login_ip = $1, updated_at = NOW()
WHERE employee_id = $2
`, [clientIp, employee.employee_id])
// 로그인 이력 추가
const loginHistory = await insertReturning(`
INSERT INTO wr_login_history (employee_id, login_ip, login_email, login_type)
VALUES ($1, $2, $3, 'PASSWORD')
RETURNING history_id
`, [employee.employee_id, clientIp, emailLower])
// 세션 생성
const sessionId = await createSession(
employee.employee_id,
loginHistory.history_id,
clientIp,
userAgent
)
setSessionCookie(event, sessionId)
return {
success: true,
user: {
employeeId: employee.employee_id,
employeeName: employee.employee_name,
employeeEmail: employee.employee_email,
employeePosition: employee.employee_position,
company: employee.company
}
}
})

View File

@@ -0,0 +1,78 @@
import { query, execute } from '../../utils/db'
import { hashPassword, generateTempPassword } from '../../utils/password'
import { sendTempPasswordEmail } from '../../utils/email'
import { getClientIp } from '../../utils/ip'
interface ResetPasswordBody {
email: string
name: string
phone?: string
}
/**
* 비밀번호 찾기 (임시 비밀번호 발급)
* POST /api/auth/reset-password
*/
export default defineEventHandler(async (event) => {
const body = await readBody<ResetPasswordBody>(event)
const clientIp = getClientIp(event)
if (!body.email || !body.name) {
throw createError({ statusCode: 400, message: '이메일과 이름을 입력해주세요.' })
}
const emailLower = body.email.toLowerCase()
const nameTrimmed = body.name.trim()
// 사용자 조회 (이메일 + 이름)
let sql = `SELECT * FROM wr_employee_info WHERE employee_email = $1 AND employee_name = $2`
const params: any[] = [emailLower, nameTrimmed]
// 핸드폰 번호도 제공된 경우 추가 검증
if (body.phone) {
const phoneClean = body.phone.replace(/[^0-9]/g, '')
sql += ` AND REPLACE(employee_phone, '-', '') = $3`
params.push(phoneClean)
}
const employees = await query<any>(sql, params)
if (employees.length === 0) {
// 보안상 동일한 메시지 반환
throw createError({ statusCode: 400, message: '입력하신 정보와 일치하는 계정을 찾을 수 없습니다.' })
}
const employee = employees[0]
// 임시 비밀번호 생성
const tempPassword = generateTempPassword()
const hash = await hashPassword(tempPassword)
// 비밀번호 업데이트
await execute(`
UPDATE wr_employee_info
SET password_hash = $1, updated_at = NOW(), updated_ip = $2
WHERE employee_id = $3
`, [hash, clientIp, employee.employee_id])
// 이메일 발송
const emailSent = await sendTempPasswordEmail(
employee.employee_email,
employee.employee_name,
tempPassword
)
if (!emailSent) {
// 이메일 발송 실패해도 비밀번호는 이미 변경됨
console.warn('임시 비밀번호 이메일 발송 실패:', employee.employee_email)
}
// 보안상 이메일 일부만 표시
const maskedEmail = employee.employee_email.replace(/(.{2})(.*)(@.*)/, '$1***$3')
return {
success: true,
message: `임시 비밀번호가 ${maskedEmail}로 발송되었습니다.`,
emailSent
}
})

View File

@@ -0,0 +1,79 @@
import { query, execute } from '../../utils/db'
import { getClientIp } from '../../utils/ip'
import { getCurrentUser } from '../../utils/session'
import { hashPassword, generateTempPassword } from '../../utils/password'
interface SetPasswordBody {
employeeId: number
password?: string
generateTemp?: boolean
}
/**
* 직원 비밀번호 설정 (관리자용)
* POST /api/auth/set-password
*/
export default defineEventHandler(async (event) => {
const user = await getCurrentUser(event)
if (!user) {
throw createError({ statusCode: 401, message: '로그인이 필요합니다.' })
}
// 권한 확인 (ROLE_ADMIN만)
const roles = await query<any>(`
SELECT role_code FROM wr_employee_role WHERE employee_id = $1
`, [user.employeeId])
const isAdmin = roles.some((r: any) => r.role_code === 'ROLE_ADMIN')
if (!isAdmin) {
throw createError({ statusCode: 403, message: '관리자 권한이 필요합니다.' })
}
const body = await readBody<SetPasswordBody>(event)
const clientIp = getClientIp(event)
if (!body.employeeId) {
throw createError({ statusCode: 400, message: '직원 ID가 필요합니다.' })
}
let password = body.password
// 임시 비밀번호 생성
if (body.generateTemp || !password) {
password = generateTempPassword()
}
if (password.length < 8) {
throw createError({ statusCode: 400, message: '비밀번호는 8자 이상이어야 합니다.' })
}
// 대상 직원 존재 확인
const employees = await query<any>(`
SELECT employee_id, employee_name, employee_email FROM wr_employee_info WHERE employee_id = $1
`, [body.employeeId])
if (employees.length === 0) {
throw createError({ statusCode: 404, message: '직원을 찾을 수 없습니다.' })
}
const targetEmployee = employees[0]
// 비밀번호 해시
const hash = await hashPassword(password)
// 업데이트
await execute(`
UPDATE wr_employee_info
SET password_hash = $1, updated_at = NOW(), updated_ip = $2, updated_email = $3
WHERE employee_id = $4
`, [hash, clientIp, user.employeeEmail, body.employeeId])
return {
success: true,
employeeId: targetEmployee.employee_id,
employeeName: targetEmployee.employee_name,
employeeEmail: targetEmployee.employee_email,
tempPassword: body.generateTemp ? password : undefined,
message: body.generateTemp ? '임시 비밀번호가 생성되었습니다.' : '비밀번호가 설정되었습니다.'
}
})