feat: 로그인 페이지 추가 (Basic Auth 제거)

- 산뜻한 로그인 페이지 (다크 테마 + 글로우 배경)
- 쿠키 기반 세션 인증 (24시간 유지)
- 로그아웃 버튼 (사이드바 + 상단바)
- 미인증 시 로그인 페이지로 리다이렉트

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
chpark
2026-03-27 01:57:38 +09:00
parent 6257025c8e
commit e1f7f1f2ad
4 changed files with 151 additions and 17 deletions
+2 -1
View File
@@ -16,6 +16,7 @@
"axios": "^1.7.0", "axios": "^1.7.0",
"ejs": "^3.1.10", "ejs": "^3.1.10",
"dotenv": "^16.4.0", "dotenv": "^16.4.0",
"https-proxy-agent": "^7.0.0" "https-proxy-agent": "^7.0.0",
"cookie-parser": "^1.4.6"
} }
} }
+60 -15
View File
@@ -1,6 +1,8 @@
require('dotenv').config(); require('dotenv').config();
const crypto = require('crypto');
const express = require('express'); const express = require('express');
const cookieParser = require('cookie-parser');
const path = require('path'); const path = require('path');
const db = require('./db'); const db = require('./db');
const apiRouter = require('./routes/api'); const apiRouter = require('./routes/api');
@@ -10,9 +12,15 @@ const { initScheduler } = require('./services/scheduler');
const app = express(); const app = express();
const PORT = process.env.PORT || 3000; const PORT = process.env.PORT || 3000;
// 세션 토큰 저장소 (메모리 - 재시작 시 로그아웃됨)
const sessions = new Map();
const SESSION_COOKIE = 'cm_session';
const SESSION_MAX_AGE = 24 * 60 * 60 * 1000; // 24시간
// ===== 미들웨어 ===== // ===== 미들웨어 =====
app.use(express.json({ limit: '50mb' })); app.use(express.json({ limit: '50mb' }));
app.use(express.urlencoded({ extended: true })); app.use(express.urlencoded({ extended: true }));
app.use(cookieParser());
// 정적 파일 // 정적 파일
app.use('/public', express.static(path.join(__dirname, '..', 'public'))); app.use('/public', express.static(path.join(__dirname, '..', 'public')));
@@ -24,23 +32,62 @@ app.set('views', path.join(__dirname, '..', 'views'));
// ===== 도메인 기반 라우팅 (최우선) ===== // ===== 도메인 기반 라우팅 (최우선) =====
app.use(domainRouter); app.use(domainRouter);
// ===== Basic Auth (관리자 영역만) ===== // ===== 인증 미들웨어 =====
function adminAuth(req, res, next) { function adminAuth(req, res, next) {
const authHeader = req.headers.authorization; const token = req.cookies[SESSION_COOKIE];
if (!authHeader) { if (token && sessions.has(token)) {
res.setHeader('WWW-Authenticate', 'Basic realm="Crawl Manager Admin"'); const session = sessions.get(token);
return res.status(401).send('인증이 필요합니다'); if (Date.now() < session.expires) {
return next();
}
sessions.delete(token);
} }
// 로그인 페이지로 리다이렉트
const [user, pass] = Buffer.from(authHeader.split(' ')[1], 'base64').toString().split(':'); const returnUrl = req.originalUrl;
if (user === process.env.ADMIN_USER && pass === process.env.ADMIN_PASS) { res.redirect('/login?redirect=' + encodeURIComponent(returnUrl));
return next();
}
res.setHeader('WWW-Authenticate', 'Basic realm="Crawl Manager Admin"');
res.status(401).send('인증 실패');
} }
// ===== 로그인 / 로그아웃 =====
app.get('/login', (req, res) => {
// 이미 로그인된 상태면 관리자로 이동
const token = req.cookies[SESSION_COOKIE];
if (token && sessions.has(token) && Date.now() < sessions.get(token).expires) {
return res.redirect('/admin');
}
res.render('admin/login', { error: null, redirect: req.query.redirect || '/admin' });
});
app.post('/login', (req, res) => {
const { email, password, redirect } = req.body;
const redirectUrl = redirect || '/admin';
if (email === process.env.ADMIN_USER && password === process.env.ADMIN_PASS) {
const token = crypto.randomBytes(32).toString('hex');
sessions.set(token, {
user: email,
expires: Date.now() + SESSION_MAX_AGE,
});
res.cookie(SESSION_COOKIE, token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: SESSION_MAX_AGE,
});
return res.redirect(redirectUrl);
}
res.render('admin/login', { error: '이메일 또는 비밀번호가 올바르지 않습니다.', redirect: redirectUrl });
});
app.get('/logout', (req, res) => {
const token = req.cookies[SESSION_COOKIE];
if (token) sessions.delete(token);
res.clearCookie(SESSION_COOKIE);
res.redirect('/login');
});
// ===== 루트 → 관리자로 리다이렉트 ===== // ===== 루트 → 관리자로 리다이렉트 =====
app.get('/', (req, res) => res.redirect('/admin')); app.get('/', (req, res) => res.redirect('/admin'));
@@ -78,11 +125,9 @@ app.use('/', publicRouter);
// ===== 시작 ===== // ===== 시작 =====
async function start() { async function start() {
try { try {
// DB 연결 대기 (재시도)
await db.waitForDB(); await db.waitForDB();
console.log('[DB] PostgreSQL 연결 성공'); console.log('[DB] PostgreSQL 연결 성공');
// 스케줄러 초기화
await initScheduler(); await initScheduler();
app.listen(PORT, '0.0.0.0', () => { app.listen(PORT, '0.0.0.0', () => {
+7 -1
View File
@@ -101,12 +101,18 @@ tr:hover td{background:rgba(255,255,255,.02)}
<a href="/admin/adsense" class="<%= typeof page !== 'undefined' && page === 'adsense' ? 'active' : '' %>">&#x1f4b0; AdSense 관리</a> <a href="/admin/adsense" class="<%= typeof page !== 'undefined' && page === 'adsense' ? 'active' : '' %>">&#x1f4b0; AdSense 관리</a>
<a href="/admin/domains" class="<%= typeof page !== 'undefined' && page === 'domains' ? 'active' : '' %>">&#x1f517; 도메인 매핑</a> <a href="/admin/domains" class="<%= typeof page !== 'undefined' && page === 'domains' ? 'active' : '' %>">&#x1f517; 도메인 매핑</a>
<a href="/admin/logs" class="<%= typeof page !== 'undefined' && page === 'logs' ? 'active' : '' %>">&#x1f4dd; 로그</a> <a href="/admin/logs" class="<%= typeof page !== 'undefined' && page === 'logs' ? 'active' : '' %>">&#x1f4dd; 로그</a>
<div style="border-top:1px solid var(--border);margin-top:auto;padding-top:.5rem;margin-top:1rem">
<a href="/logout" style="color:var(--danger)">&#x1f6aa; 로그아웃</a>
</div>
</nav> </nav>
</aside> </aside>
<div class="main"> <div class="main">
<div class="topbar"> <div class="topbar">
<h1><%= typeof pageTitle !== 'undefined' ? pageTitle : '' %></h1> <h1><%= typeof pageTitle !== 'undefined' ? pageTitle : '' %></h1>
<span class="text-muted" style="font-size:.8rem">Crawl Manager v1.0</span> <div class="flex" style="gap:1rem">
<span class="text-muted" style="font-size:.8rem">Crawl Manager v1.0</span>
<a href="/logout" class="btn btn-outline btn-sm" style="font-size:.75rem">로그아웃</a>
</div>
</div> </div>
<div class="content"> <div class="content">
<%- body %> <%- body %>
+82
View File
@@ -0,0 +1,82 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Crawl Manager - 로그인</title>
<style>
:root{--bg:#0f172a;--card:#1e293b;--border:#334155;--primary:#6366f1;--primary-hover:#818cf8;--text:#f1f5f9;--muted:#94a3b8;--danger:#ef4444;--radius:12px}
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','Noto Sans KR',sans-serif;background:var(--bg);color:var(--text);min-height:100vh;display:flex;align-items:center;justify-content:center;overflow:hidden}
/* 배경 애니메이션 */
.bg-glow{position:fixed;top:-50%;left:-50%;width:200%;height:200%;z-index:0}
.bg-glow::before,.bg-glow::after{content:'';position:absolute;border-radius:50%;filter:blur(80px);opacity:.15}
.bg-glow::before{top:20%;left:30%;width:400px;height:400px;background:#6366f1;animation:float 8s ease-in-out infinite}
.bg-glow::after{bottom:20%;right:30%;width:350px;height:350px;background:#06b6d4;animation:float 8s ease-in-out infinite reverse}
@keyframes float{0%,100%{transform:translate(0,0)}50%{transform:translate(30px,-30px)}}
.login-container{position:relative;z-index:1;width:100%;max-width:420px;padding:1rem}
.login-card{background:var(--card);border:1px solid var(--border);border-radius:16px;padding:2.5rem 2rem;box-shadow:0 25px 50px rgba(0,0,0,.3)}
.logo{text-align:center;margin-bottom:2rem}
.logo .icon{width:56px;height:56px;background:linear-gradient(135deg,#6366f1,#8b5cf6);border-radius:14px;display:inline-flex;align-items:center;justify-content:center;font-size:1.5rem;margin-bottom:1rem;box-shadow:0 8px 20px rgba(99,102,241,.3)}
.logo h1{font-size:1.3rem;font-weight:700;letter-spacing:-.5px}
.logo p{color:var(--muted);font-size:.82rem;margin-top:.3rem}
.form-group{margin-bottom:1.2rem}
.form-group label{display:block;font-size:.8rem;color:var(--muted);margin-bottom:.4rem;font-weight:500}
.form-group input{width:100%;padding:.75rem 1rem;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius);color:var(--text);font-size:.92rem;transition:border-color .2s;outline:none}
.form-group input:focus{border-color:var(--primary);box-shadow:0 0 0 3px rgba(99,102,241,.15)}
.form-group input::placeholder{color:#475569}
.error-msg{background:rgba(239,68,68,.1);border:1px solid rgba(239,68,68,.25);border-radius:8px;padding:.6rem 1rem;margin-bottom:1.2rem;font-size:.82rem;color:var(--danger);display:flex;align-items:center;gap:.5rem}
.btn-login{width:100%;padding:.8rem;background:linear-gradient(135deg,#6366f1,#7c3aed);border:none;border-radius:var(--radius);color:#fff;font-size:.95rem;font-weight:600;cursor:pointer;transition:all .2s;margin-top:.5rem}
.btn-login:hover{background:linear-gradient(135deg,#818cf8,#8b5cf6);transform:translateY(-1px);box-shadow:0 8px 20px rgba(99,102,241,.3)}
.btn-login:active{transform:translateY(0)}
.footer-text{text-align:center;margin-top:1.5rem;font-size:.75rem;color:#475569}
</style>
</head>
<body>
<div class="bg-glow"></div>
<div class="login-container">
<div class="login-card">
<div class="logo">
<div class="icon">CM</div>
<h1>Crawl Manager</h1>
<p>관리자 로그인</p>
</div>
<% if (error) { %>
<div class="error-msg">
<span>&#x26a0;</span> <%= error %>
</div>
<% } %>
<form method="POST" action="/login">
<input type="hidden" name="redirect" value="<%= redirect %>">
<div class="form-group">
<label>이메일</label>
<input type="email" name="email" placeholder="admin@example.com" required autofocus>
</div>
<div class="form-group">
<label>비밀번호</label>
<input type="password" name="password" placeholder="비밀번호를 입력하세요" required>
</div>
<button type="submit" class="btn-login">로그인</button>
</form>
<div class="footer-text">Crawl Manager v1.0</div>
</div>
</div>
</body>
</html>