This commit is contained in:
1 parent
5091da737d
commit
821c770b4a
4 files changed
+16
-68
No files matched your search
@@ -35,9 +35,9 @@ python3 -m http.server 4173
|
||||
|
||||
## 연동 지점
|
||||
|
||||
- SSO: Q&A는 홈페이지 로그인 정보를 전달받지 않고, `https://sso.hmac.kr/oidc`에 등록된 독립적인 PKCE Public Client로 직접 인증합니다. callback은 `https://qa-test.baroncs.co.kr/auth/callback`이며, Worker가 검증한 claim과 SSO access token을 `SESSION_SECRET`에서 파생한 키로 암호화해 HttpOnly 세션 쿠키에 저장합니다. 브라우저의 `GET /auth/session`은 작성자 정보만 반환하며 access token은 반환하지 않습니다.
|
||||
- SSO: Q&A는 홈페이지 로그인 정보를 전달받지 않고, `https://sso.hmac.kr/oidc`에 등록된 독립적인 PKCE Public Client로 직접 인증합니다. callback은 `https://qa-test.baroncs.co.kr/auth/callback`이며, Worker가 검증한 claim을 버전이 지정된 HttpOnly 세션 쿠키에 저장합니다. 브라우저의 `GET /auth/session`은 Q&A 세션에 저장된 작성자 정보를 반환합니다. feedback 서버는 반드시 SSO 세션 또는 토큰을 서버 측에서 검증해야 합니다.
|
||||
- 작성자 식별자: `ssoSubject`/`requesterId`, `userUuid`, `tenantId`/`requesterTenantId`, `tenantIds`, `scope`, `roles`, 이메일·이름·부서·전화번호를 payload에 넣습니다. `requester_id`와 `requester_tenant_id`가 없으면 제출을 차단합니다.
|
||||
- API: 작성페이지는 같은 Worker의 `POST /api/feedbacks`를 호출합니다. Worker가 암호화된 Q&A 세션 쿠키를 검증하고, SSO requester 정보와 `Authorization: Bearer` access token, `ABC_API_KEY` Secret을 추가한 뒤 `POST https://feedback.hmac.kr/api/projects/{projectId}/channels/{channelId}/feedbacks`로 전달합니다. Bearer token, API Key와 내부 ABC 주소는 브라우저 JavaScript에 노출하지 않습니다.
|
||||
- API: 작성페이지는 같은 Worker의 `POST /api/feedbacks`를 호출합니다. Worker가 Q&A 세션 쿠키를 검증하고, SSO requester 정보와 `ABC_API_KEY` Secret을 추가한 뒤 `POST https://feedback.hmac.kr/api/projects/{projectId}/channels/{channelId}/feedbacks`로 전달합니다. API Key와 내부 ABC 주소는 브라우저에 노출하지 않습니다.
|
||||
- 첨부파일: 작성페이지가 `multipart/form-data`의 반복 `images` 필드로 파일을 Worker에 전달하고, Worker가 검증된 requester 정보와 API Key를 추가해 ABC API로 전달합니다. ABC API가 `qa_cdn` 저장소에 파일을 저장하므로 R2 access key/secret은 정적 페이지에 넣지 않습니다.
|
||||
- 텍스트만 등록할 때는 기존 JSON 요청을 사용하고, 파일이 있을 때만 multipart 요청을 사용합니다.
|
||||
|
||||
@@ -68,7 +68,7 @@ npx wrangler secret bulk qa-secrets.json --name baron-qa-gateway-test
|
||||
|
||||
`AUTH_CLIENT_ID`, `AUTH_AUTHORIZE_URL`, `AUTH_TOKEN_URL`, 선택적인 `AUTH_USERINFO_URL`은 `wrangler.toml`에 실제 SSO 값으로 설정해야 합니다. 현재 Q&A RP는 `https://sso.hmac.kr/oidc`를 사용합니다. 이 RP는 PKCE Public Client이므로 `AUTH_CLIENT_SECRET`은 사용하지 않으며, `SESSION_SECRET`만 secret으로 등록합니다.
|
||||
|
||||
Worker는 OAuth Authorization Code + PKCE를 사용하고, callback에서 받은 access token과 정규화한 사용자 claim을 `SESSION_SECRET`에서 파생한 키로 암호화해 HttpOnly 세션 쿠키에 저장합니다. 브라우저의 `GET /auth/session`은 정규화된 작성자 정보만 반환하며 access token은 반환하지 않습니다. 세션 쿠키 형식 변경 후에는 다시 로그인해야 합니다.
|
||||
Worker는 OAuth Authorization Code + PKCE를 사용하고, callback에서 검증한 사용자 claim을 서명된 HttpOnly 세션 쿠키에 저장합니다. 브라우저의 `GET /auth/session`은 정규화된 작성자 정보만 반환합니다.
|
||||
|
||||
## Gitea Actions 등록값
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
작성페이지
|
||||
→ Worker POST
|
||||
→ Q&A SSO 세션 확인
|
||||
→ Worker가 requester 정보, SSO Bearer token, API Key 추가
|
||||
→ Worker가 requester 정보와 API Key 추가
|
||||
→ ABC UserFeedback API
|
||||
→ feedbacks 저장
|
||||
→ { id } 반환
|
||||
@@ -48,7 +48,6 @@
|
||||
- [x] `requester_phone_number` 매핑: SSO `profile.phones[0]` 또는 phone claim
|
||||
- [x] 브라우저 요청의 requester 값을 신뢰하지 않도록 처리
|
||||
- [x] Worker Secret `ABC_API_KEY`로 `x-api-key` 추가
|
||||
- [x] SSO access token을 암호화된 HttpOnly 세션에 보관하고 글 저장 요청에 Bearer token 추가
|
||||
- [x] `projectId`와 `channelId`를 Worker 설정에 등록
|
||||
- [x] `POST /api/projects/{projectId}/channels/{channelId}/feedbacks` 호출
|
||||
- [x] `x-source-namespace` 추가
|
||||
|
||||
@@ -126,7 +126,7 @@ support_tickets.idempotency_key
|
||||
```
|
||||
|
||||
- 첨부파일은 `qa_cdn`에 저장하고, DB에는 bucket/key 및 파일 메타데이터를 기록한다.
|
||||
- Worker는 저장 요청에 SSO access token을 `Authorization: Bearer`로, 프로젝트 API 키를 `x-api-key`로 전달한다. 세션 쿠키와 access token은 브라우저 JavaScript에 노출하지 않는다.
|
||||
- 실제 운영에서는 feedback API가 서버 측에서 Q&A 세션 또는 SSO token을 검증해야 한다.
|
||||
|
||||
## 4. 현재 테스트 모드와 운영 모드
|
||||
|
||||
|
||||
+11
-62
@@ -144,28 +144,6 @@ async function verifyValue(signed, secret) {
|
||||
return difference === 0 ? value : null;
|
||||
}
|
||||
|
||||
async function encryptValue(value, secret) {
|
||||
const keyMaterial = await sha256(`baron-qa-session-encryption-v1:${secret}`);
|
||||
const key = await crypto.subtle.importKey('raw', keyMaterial, { name: 'AES-GCM' }, false, ['encrypt']);
|
||||
const iv = randomBytes(12);
|
||||
const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, encoder.encode(value));
|
||||
return `v1.${base64url(iv)}.${base64url(new Uint8Array(ciphertext))}`;
|
||||
}
|
||||
|
||||
async function decryptValue(encrypted, secret) {
|
||||
if (!encrypted || !secret) return null;
|
||||
const [version, encodedIv, encodedCiphertext] = encrypted.split('.');
|
||||
if (version !== 'v1' || !encodedIv || !encodedCiphertext) return null;
|
||||
try {
|
||||
const keyMaterial = await sha256(`baron-qa-session-encryption-v1:${secret}`);
|
||||
const key = await crypto.subtle.importKey('raw', keyMaterial, { name: 'AES-GCM' }, false, ['decrypt']);
|
||||
const plaintext = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: fromBase64url(encodedIv) }, key, fromBase64url(encodedCiphertext));
|
||||
return decoder.decode(plaintext);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function randomBytes(size = 32) {
|
||||
const bytes = new Uint8Array(size);
|
||||
crypto.getRandomValues(bytes);
|
||||
@@ -319,7 +297,6 @@ async function finishLogin(request, env) {
|
||||
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(() => ({}));
|
||||
if (!tokenResponse.ok) return oauthError(env, 'oauth_token_exchange_failed', tokens.error_description || tokens.error || '', 502);
|
||||
if (typeof tokens.access_token !== 'string' || !tokens.access_token) return oauthError(env, 'oauth_access_token_missing', '', 502);
|
||||
let claims = parseJwt(tokens.id_token || tokens.access_token);
|
||||
if (state.nonce && claims.nonce && state.nonce !== claims.nonce) return oauthError(env, 'invalid_oauth_nonce', '', 400);
|
||||
if (env.AUTH_USERINFO_URL && tokens.access_token) {
|
||||
@@ -329,43 +306,19 @@ async function finishLogin(request, env) {
|
||||
}
|
||||
const user = normalizeClaims(claims);
|
||||
if (!user.ssoSubject) return oauthError(env, 'sso_subject_missing', '', 502);
|
||||
const now = Date.now();
|
||||
const accessToken = typeof tokens.access_token === 'string' ? tokens.access_token : '';
|
||||
const accessTokenClaims = parseJwt(accessToken);
|
||||
const tokenExpiresAt = Number(tokens.expires_in) > 0
|
||||
? now + Number(tokens.expires_in) * 1000
|
||||
: (Number(accessTokenClaims.exp) > 0 ? Number(accessTokenClaims.exp) * 1000 : now + Number(env.SESSION_TTL_SECONDS || 3600) * 1000);
|
||||
const sessionExpiresAt = Math.min(now + Number(env.SESSION_TTL_SECONDS || 3600) * 1000, tokenExpiresAt);
|
||||
if (sessionExpiresAt <= now) return oauthError(env, 'oauth_access_token_expired', '', 502);
|
||||
const sessionPayload = JSON.stringify({
|
||||
user,
|
||||
accessToken,
|
||||
accessTokenExpiresAt: Math.min(sessionExpiresAt, tokenExpiresAt),
|
||||
expiresAt: sessionExpiresAt
|
||||
});
|
||||
const session = await encryptValue(sessionPayload, env.SESSION_SECRET);
|
||||
const sessionMaxAge = Math.max(1, Math.floor((sessionExpiresAt - now) / 1000));
|
||||
return redirect(new URL(state.returnUrl || '/', url.origin).toString(), { 'set-cookie': [cookie(env.SESSION_COOKIE_NAME, session, sessionMaxAge), cookie(env.OAUTH_COOKIE_NAME, '', 0, { delete: true })] });
|
||||
}
|
||||
|
||||
async function getSessionContext(request, env) {
|
||||
const signedSession = getCookie(request, env.SESSION_COOKIE_NAME);
|
||||
const value = await decryptValue(signedSession, env.SESSION_SECRET);
|
||||
if (!value) return null;
|
||||
try {
|
||||
const payload = JSON.parse(value);
|
||||
if (!(payload.expiresAt > Date.now()) || !payload.user) return null;
|
||||
return {
|
||||
user: payload.user,
|
||||
accessToken: typeof payload.accessToken === 'string' ? payload.accessToken : '',
|
||||
accessTokenExpiresAt: Number(payload.accessTokenExpiresAt) || 0
|
||||
};
|
||||
} catch (error) { return null; }
|
||||
const sessionPayload = base64url(JSON.stringify({ user, expiresAt: Date.now() + Number(env.SESSION_TTL_SECONDS || 3600) * 1000 }));
|
||||
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 })] });
|
||||
}
|
||||
|
||||
async function getSession(request, env) {
|
||||
const session = await getSessionContext(request, env);
|
||||
return session?.user || null;
|
||||
const signedSession = getCookie(request, env.SESSION_COOKIE_NAME);
|
||||
const value = await verifyValue(signedSession, env.SESSION_SECRET);
|
||||
if (!value) return null;
|
||||
try {
|
||||
const payload = JSON.parse(decoder.decode(fromBase64url(value)));
|
||||
return payload.expiresAt > Date.now() ? payload.user : null;
|
||||
} catch (error) { return null; }
|
||||
}
|
||||
|
||||
async function sessionResponse(request, env) {
|
||||
@@ -375,11 +328,8 @@ async function sessionResponse(request, env) {
|
||||
|
||||
async function createFeedback(request, env) {
|
||||
if (request.method !== 'POST') return json({ error: 'method_not_allowed' }, 405, { allow: 'POST' });
|
||||
const session = await getSessionContext(request, env);
|
||||
const user = session?.user;
|
||||
const user = await getSession(request, env);
|
||||
if (!user) return json({ error: 'unauthenticated' }, 401);
|
||||
if (!session.accessToken) return json({ error: 'sso_access_token_missing' }, 401);
|
||||
if (session.accessTokenExpiresAt && session.accessTokenExpiresAt <= Date.now()) return json({ error: 'sso_access_token_expired' }, 401);
|
||||
if (!env.ABC_API_KEY) return json({ error: 'feedback_api_not_configured' }, 503);
|
||||
if (!env.ABC_PROJECT_ID || !env.ABC_CHANNEL_ID) return json({ error: 'feedback_target_not_configured' }, 503);
|
||||
|
||||
@@ -440,7 +390,6 @@ async function createFeedback(request, env) {
|
||||
const idempotencyConsumer = env.ABC_IDEMPOTENCY_CONSUMER || sourceNamespace;
|
||||
const upstreamHeaders = {
|
||||
accept: 'application/json',
|
||||
authorization: `Bearer ${session.accessToken}`,
|
||||
'x-api-key': env.ABC_API_KEY,
|
||||
'x-source-namespace': sourceNamespace,
|
||||
'x-source-record-id': feedbackId,
|
||||
|
||||
Reference in new issue
Block a user