feat: 로그인 페이지 추가 (Basic Auth 제거)
- 산뜻한 로그인 페이지 (다크 테마 + 글로우 배경) - 쿠키 기반 세션 인증 (24시간 유지) - 로그아웃 버튼 (사이드바 + 상단바) - 미인증 시 로그인 페이지로 리다이렉트 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
+2
-1
@@ -16,6 +16,7 @@
|
||||
"axios": "^1.7.0",
|
||||
"ejs": "^3.1.10",
|
||||
"dotenv": "^16.4.0",
|
||||
"https-proxy-agent": "^7.0.0"
|
||||
"https-proxy-agent": "^7.0.0",
|
||||
"cookie-parser": "^1.4.6"
|
||||
}
|
||||
}
|
||||
|
||||
+59
-14
@@ -1,6 +1,8 @@
|
||||
require('dotenv').config();
|
||||
|
||||
const crypto = require('crypto');
|
||||
const express = require('express');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const path = require('path');
|
||||
const db = require('./db');
|
||||
const apiRouter = require('./routes/api');
|
||||
@@ -10,9 +12,15 @@ const { initScheduler } = require('./services/scheduler');
|
||||
const app = express();
|
||||
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.urlencoded({ extended: true }));
|
||||
app.use(cookieParser());
|
||||
|
||||
// 정적 파일
|
||||
app.use('/public', express.static(path.join(__dirname, '..', 'public')));
|
||||
@@ -24,23 +32,62 @@ app.set('views', path.join(__dirname, '..', 'views'));
|
||||
// ===== 도메인 기반 라우팅 (최우선) =====
|
||||
app.use(domainRouter);
|
||||
|
||||
// ===== Basic Auth (관리자 영역만) =====
|
||||
// ===== 인증 미들웨어 =====
|
||||
function adminAuth(req, res, next) {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader) {
|
||||
res.setHeader('WWW-Authenticate', 'Basic realm="Crawl Manager Admin"');
|
||||
return res.status(401).send('인증이 필요합니다');
|
||||
}
|
||||
|
||||
const [user, pass] = Buffer.from(authHeader.split(' ')[1], 'base64').toString().split(':');
|
||||
if (user === process.env.ADMIN_USER && pass === process.env.ADMIN_PASS) {
|
||||
const token = req.cookies[SESSION_COOKIE];
|
||||
if (token && sessions.has(token)) {
|
||||
const session = sessions.get(token);
|
||||
if (Date.now() < session.expires) {
|
||||
return next();
|
||||
}
|
||||
|
||||
res.setHeader('WWW-Authenticate', 'Basic realm="Crawl Manager Admin"');
|
||||
res.status(401).send('인증 실패');
|
||||
sessions.delete(token);
|
||||
}
|
||||
// 로그인 페이지로 리다이렉트
|
||||
const returnUrl = req.originalUrl;
|
||||
res.redirect('/login?redirect=' + encodeURIComponent(returnUrl));
|
||||
}
|
||||
|
||||
// ===== 로그인 / 로그아웃 =====
|
||||
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'));
|
||||
|
||||
@@ -78,11 +125,9 @@ app.use('/', publicRouter);
|
||||
// ===== 시작 =====
|
||||
async function start() {
|
||||
try {
|
||||
// DB 연결 대기 (재시도)
|
||||
await db.waitForDB();
|
||||
console.log('[DB] PostgreSQL 연결 성공');
|
||||
|
||||
// 스케줄러 초기화
|
||||
await initScheduler();
|
||||
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
|
||||
@@ -101,12 +101,18 @@ tr:hover td{background:rgba(255,255,255,.02)}
|
||||
<a href="/admin/adsense" class="<%= typeof page !== 'undefined' && page === 'adsense' ? 'active' : '' %>">💰 AdSense 관리</a>
|
||||
<a href="/admin/domains" class="<%= typeof page !== 'undefined' && page === 'domains' ? 'active' : '' %>">🔗 도메인 매핑</a>
|
||||
<a href="/admin/logs" class="<%= typeof page !== 'undefined' && page === 'logs' ? 'active' : '' %>">📝 로그</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)">🚪 로그아웃</a>
|
||||
</div>
|
||||
</nav>
|
||||
</aside>
|
||||
<div class="main">
|
||||
<div class="topbar">
|
||||
<h1><%= typeof pageTitle !== 'undefined' ? pageTitle : '' %></h1>
|
||||
<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 class="content">
|
||||
<%- body %>
|
||||
|
||||
@@ -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>⚠</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>
|
||||
Reference in New Issue
Block a user