SSO 중복 인증 사용 오류 개선
Deploy EG-BIM QA Gateway / deploy (push) Successful in 33s

This commit is contained in:
root
2026-09-22 15:45:55 +09:00
parent c6b430a197
commit ee5205e133
4 changed files with 33 additions and 13 deletions
+13 -4
View File
@@ -266,6 +266,7 @@ button { cursor: pointer; }
.detail-head { padding: 23px 22px 20px; border-bottom: 1px solid var(--line); } .detail-head { padding: 23px 22px 20px; border-bottom: 1px solid var(--line); }
.detail-title-line { display: flex; align-items: center; justify-content: space-between; gap: 16px; } .detail-title-line { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.detail-title-line h2 { margin: 0; font-size: 22px; letter-spacing: -.04em; } .detail-title-line h2 { margin: 0; font-size: 22px; letter-spacing: -.04em; }
.detail-title-line .status { min-width: 82px; padding: 5px 10px; font-size: 13px; font-weight: 700; }
.detail-meta { display: flex; gap: 14px; flex-wrap: wrap; padding-top: 15px; color: #888; font-size: 13px; } .detail-meta { display: flex; gap: 14px; flex-wrap: wrap; padding-top: 15px; color: #888; font-size: 13px; }
.detail-meta span + span::before { content: ''; display: inline-block; width: 1px; height: 11px; margin: 0 14px 0 0; background: #ddd; vertical-align: -1px; } .detail-meta span + span::before { content: ''; display: inline-block; width: 1px; height: 11px; margin: 0 14px 0 0; background: #ddd; vertical-align: -1px; }
.detail-body { min-height: 260px; padding: 38px 22px 56px; white-space: pre-wrap; word-break: break-word; } .detail-body { min-height: 260px; padding: 38px 22px 56px; white-space: pre-wrap; word-break: break-word; }
@@ -274,6 +275,8 @@ button { cursor: pointer; }
.attachment-item { padding: 7px 0; color: #555; font-size: 13px; } .attachment-item { padding: 7px 0; color: #555; font-size: 13px; }
.attachments a { color: var(--navy); text-decoration: underline; } .attachments a { color: var(--navy); text-decoration: underline; }
.detail-actions { display: flex; justify-content: space-between; padding: 23px 22px 0; } .detail-actions { display: flex; justify-content: space-between; padding: 23px 22px 0; }
.egbim-page #backButton { display: none; }
.egbim-page .form-footer:has(#backButton) { display: none; }
.owner-actions { display: flex; gap: 8px; } .owner-actions { display: flex; gap: 8px; }
.button-danger { border-color: #c43d3d; background: #c43d3d; color: #fff; } .button-danger { border-color: #c43d3d; background: #c43d3d; color: #fff; }
.button-danger:hover { border-color: #a52f2f; background: #a52f2f; } .button-danger:hover { border-color: #a52f2f; background: #a52f2f; }
@@ -285,11 +288,12 @@ button { cursor: pointer; }
.comment-meta { display: flex; justify-content: space-between; margin-bottom: 7px; color: #777; font-size: 13px; } .comment-meta { display: flex; justify-content: space-between; margin-bottom: 7px; color: #777; font-size: 13px; }
.comment-content { white-space: pre-wrap; } .comment-content { white-space: pre-wrap; }
.comment-empty { padding: 32px; border-bottom: 1px solid var(--line); color: #999; text-align: center; } .comment-empty { padding: 32px; border-bottom: 1px solid var(--line); color: #999; text-align: center; }
.comment-form { padding: 20px 10px 0; } .comment-form { display: grid; grid-template-columns: minmax(0, 1fr) auto; column-gap: 16px; padding: 20px 10px 0; }
.comment-form .textarea { min-height: 120px; } .comment-form .textarea { min-height: 120px; }
.comment-attachment-input { padding-top: 14px; } .comment-form > .textarea { grid-column: 1 / -1; }
.comment-attachment-input { grid-column: 1; padding-top: 14px; }
.comment-attachment-input input { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; clip-path: inset(50%); } .comment-attachment-input input { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; clip-path: inset(50%); }
.comment-image-button { height: 34px; padding: 0 13px; font-size: 13px; } .comment-image-button { height: 42px; padding: 0 21px; font-size: 13px; }
.comment-image-preview, .comment-attachments-list { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 10px; } .comment-image-preview, .comment-attachments-list { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 10px; }
.comment-image-preview-item { width: 104px; overflow: hidden; border: 1px solid #ddd; background: #fafafa; } .comment-image-preview-item { width: 104px; overflow: hidden; border: 1px solid #ddd; background: #fafafa; }
.comment-image-preview-item img { display: block; width: 104px; height: 78px; object-fit: cover; } .comment-image-preview-item img { display: block; width: 104px; height: 78px; object-fit: cover; }
@@ -297,8 +301,11 @@ button { cursor: pointer; }
.comment-image-link { display: block; width: 124px; } .comment-image-link { display: block; width: 124px; }
.comment-image { display: block; width: 124px; height: 94px; border: 1px solid #ddd; object-fit: cover; } .comment-image { display: block; width: 124px; height: 94px; border: 1px solid #ddd; object-fit: cover; }
.comment-attachment-name { color: #666; font-size: 13px; } .comment-attachment-name { color: #666; font-size: 13px; }
.comment-form-actions { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding-top: 10px; } .comment-form-actions { display: flex; grid-column: 2; align-items: flex-end; justify-content: flex-end; gap: 12px; padding-top: 14px; }
.comment-form-actions .form-error { display: block; flex: 1; margin: 0; } .comment-form-actions .form-error { display: block; flex: 1; margin: 0; }
.comment-form-actions .form-error[hidden] { display: none; }
#commentSubmit { height: 42px; border-color: #000; background: #000; color: #fff; }
#commentSubmit:hover { border-color: #222; background: #222; }
.site-footer { padding: 38px 32px; background: #111; color: #b9b9b9; } .site-footer { padding: 38px 32px; background: #111; color: #b9b9b9; }
.footer-inner { display: flex; align-items: flex-start; justify-content: space-between; max-width: 1280px; margin: 0 auto; gap: 40px; } .footer-inner { display: flex; align-items: flex-start; justify-content: space-between; max-width: 1280px; margin: 0 auto; gap: 40px; }
@@ -436,6 +443,8 @@ button { cursor: pointer; }
.detail-actions .button { flex: 1; padding: 0 10px; } .detail-actions .button { flex: 1; padding: 0 10px; }
.footer-inner { display: block; } .footer-inner { display: block; }
.footer-links { margin-top: 18px; } .footer-links { margin-top: 18px; }
.comment-form { grid-template-columns: minmax(0, 1fr); }
.comment-form-actions { grid-column: 1; justify-content: flex-end; }
} }
@media (max-width: 480px) { @media (max-width: 480px) {
+6 -1
View File
@@ -405,6 +405,10 @@
function initDetail() { function initDetail() {
const detail = qs('#detail'); const detail = qs('#detail');
if (!detail) return; if (!detail) return;
const detailListLink = qs('.detail-actions > a', detail);
if (detailListLink) detailListLink.textContent = '목록으로';
const bottomBackButton = qs('#backButton', detail);
if (bottomBackButton && bottomBackButton.parentElement) bottomBackButton.parentElement.remove();
const id = new URLSearchParams(window.location.search).get('id') || 'EG-1048'; const id = new URLSearchParams(window.location.search).get('id') || 'EG-1048';
const post = getPosts().find(function (item) { return item.id === id; }); const post = getPosts().find(function (item) { return item.id === id; });
if (!post) { if (!post) {
@@ -596,6 +600,8 @@
}); });
const commentImages = qs('#commentImages'); const commentImages = qs('#commentImages');
const commentImagePreview = qs('#commentImagePreview'); const commentImagePreview = qs('#commentImagePreview');
const commentImageLabel = qs('label[for="commentImages"]');
if (commentImageLabel) commentImageLabel.textContent = '댓글 이미지 첨부';
if (commentImages && commentImagePreview) commentImages.addEventListener('change', function () { if (commentImages && commentImagePreview) commentImages.addEventListener('change', function () {
const files = Array.prototype.slice.call(commentImages.files || []); const files = Array.prototype.slice.call(commentImages.files || []);
commentImagePreview.innerHTML = files.filter(function (file) { return /^image\//i.test(file.type); }).map(function (file) { commentImagePreview.innerHTML = files.filter(function (file) { return /^image\//i.test(file.type); }).map(function (file) {
@@ -606,7 +612,6 @@
refreshRemoteStatus(post).then(function (changed) { refreshRemoteStatus(post).then(function (changed) {
if (changed) qs('[data-detail-status]').textContent = normalizeStatus(post.status) || '공지'; if (changed) qs('[data-detail-status]').textContent = normalizeStatus(post.status) || '공지';
}); });
qs('#backButton').addEventListener('click', function () { window.location.href = 'index.html'; });
} }
document.addEventListener('DOMContentLoaded', function () { bindGlobal(); initList(); initWrite(); initDetail(); }); document.addEventListener('DOMContentLoaded', function () { bindGlobal(); initList(); initWrite(); initDetail(); });
+1 -1
View File
@@ -3,6 +3,6 @@
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>문의 상세 | EG-BIM Q&amp;A</title><link rel="stylesheet" href="../assets/styles.css?v=20260921-4"></head> <head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>문의 상세 | EG-BIM Q&amp;A</title><link rel="stylesheet" href="../assets/styles.css?v=20260921-4"></head>
<body> <body>
<main class="egbim-page"><section class="page-hero"><div class="hero-inner"><span class="hero-kicker">EG-BIM SUPPORT</span><h1>Q&amp;A</h1><p>EG-BIM 관련 문의하기</p></div><nav class="sub-nav" aria-label="EG-BIM 지원 메뉴"><div class="sub-nav-inner"><a href="index.html" aria-current="page">문의하기(Q&amp;A)</a><a href="#" aria-disabled="true" class="disabled-tab">원격지원</a></div></nav></section> <main class="egbim-page"><section class="page-hero"><div class="hero-inner"><span class="hero-kicker">EG-BIM SUPPORT</span><h1>Q&amp;A</h1><p>EG-BIM 관련 문의하기</p></div><nav class="sub-nav" aria-label="EG-BIM 지원 메뉴"><div class="sub-nav-inner"><a href="index.html" aria-current="page">문의하기(Q&amp;A)</a><a href="#" aria-disabled="true" class="disabled-tab">원격지원</a></div></nav></section>
<section class="page-shell" id="detail"><div class="section-heading"><div><h2>문의 상세</h2><p>문의 내용과 답변을 확인할 수 있습니다.</p></div></div><article class="detail-card"><div class="detail-head"><div class="detail-title-line"><h2><span class="category" data-detail-category>문의</span> <span data-detail-title>문의 제목</span></h2><span class="status review" data-detail-status>문의접수</span></div><div class="detail-meta"><span data-detail-date>2026-09-17</span><span data-detail-author>작성자</span><span data-detail-company>회사</span><span data-detail-department>부서</span><span data-detail-secret hidden>🔒 비밀글</span></div></div><div class="detail-body" data-detail-content></div><div class="attachments"><h3>첨부파일</h3><div data-detail-attachments><span class="help-text">첨부된 파일이 없습니다.</span></div></div><div class="detail-actions"><div class="owner-actions"><button class="button button-outline" id="editButton" type="button" hidden>수정</button><button class="button button-danger" id="deleteButton" type="button" hidden>삭제</button></div><a class="button button-outline" href="index.html">목록</a></div></article><form id="editForm" class="edit-form" hidden><h3>문의 수정</h3><div class="form-row"><label class="form-label" for="editCategory">구분</label><div class="form-control"><select id="editCategory" class="select"><option value="오류문의">오류문의</option><option value="개선문의">개선문의</option><option value="일반문의">일반문의</option></select></div></div><div class="form-row"><label class="form-label" for="editTitle">제목</label><div class="form-control"><input id="editTitle" class="input" maxlength="200" required></div></div><div class="form-row"><label class="form-label" for="editContent">내용</label><div class="form-control column"><textarea id="editContent" class="textarea" maxlength="10000" required></textarea></div></div><div class="form-row"><div class="form-label">설정</div><div class="form-control"><label class="check"><input id="editSecret" type="checkbox"> 비밀글</label></div></div><div class="form-footer"><button class="button button-outline" id="editCancel" type="button">취소</button><button class="button button-primary" type="submit">수정 저장</button></div><div id="editError" class="form-error" role="alert" hidden></div></form><section class="comment-box"><h3>답변 <span id="commentCount">0</span></h3><div id="comments"></div><form id="commentForm" class="comment-form"><label class="sr-only" for="commentContent">댓글 내용</label><textarea id="commentContent" class="textarea" maxlength="5000" placeholder="답변 또는 추가 문의를 입력해주세요" required></textarea><div class="comment-attachment-input"><label class="button button-outline comment-image-button" for="commentImages">이미지 첨부</label><input id="commentImages" type="file" accept="image/*" multiple><div id="commentImagePreview" class="comment-image-preview"></div></div><div class="comment-form-actions"><span id="commentError" class="form-error" role="alert" hidden></span><button class="button button-primary" id="commentSubmit" type="submit">댓글 등록</button></div></form></section><div class="form-footer"><button class="button button-outline" id="backButton" type="button">목록으로</button></div></section> <section class="page-shell" id="detail"><div class="section-heading"><div><h2>문의 상세</h2><p>문의 내용과 답변을 확인할 수 있습니다.</p></div></div><article class="detail-card"><div class="detail-head"><div class="detail-title-line"><h2><span class="category" data-detail-category>문의</span> <span data-detail-title>문의 제목</span></h2><span class="status review" data-detail-status>문의접수</span></div><div class="detail-meta"><span data-detail-date>2026-09-17</span><span data-detail-author>작성자</span><span data-detail-company>회사</span><span data-detail-department>부서</span><span data-detail-secret hidden>🔒 비밀글</span></div></div><div class="detail-body" data-detail-content></div><div class="attachments"><h3>첨부파일</h3><div data-detail-attachments><span class="help-text">첨부된 파일이 없습니다.</span></div></div><div class="detail-actions"><div class="owner-actions"><button class="button button-outline" id="editButton" type="button" hidden>수정</button><button class="button button-danger" id="deleteButton" type="button" hidden>삭제</button></div><a class="button button-outline" href="index.html">목록</a></div></article><form id="editForm" class="edit-form" hidden><h3>문의 수정</h3><div class="form-row"><label class="form-label" for="editCategory">구분</label><div class="form-control"><select id="editCategory" class="select"><option value="오류문의">오류문의</option><option value="개선문의">개선문의</option><option value="일반문의">일반문의</option></select></div></div><div class="form-row"><label class="form-label" for="editTitle">제목</label><div class="form-control"><input id="editTitle" class="input" maxlength="200" required></div></div><div class="form-row"><label class="form-label" for="editContent">내용</label><div class="form-control column"><textarea id="editContent" class="textarea" maxlength="10000" required></textarea></div></div><div class="form-row"><div class="form-label">설정</div><div class="form-control"><label class="check"><input id="editSecret" type="checkbox"> 비밀글</label></div></div><div class="form-footer"><button class="button button-outline" id="editCancel" type="button">취소</button><button class="button button-primary" type="submit">수정 저장</button></div><div id="editError" class="form-error" role="alert" hidden></div></form><section class="comment-box"><h3>답변 <span id="commentCount">0</span></h3><div id="comments"></div><form id="commentForm" class="comment-form"><label class="sr-only" for="commentContent">댓글 내용</label><textarea id="commentContent" class="textarea" maxlength="5000" placeholder="답변 또는 추가 문의를 입력해주세요" required></textarea><div class="comment-attachment-input"><label class="button button-primary comment-image-button" for="commentImages">이미지 첨부</label><input id="commentImages" type="file" accept="image/*" multiple><div id="commentImagePreview" class="comment-image-preview"></div></div><div class="comment-form-actions"><span id="commentError" class="form-error" role="alert" hidden></span><button class="button button-primary" id="commentSubmit" type="submit">댓글 등록</button></div></form></section><div class="form-footer"><button class="button button-outline" id="backButton" type="button">목록으로</button></div></section>
</main><script src="config.js?v=20260921-3"></script><script src="app.js?v=20260921-3"></script> </main><script src="config.js?v=20260921-3"></script><script src="app.js?v=20260921-3"></script>
</body></html> </body></html>
+13 -7
View File
@@ -96,6 +96,8 @@ function redirect(url, headers = {}) {
else responseHeaders.set(name, value); else responseHeaders.set(name, value);
}); });
responseHeaders.set('location', url); responseHeaders.set('location', url);
responseHeaders.set('cache-control', 'no-store, no-cache, must-revalidate');
responseHeaders.set('pragma', 'no-cache');
return new Response(null, { status: 302, headers: responseHeaders }); return new Response(null, { status: 302, headers: responseHeaders });
} }
@@ -250,32 +252,36 @@ async function startLogin(request, env) {
return redirect(authorize.toString(), { 'set-cookie': cookie(env.OAUTH_COOKIE_NAME, signedState, 600) }); return redirect(authorize.toString(), { 'set-cookie': cookie(env.OAUTH_COOKIE_NAME, signedState, 600) });
} }
function oauthError(env, error, detail, status) {
return json({ error, detail: detail || '' }, status, { 'set-cookie': cookie(env.OAUTH_COOKIE_NAME, '', 0, { delete: true }) });
}
async function finishLogin(request, env) { async function finishLogin(request, env) {
const missing = requiredAuthConfig(env); const missing = requiredAuthConfig(env);
if (missing.length) return json({ error: 'auth_not_configured', missing }, 503); if (missing.length) return json({ error: 'auth_not_configured', missing }, 503);
const url = new URL(request.url); const url = new URL(request.url);
const signedState = getCookie(request, env.OAUTH_COOKIE_NAME); const signedState = getCookie(request, env.OAUTH_COOKIE_NAME);
const statePayload = await verifyValue(signedState, env.SESSION_SECRET); const statePayload = await verifyValue(signedState, env.SESSION_SECRET);
if (!statePayload) return json({ error: 'invalid_oauth_state' }, 400); if (!statePayload) return oauthError(env, 'invalid_oauth_state', '', 400);
let state; let state;
try { state = JSON.parse(decoder.decode(fromBase64url(statePayload))); } catch (error) { return json({ error: 'invalid_oauth_state' }, 400); } try { state = JSON.parse(decoder.decode(fromBase64url(statePayload))); } catch (error) { return oauthError(env, 'invalid_oauth_state', '', 400); }
if (url.searchParams.get('state') !== state.state || Date.now() - state.createdAt > 10 * 60 * 1000) return json({ error: 'invalid_oauth_state' }, 400); if (url.searchParams.get('state') !== state.state || Date.now() - state.createdAt > 10 * 60 * 1000) return oauthError(env, 'invalid_oauth_state', '', 400);
const code = url.searchParams.get('code'); const code = url.searchParams.get('code');
if (!code) return json({ error: 'oauth_code_missing', detail: url.searchParams.get('error_description') || url.searchParams.get('error') || '' }, 400); if (!code) return oauthError(env, 'oauth_code_missing', url.searchParams.get('error_description') || url.searchParams.get('error') || '', 400);
const tokenParams = new URLSearchParams({ grant_type: 'authorization_code', client_id: env.AUTH_CLIENT_ID, redirect_uri: env.AUTH_REDIRECT_URI, code, code_verifier: state.verifier }); const tokenParams = new URLSearchParams({ grant_type: 'authorization_code', client_id: env.AUTH_CLIENT_ID, redirect_uri: env.AUTH_REDIRECT_URI, code, code_verifier: state.verifier });
const tokenResponse = await fetch(env.AUTH_TOKEN_URL, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' }, body: tokenParams }); const tokenResponse = await fetch(env.AUTH_TOKEN_URL, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' }, body: tokenParams });
const tokens = await tokenResponse.json().catch(() => ({})); const tokens = await tokenResponse.json().catch(() => ({}));
if (!tokenResponse.ok) return json({ error: 'oauth_token_exchange_failed', detail: tokens.error_description || tokens.error || '' }, 502); if (!tokenResponse.ok) return oauthError(env, 'oauth_token_exchange_failed', tokens.error_description || tokens.error || '', 502);
let claims = parseJwt(tokens.id_token || tokens.access_token); let claims = parseJwt(tokens.id_token || tokens.access_token);
if (state.nonce && claims.nonce && state.nonce !== claims.nonce) return json({ error: 'invalid_oauth_nonce' }, 400); if (state.nonce && claims.nonce && state.nonce !== claims.nonce) return oauthError(env, 'invalid_oauth_nonce', '', 400);
if (env.AUTH_USERINFO_URL && tokens.access_token) { if (env.AUTH_USERINFO_URL && tokens.access_token) {
const userResponse = await fetch(env.AUTH_USERINFO_URL, { headers: { authorization: `Bearer ${tokens.access_token}`, accept: 'application/json' } }); const userResponse = await fetch(env.AUTH_USERINFO_URL, { headers: { authorization: `Bearer ${tokens.access_token}`, accept: 'application/json' } });
const userInfo = await userResponse.json().catch(() => ({})); const userInfo = await userResponse.json().catch(() => ({}));
if (userResponse.ok) claims = { ...claims, ...userInfo }; if (userResponse.ok) claims = { ...claims, ...userInfo };
} }
const user = normalizeClaims(claims); const user = normalizeClaims(claims);
if (!user.ssoSubject) return json({ error: 'sso_subject_missing' }, 502); if (!user.ssoSubject) return oauthError(env, 'sso_subject_missing', '', 502);
const sessionPayload = base64url(JSON.stringify({ user, expiresAt: Date.now() + Number(env.SESSION_TTL_SECONDS || 3600) * 1000 })); const sessionPayload = base64url(JSON.stringify({ user, expiresAt: Date.now() + Number(env.SESSION_TTL_SECONDS || 3600) * 1000 }));
const session = await signValue(sessionPayload, env.SESSION_SECRET); const session = await signValue(sessionPayload, env.SESSION_SECRET);
return redirect(new URL(state.returnUrl || '/', url.origin).toString(), { 'set-cookie': [cookie(env.SESSION_COOKIE_NAME, session, Number(env.SESSION_TTL_SECONDS || 3600)), cookie(env.OAUTH_COOKIE_NAME, '', 0, { delete: true })] }); return redirect(new URL(state.returnUrl || '/', url.origin).toString(), { 'set-cookie': [cookie(env.SESSION_COOKIE_NAME, session, Number(env.SESSION_TTL_SECONDS || 3600)), cookie(env.OAUTH_COOKIE_NAME, '', 0, { delete: true })] });