OAuth 인증 처리 로직 개선 및 토큰 관리 기능 추가
This commit is contained in:
parent
152f3ec045
commit
48af411c99
2 changed files with 324 additions and 8 deletions
|
|
@ -2,16 +2,69 @@
|
|||
<html>
|
||||
<head>
|
||||
<title>OAuth Callback</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; margin: 40px; }
|
||||
.status { padding: 10px; margin: 10px 0; border-radius: 5px; }
|
||||
.success { background-color: #d4edda; color: #155724; border: 1px solid #c3e6cb; }
|
||||
.error { background-color: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; }
|
||||
.info { background-color: #d1ecf1; color: #0c5460; border: 1px solid #bee5eb; }
|
||||
pre { background-color: #f8f9fa; padding: 15px; border-radius: 5px; border: 1px solid #dee2e6; overflow-x: auto; white-space: pre-wrap; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h2>처리 중입니다...</h2>
|
||||
<h2>OAuth 인증 처리 중...</h2>
|
||||
<div id="status" class="status info">처리 중입니다...</div>
|
||||
|
||||
<script>
|
||||
const hash = window.location.hash.substring(1); // '#access_token=...'
|
||||
const params = new URLSearchParams(hash);
|
||||
const accessToken = params.get("access_token");
|
||||
async function handleCallback() {
|
||||
try {
|
||||
// URL에서 authorization code 또는 access token 확인
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const hash = window.location.hash.substring(1);
|
||||
const hashParams = new URLSearchParams(hash);
|
||||
|
||||
const authCode = urlParams.get("code");
|
||||
const accessToken = hashParams.get("access_token");
|
||||
const error = urlParams.get("error");
|
||||
|
||||
location.href = "/token?access_token=" + accessToken;
|
||||
const statusDiv = document.getElementById("status");
|
||||
|
||||
if (error) {
|
||||
statusDiv.className = "status error";
|
||||
statusDiv.innerHTML = `오류 발생: ${error}`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (authCode) {
|
||||
// Authorization Code 방식 (새로운 방식 - 리프레시 토큰 포함)
|
||||
statusDiv.innerHTML = "Authorization Code를 처리하는 중...";
|
||||
|
||||
const response = await fetch(`/exchange?code=${authCode}`);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
statusDiv.className = "status success";
|
||||
statusDiv.innerHTML = `<h3>✅ 인증 성공!</h3><pre>${JSON.stringify(result, null, 2)}</pre>`;
|
||||
} else {
|
||||
statusDiv.className = "status error";
|
||||
statusDiv.innerHTML = `<h3>❌ 토큰 교환 실패</h3><pre>${JSON.stringify(result, null, 2)}</pre>`;
|
||||
}
|
||||
} else if (accessToken) {
|
||||
// 기존 방식 (Access Token 직접 받기)
|
||||
statusDiv.innerHTML = "Access Token을 처리하는 중...";
|
||||
location.href = "/token?access_token=" + accessToken;
|
||||
} else {
|
||||
statusDiv.className = "status error";
|
||||
statusDiv.innerHTML = "Authorization Code나 Access Token을 찾을 수 없습니다.";
|
||||
}
|
||||
} catch (error) {
|
||||
document.getElementById("status").className = "status error";
|
||||
document.getElementById("status").innerHTML = `처리 중 오류 발생: ${error.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
// 페이지 로드 시 콜백 처리
|
||||
handleCallback();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
269
server.js
269
server.js
|
|
@ -2,9 +2,81 @@ const express = require("express");
|
|||
const app = express();
|
||||
const path = require("path");
|
||||
const fetch = require("node-fetch");
|
||||
const cron = require("node-cron");
|
||||
const fs = require("fs");
|
||||
|
||||
app.use(express.json()); // JSON 본문 파싱
|
||||
|
||||
// 토큰 저장소 (실제 환경에서는 데이터베이스 사용 권장)
|
||||
let tokenStorage = {
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
expiresAt: null,
|
||||
userInfo: null
|
||||
};
|
||||
|
||||
// 토큰을 파일에 저장하는 함수
|
||||
function saveTokensToFile() {
|
||||
try {
|
||||
fs.writeFileSync('./tokens.json', JSON.stringify(tokenStorage, null, 2));
|
||||
console.log('✅ 토큰이 파일에 저장되었습니다.');
|
||||
} catch (error) {
|
||||
console.error('❌ 토큰 저장 실패:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 파일에서 토큰을 로드하는 함수
|
||||
function loadTokensFromFile() {
|
||||
try {
|
||||
if (fs.existsSync('./tokens.json')) {
|
||||
const data = fs.readFileSync('./tokens.json', 'utf8');
|
||||
tokenStorage = JSON.parse(data);
|
||||
console.log('✅ 저장된 토큰을 로드했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ 토큰 로드 실패:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 액세스 토큰 갱신 함수
|
||||
async function refreshAccessToken() {
|
||||
if (!tokenStorage.refreshToken) {
|
||||
console.log('⚠️ 리프레시 토큰이 없습니다.');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('https://oauth2.googleapis.com/token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
client_id: "16435018183-9a880bertda0en85387ge8f8mgsves71.apps.googleusercontent.com",
|
||||
client_secret: "YOUR_CLIENT_SECRET", // 실제 클라이언트 시크릿으로 변경 필요
|
||||
refresh_token: tokenStorage.refreshToken,
|
||||
grant_type: 'refresh_token'
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.access_token) {
|
||||
tokenStorage.accessToken = data.access_token;
|
||||
tokenStorage.expiresAt = Date.now() + (data.expires_in * 1000);
|
||||
saveTokensToFile();
|
||||
console.log('🔄 액세스 토큰이 갱신되었습니다:', new Date().toLocaleString());
|
||||
return true;
|
||||
} else {
|
||||
console.error('❌ 토큰 갱신 실패:', data);
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ 토큰 갱신 중 오류:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
app.get("/", (req, res) => {
|
||||
const clientId = "16435018183-9a880bertda0en85387ge8f8mgsves71.apps.googleusercontent.com"; // 반드시 수정
|
||||
const redirectUri = "https://google-oauth-access-token-whs.hako.li/callback";
|
||||
|
|
@ -12,12 +84,93 @@ app.get("/", (req, res) => {
|
|||
const authUrl = "https://accounts.google.com/o/oauth2/v2/auth?" +
|
||||
`client_id=${clientId}` +
|
||||
`&redirect_uri=${redirectUri}` +
|
||||
`&response_type=token` +
|
||||
`&scope=email%20profile`;
|
||||
`&response_type=code` + // code로 변경하여 리프레시 토큰도 받을 수 있도록
|
||||
`&scope=email%20profile` +
|
||||
`&access_type=offline` + // 리프레시 토큰을 받기 위해 필요
|
||||
`&prompt=consent`; // 매번 동의 화면을 표시하여 리프레시 토큰 확보
|
||||
res.redirect(authUrl);
|
||||
});
|
||||
|
||||
// Access Token 수신용 엔드포인트
|
||||
// Authorization Code를 Access Token으로 교환하는 엔드포인트
|
||||
app.get("/exchange", async (req, res) => {
|
||||
const code = req.query.code;
|
||||
|
||||
if (!code) {
|
||||
return res.status(400).send("Authorization code가 필요합니다.");
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('https://oauth2.googleapis.com/token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
client_id: "16435018183-9a880bertda0en85387ge8f8mgsves71.apps.googleusercontent.com",
|
||||
client_secret: "YOUR_CLIENT_SECRET", // 실제 클라이언트 시크릿으로 변경 필요
|
||||
code: code,
|
||||
grant_type: 'authorization_code',
|
||||
redirect_uri: "https://google-oauth-access-token-whs.hako.li/callback"
|
||||
})
|
||||
});
|
||||
|
||||
const tokenData = await response.json();
|
||||
|
||||
if (tokenData.access_token) {
|
||||
// 사용자 정보 가져오기
|
||||
const userResponse = await fetch("https://www.googleapis.com/oauth2/v3/userinfo", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokenData.access_token}`,
|
||||
},
|
||||
});
|
||||
const userInfo = await userResponse.json();
|
||||
|
||||
// 토큰 저장
|
||||
tokenStorage.accessToken = tokenData.access_token;
|
||||
tokenStorage.refreshToken = tokenData.refresh_token;
|
||||
tokenStorage.expiresAt = Date.now() + (tokenData.expires_in * 1000);
|
||||
tokenStorage.userInfo = userInfo;
|
||||
|
||||
saveTokensToFile();
|
||||
|
||||
console.log("📧 Email:", userInfo.email);
|
||||
console.log("👤 Name:", userInfo.name);
|
||||
console.log("🔑 Access Token:", tokenData.access_token);
|
||||
console.log("🔄 Refresh Token:", tokenData.refresh_token ? "받음" : "없음");
|
||||
|
||||
res.send({
|
||||
success: true,
|
||||
timestamp: new Date().toISOString(),
|
||||
user: {
|
||||
email: userInfo.email,
|
||||
name: userInfo.name,
|
||||
picture: userInfo.picture
|
||||
},
|
||||
tokens: {
|
||||
accessToken: tokenData.access_token,
|
||||
refreshToken: tokenData.refresh_token || null,
|
||||
tokenType: tokenData.token_type,
|
||||
expiresIn: tokenData.expires_in,
|
||||
expiresAt: new Date(tokenStorage.expiresAt).toISOString(),
|
||||
scope: tokenData.scope
|
||||
},
|
||||
storage: {
|
||||
saved: true,
|
||||
hasRefreshToken: !!tokenData.refresh_token,
|
||||
autoRefreshEnabled: !!tokenData.refresh_token
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.error("❌ 토큰 교환 실패:", tokenData);
|
||||
res.status(400).send("토큰 교환 실패");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("❌ Error:", err);
|
||||
res.status(500).send("Error");
|
||||
}
|
||||
});
|
||||
|
||||
// Access Token 수신용 엔드포인트 (기존 방식도 유지)
|
||||
app.get("/token", async (req, res) => {
|
||||
const token = req.query.access_token;
|
||||
try {
|
||||
|
|
@ -42,11 +195,121 @@ app.get("/token", async (req, res) => {
|
|||
}
|
||||
});
|
||||
|
||||
// 현재 저장된 토큰 정보 확인 엔드포인트
|
||||
app.get("/status", (req, res) => {
|
||||
const isExpired = tokenStorage.expiresAt ? Date.now() > tokenStorage.expiresAt : true;
|
||||
const timeUntilExpiry = tokenStorage.expiresAt ? Math.max(0, tokenStorage.expiresAt - Date.now()) : 0;
|
||||
|
||||
res.send({
|
||||
timestamp: new Date().toISOString(),
|
||||
tokens: {
|
||||
hasAccessToken: !!tokenStorage.accessToken,
|
||||
hasRefreshToken: !!tokenStorage.refreshToken,
|
||||
isExpired: isExpired,
|
||||
expiresAt: tokenStorage.expiresAt ? new Date(tokenStorage.expiresAt).toISOString() : null,
|
||||
timeUntilExpiryMs: timeUntilExpiry,
|
||||
timeUntilExpiryMinutes: Math.round(timeUntilExpiry / 1000 / 60)
|
||||
},
|
||||
user: tokenStorage.userInfo ? {
|
||||
email: tokenStorage.userInfo.email,
|
||||
name: tokenStorage.userInfo.name,
|
||||
picture: tokenStorage.userInfo.picture
|
||||
} : null,
|
||||
autoRefresh: {
|
||||
enabled: !!tokenStorage.refreshToken,
|
||||
cronSchedule: "*/30 * * * *",
|
||||
nextCheck: "Every 30 minutes"
|
||||
},
|
||||
server: {
|
||||
uptime: process.uptime(),
|
||||
pid: process.pid,
|
||||
version: process.version
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 수동으로 토큰 갱신하는 엔드포인트
|
||||
app.post("/refresh", async (req, res) => {
|
||||
const beforeRefresh = {
|
||||
accessToken: !!tokenStorage.accessToken,
|
||||
expiresAt: tokenStorage.expiresAt ? new Date(tokenStorage.expiresAt).toISOString() : null
|
||||
};
|
||||
|
||||
const success = await refreshAccessToken();
|
||||
|
||||
const afterRefresh = {
|
||||
accessToken: !!tokenStorage.accessToken,
|
||||
expiresAt: tokenStorage.expiresAt ? new Date(tokenStorage.expiresAt).toISOString() : null
|
||||
};
|
||||
|
||||
res.send({
|
||||
timestamp: new Date().toISOString(),
|
||||
success: success,
|
||||
message: success ? "토큰이 성공적으로 갱신되었습니다." : "토큰 갱신에 실패했습니다.",
|
||||
before: beforeRefresh,
|
||||
after: afterRefresh,
|
||||
hasRefreshToken: !!tokenStorage.refreshToken,
|
||||
user: tokenStorage.userInfo ? {
|
||||
email: tokenStorage.userInfo.email,
|
||||
name: tokenStorage.userInfo.name
|
||||
} : null
|
||||
});
|
||||
});
|
||||
|
||||
app.get("/callback", (req, res) => {
|
||||
res.sendFile(path.join(__dirname, "callback.html"));
|
||||
});
|
||||
|
||||
// 서버 시작 시 저장된 토큰 로드
|
||||
loadTokensFromFile();
|
||||
|
||||
// 크론 작업: 매 30분마다 토큰 만료 확인 및 갱신
|
||||
cron.schedule('*/30 * * * *', async () => {
|
||||
console.log('🕐 토큰 만료 확인 중...', new Date().toLocaleString());
|
||||
|
||||
if (!tokenStorage.accessToken) {
|
||||
console.log('⚠️ 저장된 액세스 토큰이 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 만료 10분 전에 갱신
|
||||
const timeUntilExpiry = tokenStorage.expiresAt - Date.now();
|
||||
const tenMinutes = 10 * 60 * 1000;
|
||||
|
||||
if (timeUntilExpiry <= tenMinutes) {
|
||||
console.log('⏰ 토큰이 곧 만료됩니다. 갱신을 시도합니다...');
|
||||
await refreshAccessToken();
|
||||
} else {
|
||||
console.log('✅ 토큰이 아직 유효합니다. 만료까지:', Math.round(timeUntilExpiry / 1000 / 60), '분');
|
||||
}
|
||||
});
|
||||
|
||||
// 크론 작업: 매일 오전 9시에 토큰 상태 리포트
|
||||
cron.schedule('0 9 * * *', () => {
|
||||
console.log('📊 일일 토큰 상태 리포트:', new Date().toLocaleString());
|
||||
console.log('- 액세스 토큰:', tokenStorage.accessToken ? '있음' : '없음');
|
||||
console.log('- 리프레시 토큰:', tokenStorage.refreshToken ? '있음' : '없음');
|
||||
console.log('- 사용자:', tokenStorage.userInfo?.email || '없음');
|
||||
|
||||
if (tokenStorage.expiresAt) {
|
||||
const isExpired = Date.now() > tokenStorage.expiresAt;
|
||||
console.log('- 만료 상태:', isExpired ? '만료됨' : '유효');
|
||||
console.log('- 만료 시간:', new Date(tokenStorage.expiresAt).toLocaleString());
|
||||
}
|
||||
});
|
||||
|
||||
console.log('⏰ 크론 작업이 설정되었습니다:');
|
||||
console.log('- 매 30분마다 토큰 만료 확인');
|
||||
console.log('- 매일 오전 9시 상태 리포트');
|
||||
|
||||
const PORT = 39090;
|
||||
app.listen(PORT, () => {
|
||||
console.log(`✅ Server running at http://localhost:${PORT}`);
|
||||
console.log(`📖 사용 가능한 엔드포인트:`);
|
||||
console.log(` GET / - OAuth 인증 시작`);
|
||||
console.log(` GET /exchange - Authorization Code를 토큰으로 교환`);
|
||||
console.log(` GET /token - 기존 방식 토큰 수신`);
|
||||
console.log(` GET /status - 현재 토큰 상태 확인`);
|
||||
console.log(` POST /refresh - 수동 토큰 갱신`);
|
||||
console.log(` GET /callback - OAuth 콜백`);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue