From 359c7162ca219bdd521a39059b47746123ce9db5 Mon Sep 17 00:00:00 2001 From: Xvezda Date: Fri, 26 Apr 2024 20:13:04 +0900 Subject: [PATCH 01/30] =?UTF-8?q?feat:=20jwt=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=EB=B0=A9=EC=8B=9D=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/package.json | 1 + apps/api/src/index.ts | 10 + apps/api/src/services/auth/v1/route.ts | 258 ++++++++++++++----------- apps/api/src/typings.ts | 2 + pnpm-lock.yaml | 7 + 5 files changed, 162 insertions(+), 116 deletions(-) diff --git a/apps/api/package.json b/apps/api/package.json index 19949aa..763d7e6 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -18,6 +18,7 @@ }, "dependencies": { "@libsql/client": "^0.6.0", + "@tsndr/cloudflare-worker-jwt": "^2.5.3", "drizzle-orm": "^0.30.8", "hono": "^4.2.4" } diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 96778a4..0bdd72d 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -1,10 +1,20 @@ import { Hono } from 'hono'; import { cors } from 'hono/cors'; +import { HTTPException } from 'hono/http-exception'; import auth from './services/auth/v1/route'; import { Env } from '@/typings'; const app = new Hono<{ Bindings: Env }>(); +app.onError((err, c) => { + console.error(`${err}`); + + if (err instanceof HTTPException) { + return c.json({ message: err.message }, err.status); + } + return c.json({ message: 'Internal Server Error' }, 500); +}); + app.use('*', cors({ origin: (origin, c) => { if (c.env.DEV) { diff --git a/apps/api/src/services/auth/v1/route.ts b/apps/api/src/services/auth/v1/route.ts index ebe5f46..bd2f70b 100644 --- a/apps/api/src/services/auth/v1/route.ts +++ b/apps/api/src/services/auth/v1/route.ts @@ -1,6 +1,8 @@ -import { Hono } from 'hono'; +import { Hono, MiddlewareHandler } from 'hono'; import { cors } from 'hono/cors'; +import { HTTPException } from 'hono/http-exception'; import { setCookie, getCookie, deleteCookie } from 'hono/cookie'; +import jwt from '@tsndr/cloudflare-worker-jwt'; import { eq } from 'drizzle-orm'; import { drizzle } from 'drizzle-orm/d1'; import { Env } from '@/typings'; @@ -48,6 +50,20 @@ type NidVerifyResponse = NidApiResponse<{ client_id: string; }>; +function hexEncode(str: string) { + return str + .split('') + .map(c => c.charCodeAt(0).toString(16).padStart(2, '0')) + .join(''); +} + +function hexDecode(str: string) { + return str + .match(/[0-9a-f]{2}/ig)! + .map(b => String.fromCharCode(parseInt(b, 16))) + .join(''); +} + const collectResponse = async (response?: Response, fallback: string = '') => { if (response == null) { return fallback; @@ -60,7 +76,6 @@ const collectResponse = async (response?: Response, fallback: string = '') => { return result || fallback; }; - const app = new Hono<{ Bindings: Env }>(); app.use('*', cors({ @@ -81,24 +96,108 @@ app.use('*', cors({ credentials: true, })); -app.get('/logout', async (c) => { +const withPrevUrl: MiddlewareHandler<{ + Bindings: Env; + Variables: { + prevUrl: string; + }; +}> = async (c, next) => { let prevUrl = 'https://cheda.kr/'; try { prevUrl = new URL(c.req.query('prevUrl') ?? c.req.header('Referer') ?? '').toString(); } catch (e) {} + c.set('prevUrl', prevUrl); + + await next(); +}; + +const withSessionId: MiddlewareHandler<{ + Bindings: Env, + Variables: { + prevUrl: string + }; +}> = async (c, next) => { const sessionId = getCookie(c, 'session_id'); - if (!sessionId) { - return c.redirect(prevUrl); + if (!sessionId) throw new HTTPException(401, { message: 'Unauthorized' }); + + // NOTE: 검증하지 않기 때문에 액세스 토큰이 유출된 경우 로그아웃 처리 필요 + const user = (jwt.decode(sessionId) as any).payload.user; + const expireAt = new Date(user.expireAt).getTime(); + + if (Date.now() < expireAt) { + if (c.var.prevUrl) { + return c.redirect(c.var.prevUrl); + } + return await next(); } - const cache = await caches.open('auth'); - const matched = await cache.match(new Request(`http://localhost/__auth/${sessionId}`, { method: 'GET' })); + const url = new URL('https://nid.naver.com/oauth2.0/token'); + url.searchParams.append('grant_type', 'refresh_token'); + url.searchParams.append('client_id', c.env.OAUTH_CLIENT_ID_NAVER); + url.searchParams.append('client_secret', c.env.OAUTH_CLIENT_SECRET_NAVER); + url.searchParams.append('refresh_token', user.refreshToken); - if (!matched) { - return c.redirect(prevUrl); + const response = await fetch(url); + const result = await response.json() as RefreshTokenResponse; + + const headers = { + 'Authorization': `Bearer ${result.access_token}`, + }; + const [meResult, verifyResult] = await Promise.all([ + fetch('https://openapi.naver.com/v1/nid/me', { headers }).then(r => r.json()) as Promise, + fetch('https://openapi.naver.com/v1/nid/verify?info=true', { headers }).then(r => r.json()) as Promise + ]); + + const userPatch = { + userName: meResult.response.nickname, + userImage: meResult.response.profile_image, + accessToken: result.access_token, + tokenType: result.token_type, + expireAt: new Date(verifyResult.response.expire_date), + updatedAt: new Date(), + }; + + const db = drizzle(c.env.DB); + await db.update(usersTable) + .set(userPatch) + .where(eq(usersTable.userId, meResult.response.id)); + + const newSessionId = await jwt.sign( + { + user: { + ...user, + ...userPatch, + }, + exp: Math.floor(Date.now() / 1000) + parseInt(result.expires_in), + }, + hexDecode(c.env.JWT_SECRET_KEY), + { algorithm: 'RS256' } + ); + + setCookie(c, 'session_id', newSessionId, { + expires: new Date(Date.now() + parseInt(result.expires_in) * 1000), + ...c.env.DEV ? {} : { + secure: true, + domain: '.cheda.kr', + }, + }); + + await next(); +}; + +app.get('/logout', withPrevUrl, async (c) => { + const sessionId = getCookie(c, 'session_id'); + if (!sessionId) { + return c.redirect(c.var.prevUrl); + } + + if (!await jwt.verify(sessionId, hexDecode(c.env.JWT_PUBLIC_KEY), { algorithm: 'RS256' })) { + deleteCookie(c, 'session_id'); + return c.redirect(c.var.prevUrl); } - const user = JSON.parse(await collectResponse(matched)); + + const user = (jwt.decode(sessionId).payload as any).user; /* const url = new URL('https://nid.naver.com/oauth2.0/token'); @@ -112,13 +211,10 @@ app.get('/logout', async (c) => { const result = await response.json() as DeleteTokenRespone; */ - await cache.delete(new Request(`http://localhost/__auth/${sessionId}`, { method: 'GET' })); - const db = drizzle(c.env.DB); await db.update(usersTable) .set({ accessToken: null, - refreshToken: null, expireAt: null, updatedAt: new Date(), }) @@ -126,91 +222,11 @@ app.get('/logout', async (c) => { deleteCookie(c, 'session_id'); - return c.redirect(prevUrl); + return c.redirect(c.var.prevUrl); }); -app.get('/login', async (c) => { - let prevUrl = 'https://cheda.kr/'; - try { - prevUrl = new URL(c.req.query('prevUrl') ?? c.req.header('Referer') ?? '').toString(); - } catch (e) {} - +app.get('/login', withPrevUrl, async (c) => { const cache = await caches.open('auth'); - const sessionId = getCookie(c, 'session_id'); - if (sessionId) { - const matched = await cache.match(new Request(`http://localhost/__auth/${sessionId}`, { method: 'GET' })); - if (matched) { - const user = JSON.parse(await collectResponse(matched)); - const expireAt = new Date(user.expireAt).getTime(); - if (Date.now() < expireAt) { - return c.redirect(prevUrl); - } - const url = new URL('https://nid.naver.com/oauth2.0/token'); - url.searchParams.append('grant_type', 'refresh_token'); - url.searchParams.append('client_id', c.env.OAUTH_CLIENT_ID_NAVER); - url.searchParams.append('client_secret', c.env.OAUTH_CLIENT_SECRET_NAVER); - url.searchParams.append('refresh_token', user.refreshToken); - - const response = await fetch(url); - const result = await response.json() as RefreshTokenResponse; - - const headers = { - 'Authorization': `Bearer ${result.access_token}`, - }; - const [me, verify] = await Promise.all([ - fetch('https://openapi.naver.com/v1/nid/me', { headers }).then(r => r.json()) as Promise, - fetch('https://openapi.naver.com/v1/nid/verify?info=true', { headers }).then(r => r.json()) as Promise - ]); - - - const db = drizzle(c.env.DB); - - await db.update(usersTable) - .set({ - userName: me.response.nickname, - userImage: me.response.profile_image, - accessToken: result.access_token, - tokenType: result.token_type, - expireAt: new Date(verify.response.expire_date), - updatedAt: new Date(), - }) - .where(eq(usersTable.userId, me.response.id)); - - deleteCookie(c, 'session_id'); - - const newSessionId = crypto.randomUUID(); - - await cache.put( - new Request(`http://localhost/__auth/${newSessionId}`, { method: 'GET' }), - new Response(JSON.stringify({ - userId: me.response.id, - userName: me.response.nickname, - userImage: me.response.profile_image, - createdAt: new Date(), - updatedAt: new Date(), - accessToken: result.access_token, - refreshToken: user.refreshToken, - tokenType: result.token_type, - expireAt: new Date(verify.response.expire_date), - }), { - headers: { - 'Cache-Control': `max-age=${result.expires_in}`, - }, - }), - ); - - - setCookie(c, 'session_id', newSessionId, { - httpOnly: true, - expires: new Date(Date.now() + parseInt(result.expires_in) * 1000), - ...c.env.DEV ? {} : { - secure: true, - domain: '.cheda.kr', - }, - }); - return c.redirect(prevUrl); - } - } const prevState = getCookie(c, 'state'); if (prevState) { @@ -227,7 +243,7 @@ app.get('/login', async (c) => { await cache.put( new Request(`http://localhost/__auth/${state}`, { method: 'GET' }), - new Response(prevUrl, { + new Response(c.var.prevUrl, { headers: { 'Cache-Control': 'max-age=600', }, @@ -288,7 +304,7 @@ app.get('/callback', async (c) => { 'Authorization': `Bearer ${result.access_token}`, }; - const [me, verify] = await Promise.all([ + const [meResult, verifyResult] = await Promise.all([ fetch('https://openapi.naver.com/v1/nid/me', { headers }).then(r => r.json()) as Promise, fetch('https://openapi.naver.com/v1/nid/verify?info=true', { headers }).then(r => r.json()) as Promise ]); @@ -296,15 +312,15 @@ app.get('/callback', async (c) => { const db = drizzle(c.env.DB); const user = { - userId: me.response.id, - userName: me.response.nickname, - userImage: me.response.profile_image, + userId: meResult.response.id, + userName: meResult.response.nickname, + userImage: meResult.response.profile_image, createdAt: new Date(), updatedAt: new Date(), accessToken: result.access_token, refreshToken: result.refresh_token, tokenType: result.token_type, - expireAt: new Date(verify.response.expire_date), + expireAt: new Date(verifyResult.response.expire_date), }; try { @@ -316,6 +332,7 @@ app.get('/callback', async (c) => { userName: user.userName, userImage: user.userImage, accessToken: user.accessToken, + refreshToken: user.refreshToken, tokenType: user.tokenType, expireAt: user.expireAt, updatedAt: user.updatedAt, @@ -323,18 +340,24 @@ app.get('/callback', async (c) => { .where(eq(usersTable.userId, user.userId)); } - const sessionId = crypto.randomUUID(); - - await cache.put( - new Request(`http://localhost/__auth/${sessionId}`, { method: 'GET' }), - new Response(JSON.stringify(user), { - headers: { - 'Cache-Control': `max-age=${result.expires_in}`, + const sessionId = await jwt.sign( + { + user: { + userId: user.userId, + userName: user.userName, + userImage: user.userImage, + accessToken: user.accessToken, + tokenType: user.tokenType, + expireAt: user.expireAt, + updatedAt: user.updatedAt, }, - }), + exp: Math.floor(Date.now() / 1000) + parseInt(result.expires_in), + }, + hexDecode(c.env.JWT_SECRET_KEY), + { algorithm: 'RS256' } ); + setCookie(c, 'session_id', sessionId, { - httpOnly: true, expires: new Date(Date.now() + parseInt(result.expires_in) * 1000), ...c.env.DEV ? {} : { secure: true, @@ -345,19 +368,22 @@ app.get('/callback', async (c) => { return c.redirect(prevUrl); }); -app.get('/me', async (c) => { +app.get('/me', withSessionId, async (c) => { const sessionId = getCookie(c, 'session_id'); if (!sessionId) { return c.json({ message: 'Unauthorized' }, 401); } - const cache = await caches.open('auth'); - const matched = cache.match(new Request(`http://localhost/__auth/${sessionId}`, { method: 'GET' })); - - if (!matched) { + if (!await jwt.verify( + sessionId, + hexDecode(c.env.JWT_PUBLIC_KEY), + { algorithm: 'RS256' }, + )) { return c.json({ message: 'Unauthorized' }, 401); } - const user = JSON.parse(await collectResponse(await matched)); + + const token = jwt.decode(sessionId) as any; + const user = token.payload.user; const response = fetch('https://openapi.naver.com/v1/nid/me', { headers: { diff --git a/apps/api/src/typings.ts b/apps/api/src/typings.ts index 9691667..e614128 100644 --- a/apps/api/src/typings.ts +++ b/apps/api/src/typings.ts @@ -3,6 +3,8 @@ export type Env = { OAUTH_CLIENT_ID_NAVER: string; OAUTH_CLIENT_SECRET_NAVER: string; + JWT_SECRET_KEY: string; + JWT_PUBLIC_KEY: string; API_ORIGIN: string; DEV?: string; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ff70f70..388aaab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@libsql/client': specifier: ^0.6.0 version: 0.6.0 + '@tsndr/cloudflare-worker-jwt': + specifier: ^2.5.3 + version: 2.5.3 drizzle-orm: specifier: ^0.30.8 version: 0.30.8(@cloudflare/workers-types@4.20240405.0)(@libsql/client@0.6.0) @@ -2081,6 +2084,10 @@ packages: react: 18.0.0 dev: false + /@tsndr/cloudflare-worker-jwt@2.5.3: + resolution: {integrity: sha512-zbdvjRG86y/ObiBgTJrzBC39t2FcaeGwB6AV7VO4LvHKJNyZvLYRbKT68eaoJhnJldyHhs7yZ69neRVdUd9knA==} + dev: false + /@types/estree@1.0.5: resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==} dev: true From 7a6ea288d5759249775def78efd8cd11b9b9390a Mon Sep 17 00:00:00 2001 From: Xvezda Date: Sat, 27 Apr 2024 20:33:31 +0900 Subject: [PATCH 02/30] =?UTF-8?q?chore:=20jwt=20=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=EB=B8=8C=EB=9F=AC=EB=A6=AC=20=EA=B5=90=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/package.json | 4 +- apps/api/src/services/auth/v1/route.ts | 319 ++++++++++++++----------- pnpm-lock.yaml | 14 +- 3 files changed, 182 insertions(+), 155 deletions(-) diff --git a/apps/api/package.json b/apps/api/package.json index 763d7e6..0599945 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -18,8 +18,8 @@ }, "dependencies": { "@libsql/client": "^0.6.0", - "@tsndr/cloudflare-worker-jwt": "^2.5.3", "drizzle-orm": "^0.30.8", - "hono": "^4.2.4" + "hono": "^4.2.4", + "jose": "^5.2.4" } } \ No newline at end of file diff --git a/apps/api/src/services/auth/v1/route.ts b/apps/api/src/services/auth/v1/route.ts index bd2f70b..6fbe30f 100644 --- a/apps/api/src/services/auth/v1/route.ts +++ b/apps/api/src/services/auth/v1/route.ts @@ -2,7 +2,7 @@ import { Hono, MiddlewareHandler } from 'hono'; import { cors } from 'hono/cors'; import { HTTPException } from 'hono/http-exception'; import { setCookie, getCookie, deleteCookie } from 'hono/cookie'; -import jwt from '@tsndr/cloudflare-worker-jwt'; +import * as jose from 'jose'; import { eq } from 'drizzle-orm'; import { drizzle } from 'drizzle-orm/d1'; import { Env } from '@/typings'; @@ -50,18 +50,13 @@ type NidVerifyResponse = NidApiResponse<{ client_id: string; }>; -function hexEncode(str: string) { - return str - .split('') - .map(c => c.charCodeAt(0).toString(16).padStart(2, '0')) - .join(''); -} - -function hexDecode(str: string) { - return str - .match(/[0-9a-f]{2}/ig)! - .map(b => String.fromCharCode(parseInt(b, 16))) - .join(''); +function prefixRoot< + const TPrefix extends string, + const TValue extends { [k: string]: any } +>(prefix: TPrefix, value: TValue) { + return Object.fromEntries( + Object.entries(value).map(([k, v]) => [`${prefix}${k}`, v]) + ) as { [k in keyof TValue as k extends string ? `${TPrefix}${k}` : never]: TValue[keyof TValue] }; } const collectResponse = async (response?: Response, fallback: string = '') => { @@ -99,105 +94,144 @@ app.use('*', cors({ const withPrevUrl: MiddlewareHandler<{ Bindings: Env; Variables: { + privateKey: jose.KeyLike; + publicKey: jose.KeyLike; prevUrl: string; }; }> = async (c, next) => { let prevUrl = 'https://cheda.kr/'; try { prevUrl = new URL(c.req.query('prevUrl') ?? c.req.header('Referer') ?? '').toString(); - } catch (e) {} + + const stateCookie = getCookie(c, 'state'); + if (stateCookie) { + const jwt = await jose.compactDecrypt(getCookie(c, 'state')!, c.var.privateKey); + const { payload } = await jose.jwtVerify<{ id: string; url: string }>(jwt.plaintext, c.var.publicKey); + + prevUrl = payload.url; + + deleteCookie(c, 'state'); + } + } catch (e) { + console.error(e); + } c.set('prevUrl', prevUrl); await next(); }; -const withSessionId: MiddlewareHandler<{ - Bindings: Env, +const u8ToString = (arr: Uint8Array) => { + return Array.from(arr).map(b => String.fromCharCode(b)).join(''); +}; + +const withPrivateKey: MiddlewareHandler<{ + Bindings: Env; Variables: { - prevUrl: string + privateKey: jose.KeyLike; }; }> = async (c, next) => { - const sessionId = getCookie(c, 'session_id'); - if (!sessionId) throw new HTTPException(401, { message: 'Unauthorized' }); - - // NOTE: 검증하지 않기 때문에 액세스 토큰이 유출된 경우 로그아웃 처리 필요 - const user = (jwt.decode(sessionId) as any).payload.user; - const expireAt = new Date(user.expireAt).getTime(); - - if (Date.now() < expireAt) { - if (c.var.prevUrl) { - return c.redirect(c.var.prevUrl); - } - return await next(); - } - - const url = new URL('https://nid.naver.com/oauth2.0/token'); - url.searchParams.append('grant_type', 'refresh_token'); - url.searchParams.append('client_id', c.env.OAUTH_CLIENT_ID_NAVER); - url.searchParams.append('client_secret', c.env.OAUTH_CLIENT_SECRET_NAVER); - url.searchParams.append('refresh_token', user.refreshToken); + const privateKey = await jose.importPKCS8(u8ToString(jose.base64url.decode(c.env.JWT_SECRET_KEY)), 'RS256'); + c.set('privateKey', privateKey); - const response = await fetch(url); - const result = await response.json() as RefreshTokenResponse; - - const headers = { - 'Authorization': `Bearer ${result.access_token}`, - }; - const [meResult, verifyResult] = await Promise.all([ - fetch('https://openapi.naver.com/v1/nid/me', { headers }).then(r => r.json()) as Promise, - fetch('https://openapi.naver.com/v1/nid/verify?info=true', { headers }).then(r => r.json()) as Promise - ]); + await next(); +}; - const userPatch = { - userName: meResult.response.nickname, - userImage: meResult.response.profile_image, - accessToken: result.access_token, - tokenType: result.token_type, - expireAt: new Date(verifyResult.response.expire_date), - updatedAt: new Date(), +const withPublicKey: MiddlewareHandler<{ + Bindings: Env; + Variables: { + publicKey: jose.KeyLike; }; +}> = async (c, next) => { + const publicKey = await jose.importSPKI(u8ToString(jose.base64url.decode(c.env.JWT_PUBLIC_KEY)), 'RS256'); + c.set('publicKey', publicKey); - const db = drizzle(c.env.DB); - await db.update(usersTable) - .set(userPatch) - .where(eq(usersTable.userId, meResult.response.id)); - - const newSessionId = await jwt.sign( - { - user: { - ...user, - ...userPatch, - }, - exp: Math.floor(Date.now() / 1000) + parseInt(result.expires_in), - }, - hexDecode(c.env.JWT_SECRET_KEY), - { algorithm: 'RS256' } - ); + await next(); +}; - setCookie(c, 'session_id', newSessionId, { - expires: new Date(Date.now() + parseInt(result.expires_in) * 1000), - ...c.env.DEV ? {} : { - secure: true, - domain: '.cheda.kr', - }, - }); +const withSessionId: MiddlewareHandler<{ + Bindings: Env, + Variables: { + publicKey: jose.KeyLike; + privateKey: jose.KeyLike; + prevUrl: string + }; +}> = async (c, next) => { + class InvalidToken extends Error {} + try { + const sessionId = getCookie(c, 'session_id'); + if (!sessionId) throw new InvalidToken(); + + const { payload } = await jose.jwtVerify(sessionId, c.var.publicKey); + const user = payload['http:cheda.kr/user'] as any; + + const threshold = 1000 * 60 * 10; + if (new Date(user.expireAt).getTime() <= Date.now() + threshold) { + const url = new URL('https://nid.naver.com/oauth2.0/token'); + url.searchParams.append('grant_type', 'refresh_token'); + url.searchParams.append('client_id', c.env.OAUTH_CLIENT_ID_NAVER); + url.searchParams.append('client_secret', c.env.OAUTH_CLIENT_SECRET_NAVER); + url.searchParams.append('refresh_token', user.refreshToken); + + const response = await fetch(url); + const result = await response.json() as RefreshTokenResponse; + + const headers = { + 'Authorization': `Bearer ${result.access_token}`, + }; + + const [meResult, verifyResult] = await Promise.all([ + fetch('https://openapi.naver.com/v1/nid/me', { headers }).then(r => r.json()) as Promise, + fetch('https://openapi.naver.com/v1/nid/verify?info=true', { headers }).then(r => r.json()) as Promise + ]); + + const userPatch = { + userName: meResult.response.nickname, + userImage: meResult.response.profile_image, + accessToken: result.access_token, + tokenType: result.token_type, + expireAt: new Date(verifyResult.response.expire_date), + updatedAt: new Date(), + }; + + const db = drizzle(c.env.DB); + await db.update(usersTable) + .set(userPatch) + .where(eq(usersTable.userId, meResult.response.id)); + + const expires = new Date(Date.now() + parseInt(result.expires_in) * 1000); + const jwt = await new jose.SignJWT({ ...user, ...userPatch }) + .setProtectedHeader({ alg: 'RS256' }) + .setExpirationTime(expires) + .sign(c.var.privateKey); + + setCookie(c, 'session_id', jwt, { + expires, + ...c.env.DEV ? {} : { + secure: true, + domain: '.cheda.kr', + }, + }); + } + } catch (e) { + if (e instanceof InvalidToken) { + deleteCookie(c, 'session_id'); + return c.json({ message: 'Unauthorized' }, 401); + } + throw e; + } await next(); }; -app.get('/logout', withPrevUrl, async (c) => { +app.get('/logout', withPublicKey, withPrevUrl, async (c) => { const sessionId = getCookie(c, 'session_id'); if (!sessionId) { return c.redirect(c.var.prevUrl); } - if (!await jwt.verify(sessionId, hexDecode(c.env.JWT_PUBLIC_KEY), { algorithm: 'RS256' })) { - deleteCookie(c, 'session_id'); - return c.redirect(c.var.prevUrl); - } - - const user = (jwt.decode(sessionId).payload as any).user; + const { payload } = await jose.jwtVerify(sessionId, c.var.publicKey); + const user = payload['http:cheda.kr/user'] as any; /* const url = new URL('https://nid.naver.com/oauth2.0/token'); @@ -225,33 +259,33 @@ app.get('/logout', withPrevUrl, async (c) => { return c.redirect(c.var.prevUrl); }); -app.get('/login', withPrevUrl, async (c) => { - const cache = await caches.open('auth'); - - const prevState = getCookie(c, 'state'); - if (prevState) { - await cache.delete(new Request(`http://localhost/__auth/${prevState}`, { method: 'GET' })); - } - +app.get('/login', withPrivateKey, withPublicKey, withPrevUrl, async (c) => { const url = new URL(`https://nid.naver.com/oauth2.0/authorize`); url.searchParams.append('response_type', 'code'); url.searchParams.append('client_id', c.env.OAUTH_CLIENT_ID_NAVER); url.searchParams.append('redirect_uri', `${c.env.API_ORIGIN}/services/auth/v1/callback`); - const state = crypto.randomUUID(); - url.searchParams.append('state', state); + const state = { + id: crypto.randomUUID(), + url: c.var.prevUrl, + }; + url.searchParams.append('state', state.id); + + const expires = new Date(Date.now() + 1000 * 60 * 5); + + const jwt = await new jose.SignJWT(state) + .setProtectedHeader({ alg: 'RS256' }) + .setExpirationTime(expires) + .sign(c.var.privateKey); - await cache.put( - new Request(`http://localhost/__auth/${state}`, { method: 'GET' }), - new Response(c.var.prevUrl, { - headers: { - 'Cache-Control': 'max-age=600', - }, - }), - ); + const publicKey = await jose.importSPKI(u8ToString(jose.base64url.decode(c.env.JWT_PUBLIC_KEY)), 'RSA-OAEP-256'); + const jwe = await new jose.CompactEncrypt(new TextEncoder().encode(jwt)) + .setProtectedHeader({ alg: 'RSA-OAEP-256', enc: 'A256GCM' }) + .encrypt(publicKey); - setCookie(c, 'state', state, { + setCookie(c, 'state', jwe, { httpOnly: true, + expires, ...c.env.DEV ? {} : { secure: true, domain: '.cheda.kr', @@ -261,32 +295,36 @@ app.get('/login', withPrevUrl, async (c) => { return c.redirect(url.toString()); }); -app.get('/callback', async (c) => { - const state = getCookie(c, 'state'); - deleteCookie(c, 'state'); +app.get('/callback', withPrivateKey, withPublicKey, async (c) => { + let state: { id: string; url: string } | null = null; + try { + const cookie = getCookie(c, 'state')!; - if (!state) { - return c.json({ message: 'Forbidden' }, 403); + const privateKey = await jose.importPKCS8(u8ToString(jose.base64url.decode(c.env.JWT_SECRET_KEY)), 'RSA-OAEP-256'); + const jwt = await jose.compactDecrypt(cookie, privateKey); + + const { payload } = await jose.jwtVerify<{ id: string; url: string }>(jwt.plaintext, c.var.publicKey); + state = payload; + } catch (e) { + console.error(e); + /* noop */ + } finally { + deleteCookie(c, 'state'); } - const cache = await caches.open('auth'); - const cached = await cache.match(new Request(`http://localhost/__auth/${state}`, { method: 'GET' })); - if (!cached) { - return c.json({ message: 'Forbidden' }, 403); + if (!state) { + return c.json({ message: 'Invalid state' }, 403); } - const prevUrl = await collectResponse(cached); const code = c.req.query('code'); if (!code) { - return c.redirect(prevUrl); + return c.redirect(state.url); } - if (!state || state !== c.req.query('state')) { + if (!state.id || state.id !== c.req.query('state')) { return c.json({ message: 'Invalid request' }, 400); } - await cache.delete(new Request(`http://localhost/__auth/${state}`, { method: 'GET' })); - const url = new URL('https://nid.naver.com/oauth2.0/token'); url.searchParams.append('grant_type', 'authorization_code'); url.searchParams.append('client_id', c.env.OAUTH_CLIENT_ID_NAVER); @@ -296,7 +334,7 @@ app.get('/callback', async (c) => { const response = await fetch(url); if (!response.ok) { - return c.redirect(prevUrl); + return c.redirect(state.url); } const result = await response.json() as AccessTokenResponse; @@ -340,50 +378,39 @@ app.get('/callback', async (c) => { .where(eq(usersTable.userId, user.userId)); } - const sessionId = await jwt.sign( - { - user: { - userId: user.userId, - userName: user.userName, - userImage: user.userImage, - accessToken: user.accessToken, - tokenType: user.tokenType, - expireAt: user.expireAt, - updatedAt: user.updatedAt, - }, - exp: Math.floor(Date.now() / 1000) + parseInt(result.expires_in), + const payload = prefixRoot('http:cheda.kr/', { + user: { + userId: user.userId, + userName: user.userName, + userImage: user.userImage, + accessToken: user.accessToken, + expireAt: user.expireAt, + updatedAt: user.updatedAt, }, - hexDecode(c.env.JWT_SECRET_KEY), - { algorithm: 'RS256' } - ); + }); + const jwt = await new jose.SignJWT({ ...payload }) + .setProtectedHeader({ alg: 'RS256' }) + .setExpirationTime(user.expireAt) + .sign(c.var.privateKey); - setCookie(c, 'session_id', sessionId, { + setCookie(c, 'session_id', jwt, { expires: new Date(Date.now() + parseInt(result.expires_in) * 1000), ...c.env.DEV ? {} : { secure: true, domain: '.cheda.kr', }, }); - - return c.redirect(prevUrl); + return c.redirect(state.url); }); -app.get('/me', withSessionId, async (c) => { +app.get('/me', withPublicKey, withSessionId, async (c) => { const sessionId = getCookie(c, 'session_id'); if (!sessionId) { return c.json({ message: 'Unauthorized' }, 401); } - if (!await jwt.verify( - sessionId, - hexDecode(c.env.JWT_PUBLIC_KEY), - { algorithm: 'RS256' }, - )) { - return c.json({ message: 'Unauthorized' }, 401); - } - - const token = jwt.decode(sessionId) as any; - const user = token.payload.user; + const { payload } = await jose.jwtVerify(sessionId, c.var.publicKey); + const user = payload['http:cheda.kr/user'] as any; const response = fetch('https://openapi.naver.com/v1/nid/me', { headers: { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 388aaab..9845b07 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,15 +17,15 @@ importers: '@libsql/client': specifier: ^0.6.0 version: 0.6.0 - '@tsndr/cloudflare-worker-jwt': - specifier: ^2.5.3 - version: 2.5.3 drizzle-orm: specifier: ^0.30.8 version: 0.30.8(@cloudflare/workers-types@4.20240405.0)(@libsql/client@0.6.0) hono: specifier: ^4.2.4 version: 4.2.4 + jose: + specifier: ^5.2.4 + version: 5.2.4 devDependencies: '@cloudflare/vitest-pool-workers': specifier: ^0.1.0 @@ -2084,10 +2084,6 @@ packages: react: 18.0.0 dev: false - /@tsndr/cloudflare-worker-jwt@2.5.3: - resolution: {integrity: sha512-zbdvjRG86y/ObiBgTJrzBC39t2FcaeGwB6AV7VO4LvHKJNyZvLYRbKT68eaoJhnJldyHhs7yZ69neRVdUd9knA==} - dev: false - /@types/estree@1.0.5: resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==} dev: true @@ -4312,6 +4308,10 @@ packages: resolution: {integrity: sha512-gFqAIbuKyyso/3G2qhiO2OM6shY6EPP/R0+mkDbyspxKazh8BXDC5FiFsUjlczgdNz/vfra0da2y+aHrusLG/Q==} hasBin: true + /jose@5.2.4: + resolution: {integrity: sha512-6ScbIk2WWCeXkmzF6bRPmEuaqy1m8SbsRFMa/FLrSCkGIhj8OLVG/IH+XHVmNMx/KUo8cVWEE6oKR4dJ+S0Rkg==} + dev: false + /js-base64@3.7.7: resolution: {integrity: sha512-7rCnleh0z2CkXhH67J8K1Ytz0b2Y+yxTPL+/KOJoa20hfnVQ/3/T6W/KflYI4bRHRagNeXeU2bkNGI3v1oS/lw==} dev: false From ed4272d591d79a21b6d1cfd7dfb0afddfc5248ef Mon Sep 17 00:00:00 2001 From: Xvezda Date: Sat, 27 Apr 2024 20:58:54 +0900 Subject: [PATCH 03/30] =?UTF-8?q?chore:=20=ED=83=80=EC=9B=90=EA=B3=A1?= =?UTF-8?q?=EC=84=A0=20=EA=B8=B0=EB=B0=98=20=EC=95=8C=EA=B3=A0=EB=A6=AC?= =?UTF-8?q?=EC=A6=98=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/src/services/auth/v1/route.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/api/src/services/auth/v1/route.ts b/apps/api/src/services/auth/v1/route.ts index 6fbe30f..5a79198 100644 --- a/apps/api/src/services/auth/v1/route.ts +++ b/apps/api/src/services/auth/v1/route.ts @@ -131,7 +131,7 @@ const withPrivateKey: MiddlewareHandler<{ privateKey: jose.KeyLike; }; }> = async (c, next) => { - const privateKey = await jose.importPKCS8(u8ToString(jose.base64url.decode(c.env.JWT_SECRET_KEY)), 'RS256'); + const privateKey = await jose.importPKCS8(u8ToString(jose.base64url.decode(c.env.JWT_SECRET_KEY)), 'ES256'); c.set('privateKey', privateKey); await next(); @@ -143,7 +143,7 @@ const withPublicKey: MiddlewareHandler<{ publicKey: jose.KeyLike; }; }> = async (c, next) => { - const publicKey = await jose.importSPKI(u8ToString(jose.base64url.decode(c.env.JWT_PUBLIC_KEY)), 'RS256'); + const publicKey = await jose.importSPKI(u8ToString(jose.base64url.decode(c.env.JWT_PUBLIC_KEY)), 'ES256'); c.set('publicKey', publicKey); await next(); @@ -202,7 +202,7 @@ const withSessionId: MiddlewareHandler<{ const expires = new Date(Date.now() + parseInt(result.expires_in) * 1000); const jwt = await new jose.SignJWT({ ...user, ...userPatch }) - .setProtectedHeader({ alg: 'RS256' }) + .setProtectedHeader({ alg: 'ES256' }) .setExpirationTime(expires) .sign(c.var.privateKey); @@ -274,13 +274,13 @@ app.get('/login', withPrivateKey, withPublicKey, withPrevUrl, async (c) => { const expires = new Date(Date.now() + 1000 * 60 * 5); const jwt = await new jose.SignJWT(state) - .setProtectedHeader({ alg: 'RS256' }) + .setProtectedHeader({ alg: 'ES256' }) .setExpirationTime(expires) .sign(c.var.privateKey); - const publicKey = await jose.importSPKI(u8ToString(jose.base64url.decode(c.env.JWT_PUBLIC_KEY)), 'RSA-OAEP-256'); + const publicKey = await jose.importSPKI(u8ToString(jose.base64url.decode(c.env.JWT_PUBLIC_KEY)), 'ECDH-ES'); const jwe = await new jose.CompactEncrypt(new TextEncoder().encode(jwt)) - .setProtectedHeader({ alg: 'RSA-OAEP-256', enc: 'A256GCM' }) + .setProtectedHeader({ alg: 'ECDH-ES', enc: 'A256GCM' }) .encrypt(publicKey); setCookie(c, 'state', jwe, { @@ -300,7 +300,7 @@ app.get('/callback', withPrivateKey, withPublicKey, async (c) => { try { const cookie = getCookie(c, 'state')!; - const privateKey = await jose.importPKCS8(u8ToString(jose.base64url.decode(c.env.JWT_SECRET_KEY)), 'RSA-OAEP-256'); + const privateKey = await jose.importPKCS8(u8ToString(jose.base64url.decode(c.env.JWT_SECRET_KEY)), 'ECDH-ES'); const jwt = await jose.compactDecrypt(cookie, privateKey); const { payload } = await jose.jwtVerify<{ id: string; url: string }>(jwt.plaintext, c.var.publicKey); @@ -389,7 +389,7 @@ app.get('/callback', withPrivateKey, withPublicKey, async (c) => { }, }); const jwt = await new jose.SignJWT({ ...payload }) - .setProtectedHeader({ alg: 'RS256' }) + .setProtectedHeader({ alg: 'ES256' }) .setExpirationTime(user.expireAt) .sign(c.var.privateKey); From 18316a1b357f3aae6f1c68856c14040df18bb5a5 Mon Sep 17 00:00:00 2001 From: Xvezda Date: Sat, 27 Apr 2024 21:17:57 +0900 Subject: [PATCH 04/30] =?UTF-8?q?refactor:=20=ED=83=80=EC=9E=85=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EB=B0=8F=20=EA=B5=AC=EC=A1=B0=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/src/services/auth/v1/route.ts | 72 ++++++++++++++++++-------- 1 file changed, 51 insertions(+), 21 deletions(-) diff --git a/apps/api/src/services/auth/v1/route.ts b/apps/api/src/services/auth/v1/route.ts index 5a79198..c480465 100644 --- a/apps/api/src/services/auth/v1/route.ts +++ b/apps/api/src/services/auth/v1/route.ts @@ -50,13 +50,38 @@ type NidVerifyResponse = NidApiResponse<{ client_id: string; }>; +const JWT_PREFIX = 'http:cheda.kr/'; + +type SessionPayload = PrefixRoot; + +type StatePayload = PrefixRoot; + +type PrefixRoot = { + [k in keyof TValue as k extends string ? `${TPrefix}${k}` : never]: TValue[k]; +}; + function prefixRoot< const TPrefix extends string, const TValue extends { [k: string]: any } >(prefix: TPrefix, value: TValue) { return Object.fromEntries( Object.entries(value).map(([k, v]) => [`${prefix}${k}`, v]) - ) as { [k in keyof TValue as k extends string ? `${TPrefix}${k}` : never]: TValue[keyof TValue] }; + ) as PrefixRoot; } const collectResponse = async (response?: Response, fallback: string = '') => { @@ -106,9 +131,9 @@ const withPrevUrl: MiddlewareHandler<{ const stateCookie = getCookie(c, 'state'); if (stateCookie) { const jwt = await jose.compactDecrypt(getCookie(c, 'state')!, c.var.privateKey); - const { payload } = await jose.jwtVerify<{ id: string; url: string }>(jwt.plaintext, c.var.publicKey); + const { payload } = await jose.jwtVerify(jwt.plaintext, c.var.publicKey); - prevUrl = payload.url; + prevUrl = payload['http:cheda.kr/state'].url; deleteCookie(c, 'state'); } @@ -163,8 +188,8 @@ const withSessionId: MiddlewareHandler<{ const sessionId = getCookie(c, 'session_id'); if (!sessionId) throw new InvalidToken(); - const { payload } = await jose.jwtVerify(sessionId, c.var.publicKey); - const user = payload['http:cheda.kr/user'] as any; + const { payload } = await jose.jwtVerify(sessionId, c.var.publicKey); + const user = payload['http:cheda.kr/user']; const threshold = 1000 * 60 * 10; if (new Date(user.expireAt).getTime() <= Date.now() + threshold) { @@ -230,8 +255,8 @@ app.get('/logout', withPublicKey, withPrevUrl, async (c) => { return c.redirect(c.var.prevUrl); } - const { payload } = await jose.jwtVerify(sessionId, c.var.publicKey); - const user = payload['http:cheda.kr/user'] as any; + const { payload } = await jose.jwtVerify(sessionId, c.var.publicKey); + const user = payload['http:cheda.kr/user']; /* const url = new URL('https://nid.naver.com/oauth2.0/token'); @@ -265,11 +290,13 @@ app.get('/login', withPrivateKey, withPublicKey, withPrevUrl, async (c) => { url.searchParams.append('client_id', c.env.OAUTH_CLIENT_ID_NAVER); url.searchParams.append('redirect_uri', `${c.env.API_ORIGIN}/services/auth/v1/callback`); - const state = { - id: crypto.randomUUID(), - url: c.var.prevUrl, - }; - url.searchParams.append('state', state.id); + const state: StatePayload = prefixRoot(JWT_PREFIX, { + state: { + id: crypto.randomUUID(), + url: c.var.prevUrl, + }, + }); + url.searchParams.append('state', state['http:cheda.kr/state'].id); const expires = new Date(Date.now() + 1000 * 60 * 5); @@ -296,14 +323,14 @@ app.get('/login', withPrivateKey, withPublicKey, withPrevUrl, async (c) => { }); app.get('/callback', withPrivateKey, withPublicKey, async (c) => { - let state: { id: string; url: string } | null = null; + let state: StatePayload | undefined; try { const cookie = getCookie(c, 'state')!; const privateKey = await jose.importPKCS8(u8ToString(jose.base64url.decode(c.env.JWT_SECRET_KEY)), 'ECDH-ES'); const jwt = await jose.compactDecrypt(cookie, privateKey); - const { payload } = await jose.jwtVerify<{ id: string; url: string }>(jwt.plaintext, c.var.publicKey); + const { payload } = await jose.jwtVerify(jwt.plaintext, c.var.publicKey); state = payload; } catch (e) { console.error(e); @@ -318,10 +345,12 @@ app.get('/callback', withPrivateKey, withPublicKey, async (c) => { const code = c.req.query('code'); if (!code) { - return c.redirect(state.url); + return c.redirect(state['http:cheda.kr/state'].url); } - if (!state.id || state.id !== c.req.query('state')) { + const { id, url: prevUrl } = state['http:cheda.kr/state']; + + if (!id || id !== c.req.query('state')) { return c.json({ message: 'Invalid request' }, 400); } @@ -334,7 +363,7 @@ app.get('/callback', withPrivateKey, withPublicKey, async (c) => { const response = await fetch(url); if (!response.ok) { - return c.redirect(state.url); + return c.redirect(prevUrl); } const result = await response.json() as AccessTokenResponse; @@ -378,7 +407,7 @@ app.get('/callback', withPrivateKey, withPublicKey, async (c) => { .where(eq(usersTable.userId, user.userId)); } - const payload = prefixRoot('http:cheda.kr/', { + const payload = prefixRoot(JWT_PREFIX, { user: { userId: user.userId, userName: user.userName, @@ -400,7 +429,8 @@ app.get('/callback', withPrivateKey, withPublicKey, async (c) => { domain: '.cheda.kr', }, }); - return c.redirect(state.url); + + return c.redirect(prevUrl); }); app.get('/me', withPublicKey, withSessionId, async (c) => { @@ -409,8 +439,8 @@ app.get('/me', withPublicKey, withSessionId, async (c) => { return c.json({ message: 'Unauthorized' }, 401); } - const { payload } = await jose.jwtVerify(sessionId, c.var.publicKey); - const user = payload['http:cheda.kr/user'] as any; + const { payload } = await jose.jwtVerify(sessionId, c.var.publicKey); + const user = payload['http:cheda.kr/user']; const response = fetch('https://openapi.naver.com/v1/nid/me', { headers: { From 7fc5697a0216cfc3f3525279d777cab39b376c96 Mon Sep 17 00:00:00 2001 From: Xvezda Date: Sat, 27 Apr 2024 21:52:05 +0900 Subject: [PATCH 05/30] =?UTF-8?q?refactor:=20=ED=86=A0=ED=81=B0=20?= =?UTF-8?q?=EC=9C=A0=ED=8B=B8=EB=A6=AC=ED=8B=B0=20=ED=95=A8=EC=88=98=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/src/services/auth/v1/route.ts | 143 ++++++++++++------------- 1 file changed, 68 insertions(+), 75 deletions(-) diff --git a/apps/api/src/services/auth/v1/route.ts b/apps/api/src/services/auth/v1/route.ts index c480465..b371c36 100644 --- a/apps/api/src/services/auth/v1/route.ts +++ b/apps/api/src/services/auth/v1/route.ts @@ -1,4 +1,4 @@ -import { Hono, MiddlewareHandler } from 'hono'; +import { Hono, MiddlewareHandler, Context } from 'hono'; import { cors } from 'hono/cors'; import { HTTPException } from 'hono/http-exception'; import { setCookie, getCookie, deleteCookie } from 'hono/cookie'; @@ -96,26 +96,6 @@ const collectResponse = async (response?: Response, fallback: string = '') => { return result || fallback; }; -const app = new Hono<{ Bindings: Env }>(); - -app.use('*', cors({ - origin: (origin, c) => { - if (c.env.DEV) { - return origin; - } - - try { - const originUrl = new URL(origin); - if (originUrl.hostname === 'cheda.kr' || originUrl.hostname.endsWith('.cheda.kr')) { - return origin; - } - } catch (e) {} - - return 'https://cheda.kr'; - }, - credentials: true, -})); - const withPrevUrl: MiddlewareHandler<{ Bindings: Env; Variables: { @@ -131,7 +111,7 @@ const withPrevUrl: MiddlewareHandler<{ const stateCookie = getCookie(c, 'state'); if (stateCookie) { const jwt = await jose.compactDecrypt(getCookie(c, 'state')!, c.var.privateKey); - const { payload } = await jose.jwtVerify(jwt.plaintext, c.var.publicKey); + const payload = await verifyToken(c, jwt.plaintext); prevUrl = payload['http:cheda.kr/state'].url; @@ -150,36 +130,46 @@ const u8ToString = (arr: Uint8Array) => { return Array.from(arr).map(b => String.fromCharCode(b)).join(''); }; -const withPrivateKey: MiddlewareHandler<{ - Bindings: Env; - Variables: { - privateKey: jose.KeyLike; - }; -}> = async (c, next) => { - const privateKey = await jose.importPKCS8(u8ToString(jose.base64url.decode(c.env.JWT_SECRET_KEY)), 'ES256'); - c.set('privateKey', privateKey); +const verifyToken = async , C extends Context = Context>(context: C, token: string | Uint8Array) => { + const publicKey = await jose.importSPKI(u8ToString(jose.base64url.decode(context.env.JWT_PUBLIC_KEY)), 'ES256'); + const { payload } = await jose.jwtVerify(token, publicKey); + return payload; +}; - await next(); +const signToken = async , C extends Context = Context>(context: C, payload: T, expires: string | number | Date) => { + const privateKey = await jose.importPKCS8(u8ToString(jose.base64url.decode(context.env.JWT_SECRET_KEY)), 'ES256'); + const jwt = await new jose.SignJWT(payload) + .setProtectedHeader({ alg: 'ES256' }) + .setExpirationTime(expires) + .sign(privateKey); + + return jwt; }; -const withPublicKey: MiddlewareHandler<{ - Bindings: Env; - Variables: { - publicKey: jose.KeyLike; - }; -}> = async (c, next) => { - const publicKey = await jose.importSPKI(u8ToString(jose.base64url.decode(c.env.JWT_PUBLIC_KEY)), 'ES256'); - c.set('publicKey', publicKey); +const encryptToken = async (context: C, token: string) => { + const publicKey = await jose.importSPKI(u8ToString(jose.base64url.decode(context.env.JWT_PUBLIC_KEY)), 'ECDH-ES'); + const jwe = await new jose.CompactEncrypt(new TextEncoder().encode(token)) + .setProtectedHeader({ alg: 'ECDH-ES', enc: 'A256GCM' }) + .encrypt(publicKey); - await next(); + return jwe; +}; + +const decryptToken = async (context: C, token: string) => { + const privateKey = await jose.importPKCS8(u8ToString(jose.base64url.decode(context.env.JWT_SECRET_KEY)), 'ECDH-ES'); + const result = await jose.compactDecrypt(token, privateKey); + return result.plaintext; }; -const withSessionId: MiddlewareHandler<{ +const withSession: MiddlewareHandler<{ Bindings: Env, Variables: { publicKey: jose.KeyLike; privateKey: jose.KeyLike; - prevUrl: string + prevUrl: string; + session: { + user: SessionPayload['http:cheda.kr/user']; + }; }; }> = async (c, next) => { class InvalidToken extends Error {} @@ -188,7 +178,7 @@ const withSessionId: MiddlewareHandler<{ const sessionId = getCookie(c, 'session_id'); if (!sessionId) throw new InvalidToken(); - const { payload } = await jose.jwtVerify(sessionId, c.var.publicKey); + const payload = await verifyToken(c, sessionId); const user = payload['http:cheda.kr/user']; const threshold = 1000 * 60 * 10; @@ -226,10 +216,7 @@ const withSessionId: MiddlewareHandler<{ .where(eq(usersTable.userId, meResult.response.id)); const expires = new Date(Date.now() + parseInt(result.expires_in) * 1000); - const jwt = await new jose.SignJWT({ ...user, ...userPatch }) - .setProtectedHeader({ alg: 'ES256' }) - .setExpirationTime(expires) - .sign(c.var.privateKey); + const jwt = await signToken(c, { ...user, ...userPatch }, expires); setCookie(c, 'session_id', jwt, { expires, @@ -239,6 +226,7 @@ const withSessionId: MiddlewareHandler<{ }, }); } + c.set('session', { user: payload['http:cheda.kr/user'] }); } catch (e) { if (e instanceof InvalidToken) { deleteCookie(c, 'session_id'); @@ -246,16 +234,37 @@ const withSessionId: MiddlewareHandler<{ } throw e; } + await next(); }; -app.get('/logout', withPublicKey, withPrevUrl, async (c) => { +const app = new Hono<{ Bindings: Env }>(); + +app.use('*', cors({ + origin: (origin, c) => { + if (c.env.DEV) { + return origin; + } + + try { + const originUrl = new URL(origin); + if (originUrl.hostname === 'cheda.kr' || originUrl.hostname.endsWith('.cheda.kr')) { + return origin; + } + } catch (e) {} + + return 'https://cheda.kr'; + }, + credentials: true, +})); + +app.get('/logout', withPrevUrl, async (c) => { const sessionId = getCookie(c, 'session_id'); if (!sessionId) { return c.redirect(c.var.prevUrl); } - const { payload } = await jose.jwtVerify(sessionId, c.var.publicKey); + const payload = await verifyToken(c, sessionId); const user = payload['http:cheda.kr/user']; /* @@ -284,7 +293,7 @@ app.get('/logout', withPublicKey, withPrevUrl, async (c) => { return c.redirect(c.var.prevUrl); }); -app.get('/login', withPrivateKey, withPublicKey, withPrevUrl, async (c) => { +app.get('/login', withPrevUrl, async (c) => { const url = new URL(`https://nid.naver.com/oauth2.0/authorize`); url.searchParams.append('response_type', 'code'); url.searchParams.append('client_id', c.env.OAUTH_CLIENT_ID_NAVER); @@ -300,15 +309,8 @@ app.get('/login', withPrivateKey, withPublicKey, withPrevUrl, async (c) => { const expires = new Date(Date.now() + 1000 * 60 * 5); - const jwt = await new jose.SignJWT(state) - .setProtectedHeader({ alg: 'ES256' }) - .setExpirationTime(expires) - .sign(c.var.privateKey); - - const publicKey = await jose.importSPKI(u8ToString(jose.base64url.decode(c.env.JWT_PUBLIC_KEY)), 'ECDH-ES'); - const jwe = await new jose.CompactEncrypt(new TextEncoder().encode(jwt)) - .setProtectedHeader({ alg: 'ECDH-ES', enc: 'A256GCM' }) - .encrypt(publicKey); + const jwt = await signToken(c, state, expires); + const jwe = await encryptToken(c, jwt); setCookie(c, 'state', jwe, { httpOnly: true, @@ -322,15 +324,14 @@ app.get('/login', withPrivateKey, withPublicKey, withPrevUrl, async (c) => { return c.redirect(url.toString()); }); -app.get('/callback', withPrivateKey, withPublicKey, async (c) => { +app.get('/callback', async (c) => { let state: StatePayload | undefined; try { const cookie = getCookie(c, 'state')!; - const privateKey = await jose.importPKCS8(u8ToString(jose.base64url.decode(c.env.JWT_SECRET_KEY)), 'ECDH-ES'); - const jwt = await jose.compactDecrypt(cookie, privateKey); + const jwt = await decryptToken(c, cookie); + const payload = await verifyToken(c, jwt); - const { payload } = await jose.jwtVerify(jwt.plaintext, c.var.publicKey); state = payload; } catch (e) { console.error(e); @@ -417,10 +418,8 @@ app.get('/callback', withPrivateKey, withPublicKey, async (c) => { updatedAt: user.updatedAt, }, }); - const jwt = await new jose.SignJWT({ ...payload }) - .setProtectedHeader({ alg: 'ES256' }) - .setExpirationTime(user.expireAt) - .sign(c.var.privateKey); + + const jwt = await signToken(c, payload, user.expireAt); setCookie(c, 'session_id', jwt, { expires: new Date(Date.now() + parseInt(result.expires_in) * 1000), @@ -433,14 +432,8 @@ app.get('/callback', withPrivateKey, withPublicKey, async (c) => { return c.redirect(prevUrl); }); -app.get('/me', withPublicKey, withSessionId, async (c) => { - const sessionId = getCookie(c, 'session_id'); - if (!sessionId) { - return c.json({ message: 'Unauthorized' }, 401); - } - - const { payload } = await jose.jwtVerify(sessionId, c.var.publicKey); - const user = payload['http:cheda.kr/user']; +app.get('/me', withSession, async (c) => { + const { user } = c.var.session; const response = fetch('https://openapi.naver.com/v1/nid/me', { headers: { From 00886ef55d38d4a23115d84a72249e859f06edcb Mon Sep 17 00:00:00 2001 From: Xvezda Date: Sat, 27 Apr 2024 22:54:01 +0900 Subject: [PATCH 06/30] =?UTF-8?q?chore:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=EC=BB=AC=EB=9F=BC=20=EC=A0=9C=EA=B1=B0=20=EB=B0=8F?= =?UTF-8?q?=20`session=5Fsid`=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/src/db/schema.ts | 4 - apps/api/src/services/auth/v1/route.ts | 133 ++++++++++++++----------- 2 files changed, 75 insertions(+), 62 deletions(-) diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index 26fb295..7222172 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -7,10 +7,6 @@ export const users = sqliteTable('users', { userImage: text('user_image'), createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(), updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(), - accessToken: text('access_token'), - refreshToken: text('refresh_token'), - tokenType: text('token_type'), - expireAt: integer('expire_at', { mode: 'timestamp_ms' }), }, (table) => { return { idxUserId: uniqueIndex('idx_users_user_id').on(table.userId), diff --git a/apps/api/src/services/auth/v1/route.ts b/apps/api/src/services/auth/v1/route.ts index b371c36..1b5ab8b 100644 --- a/apps/api/src/services/auth/v1/route.ts +++ b/apps/api/src/services/auth/v1/route.ts @@ -58,9 +58,12 @@ type SessionPayload = PrefixRoot; + +type SecuredSessionPayload = PrefixRoot; @@ -110,7 +113,7 @@ const withPrevUrl: MiddlewareHandler<{ const stateCookie = getCookie(c, 'state'); if (stateCookie) { - const jwt = await jose.compactDecrypt(getCookie(c, 'state')!, c.var.privateKey); + const jwt = await jose.compactDecrypt(stateCookie, c.var.privateKey); const payload = await verifyToken(c, jwt.plaintext); prevUrl = payload['http:cheda.kr/state'].url; @@ -176,18 +179,23 @@ const withSession: MiddlewareHandler<{ try { const sessionId = getCookie(c, 'session_id'); - if (!sessionId) throw new InvalidToken(); + const sessionSid = getCookie(c, 'session_sid'); + if (!sessionId || !sessionSid) throw new InvalidToken(); const payload = await verifyToken(c, sessionId); - const user = payload['http:cheda.kr/user']; + let user = payload['http:cheda.kr/user']; const threshold = 1000 * 60 * 10; - if (new Date(user.expireAt).getTime() <= Date.now() + threshold) { + if (new Date(payload.exp! * 1000).getTime() <= Date.now() + threshold) { + const securedToken = await decryptToken(c, sessionSid); + const securedPayload = await verifyToken(c, securedToken); + const { refreshToken } = securedPayload['http:cheda.kr/user']; + const url = new URL('https://nid.naver.com/oauth2.0/token'); url.searchParams.append('grant_type', 'refresh_token'); url.searchParams.append('client_id', c.env.OAUTH_CLIENT_ID_NAVER); url.searchParams.append('client_secret', c.env.OAUTH_CLIENT_SECRET_NAVER); - url.searchParams.append('refresh_token', user.refreshToken); + url.searchParams.append('refresh_token', refreshToken); const response = await fetch(url); const result = await response.json() as RefreshTokenResponse; @@ -201,24 +209,23 @@ const withSession: MiddlewareHandler<{ fetch('https://openapi.naver.com/v1/nid/verify?info=true', { headers }).then(r => r.json()) as Promise ]); - const userPatch = { + user = { + userId: meResult.response.id, userName: meResult.response.nickname, userImage: meResult.response.profile_image, accessToken: result.access_token, - tokenType: result.token_type, - expireAt: new Date(verifyResult.response.expire_date), - updatedAt: new Date(), }; + const expires = new Date(verifyResult.response.expire_date); - const db = drizzle(c.env.DB); - await db.update(usersTable) - .set(userPatch) - .where(eq(usersTable.userId, meResult.response.id)); - - const expires = new Date(Date.now() + parseInt(result.expires_in) * 1000); - const jwt = await signToken(c, { ...user, ...userPatch }, expires); + const session = await signToken( + c, + prefixRoot(JWT_PREFIX, { + user, + }) satisfies SessionPayload, + expires + ); - setCookie(c, 'session_id', jwt, { + setCookie(c, 'session_id', session, { expires, ...c.env.DEV ? {} : { secure: true, @@ -226,7 +233,7 @@ const withSession: MiddlewareHandler<{ }, }); } - c.set('session', { user: payload['http:cheda.kr/user'] }); + c.set('session', { user }); } catch (e) { if (e instanceof InvalidToken) { deleteCookie(c, 'session_id'); @@ -263,10 +270,6 @@ app.get('/logout', withPrevUrl, async (c) => { if (!sessionId) { return c.redirect(c.var.prevUrl); } - - const payload = await verifyToken(c, sessionId); - const user = payload['http:cheda.kr/user']; - /* const url = new URL('https://nid.naver.com/oauth2.0/token'); url.searchParams.append('grant_type', 'delete'); @@ -279,15 +282,6 @@ app.get('/logout', withPrevUrl, async (c) => { const result = await response.json() as DeleteTokenRespone; */ - const db = drizzle(c.env.DB); - await db.update(usersTable) - .set({ - accessToken: null, - expireAt: null, - updatedAt: new Date(), - }) - .where(eq(usersTable.userId, user.userId)); - deleteCookie(c, 'session_id'); return c.redirect(c.var.prevUrl); @@ -377,52 +371,75 @@ app.get('/callback', async (c) => { fetch('https://openapi.naver.com/v1/nid/verify?info=true', { headers }).then(r => r.json()) as Promise ]); - const db = drizzle(c.env.DB); - + const now = new Date(); const user = { userId: meResult.response.id, userName: meResult.response.nickname, userImage: meResult.response.profile_image, - createdAt: new Date(), - updatedAt: new Date(), + createdAt: now, + updatedAt: now, accessToken: result.access_token, refreshToken: result.refresh_token, tokenType: result.token_type, - expireAt: new Date(verifyResult.response.expire_date), }; + const expires = new Date(verifyResult.response.expire_date); + const db = drizzle(c.env.DB); try { await db.insert(usersTable) - .values(user); + .values({ + userId: meResult.response.id, + userName: meResult.response.nickname, + userImage: meResult.response.profile_image, + createdAt: now, + updatedAt: now, + }); } catch (e) { await db.update(usersTable) .set({ + userName: meResult.response.nickname, + userImage: meResult.response.profile_image, + updatedAt: now, + }) + .where(eq(usersTable.userId, user.userId)); + } + + const session = await signToken( + c, + prefixRoot(JWT_PREFIX, { + user: { + userId: user.userId, userName: user.userName, userImage: user.userImage, accessToken: user.accessToken, + }, + }) satisfies SessionPayload, + expires + ); + + const weekLater = new Date(Date.now() + 1000 * 60 * 60 * 24 * 7); + let securedSession = await signToken( + c, + prefixRoot(JWT_PREFIX, { + user: { refreshToken: user.refreshToken, - tokenType: user.tokenType, - expireAt: user.expireAt, - updatedAt: user.updatedAt, - }) - .where(eq(usersTable.userId, user.userId)); - } + }, + }) satisfies SecuredSessionPayload, + weekLater, + ); + securedSession = await encryptToken(c, securedSession); - const payload = prefixRoot(JWT_PREFIX, { - user: { - userId: user.userId, - userName: user.userName, - userImage: user.userImage, - accessToken: user.accessToken, - expireAt: user.expireAt, - updatedAt: user.updatedAt, + setCookie(c, 'session_id', session, { + expires, + ...c.env.DEV ? {} : { + secure: true, + domain: '.cheda.kr', }, }); - const jwt = await signToken(c, payload, user.expireAt); - - setCookie(c, 'session_id', jwt, { - expires: new Date(Date.now() + parseInt(result.expires_in) * 1000), + setCookie(c, 'session_sid', securedSession, { + httpOnly: true, + expires, ...c.env.DEV ? {} : { secure: true, domain: '.cheda.kr', From db7a90f7b8d9f3561b83ecbd31fc8dfd2f6e2a92 Mon Sep 17 00:00:00 2001 From: Xvezda Date: Sat, 27 Apr 2024 22:56:54 +0900 Subject: [PATCH 07/30] =?UTF-8?q?chore:=20=EB=A1=9C=EA=B7=B8=EC=95=84?= =?UTF-8?q?=EC=9B=83=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/src/services/auth/v1/route.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/apps/api/src/services/auth/v1/route.ts b/apps/api/src/services/auth/v1/route.ts index 1b5ab8b..7b3ff6f 100644 --- a/apps/api/src/services/auth/v1/route.ts +++ b/apps/api/src/services/auth/v1/route.ts @@ -266,10 +266,6 @@ app.use('*', cors({ })); app.get('/logout', withPrevUrl, async (c) => { - const sessionId = getCookie(c, 'session_id'); - if (!sessionId) { - return c.redirect(c.var.prevUrl); - } /* const url = new URL('https://nid.naver.com/oauth2.0/token'); url.searchParams.append('grant_type', 'delete'); @@ -283,6 +279,7 @@ app.get('/logout', withPrevUrl, async (c) => { */ deleteCookie(c, 'session_id'); + deleteCookie(c, 'session_sid'); return c.redirect(c.var.prevUrl); }); From 94f040eb65e14fe8d087e850c2ef75817f338477 Mon Sep 17 00:00:00 2001 From: Xvezda Date: Sat, 27 Apr 2024 23:00:20 +0900 Subject: [PATCH 08/30] =?UTF-8?q?chore:=20=EC=9E=98=EB=AA=BB=EB=90=9C=20?= =?UTF-8?q?=ED=86=A0=ED=81=B0=EC=9D=B8=20=EA=B2=BD=EC=9A=B0=20=EC=84=B8?= =?UTF-8?q?=EC=85=98=20=EA=B4=80=EB=A0=A8=20=EC=BF=A0=ED=82=A4=EB=A5=BC=20?= =?UTF-8?q?=EC=A0=84=EB=B6=80=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/src/services/auth/v1/route.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/api/src/services/auth/v1/route.ts b/apps/api/src/services/auth/v1/route.ts index 7b3ff6f..9effee1 100644 --- a/apps/api/src/services/auth/v1/route.ts +++ b/apps/api/src/services/auth/v1/route.ts @@ -164,6 +164,11 @@ const decryptToken = async (context: C, token: stri return result.plaintext; }; +const deleteSessionCookies = (c: Context) => { + deleteCookie(c, 'session_id'); + deleteCookie(c, 'session_sid'); +}; + const withSession: MiddlewareHandler<{ Bindings: Env, Variables: { @@ -236,7 +241,7 @@ const withSession: MiddlewareHandler<{ c.set('session', { user }); } catch (e) { if (e instanceof InvalidToken) { - deleteCookie(c, 'session_id'); + deleteSessionCookies(c); return c.json({ message: 'Unauthorized' }, 401); } throw e; @@ -278,8 +283,7 @@ app.get('/logout', withPrevUrl, async (c) => { const result = await response.json() as DeleteTokenRespone; */ - deleteCookie(c, 'session_id'); - deleteCookie(c, 'session_sid'); + deleteSessionCookies(c); return c.redirect(c.var.prevUrl); }); From 639daacc887c33d93d5ad32367ea58df9ca5d3d5 Mon Sep 17 00:00:00 2001 From: Xvezda Date: Sat, 27 Apr 2024 23:18:57 +0900 Subject: [PATCH 09/30] =?UTF-8?q?chore:=20=EB=A1=9C=EA=B7=B8=EC=95=84?= =?UTF-8?q?=EC=9B=83=EC=8B=9C=20=EC=9D=B4=EC=A0=84=20=ED=86=A0=ED=81=B0=20?= =?UTF-8?q?=EB=AC=B4=ED=9A=A8=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/src/services/auth/v1/route.ts | 33 ++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/apps/api/src/services/auth/v1/route.ts b/apps/api/src/services/auth/v1/route.ts index 9effee1..00026d1 100644 --- a/apps/api/src/services/auth/v1/route.ts +++ b/apps/api/src/services/auth/v1/route.ts @@ -283,9 +283,25 @@ app.get('/logout', withPrevUrl, async (c) => { const result = await response.json() as DeleteTokenRespone; */ - deleteSessionCookies(c); - - return c.redirect(c.var.prevUrl); + try { + // access token 갱신 요청으로 이전 토큰을 무효화 + const sessionSid = getCookie(c, 'session_sid')!; + const securedToken = await decryptToken(c, sessionSid); + const securedPayload = await verifyToken(c, securedToken); + const { refreshToken } = securedPayload['http:cheda.kr/user']; + + const url = new URL('https://nid.naver.com/oauth2.0/token'); + url.searchParams.append('grant_type', 'refresh_token'); + url.searchParams.append('client_id', c.env.OAUTH_CLIENT_ID_NAVER); + url.searchParams.append('client_secret', c.env.OAUTH_CLIENT_SECRET_NAVER); + url.searchParams.append('refresh_token', refreshToken); + + const response = await fetch(url); + await response.json() as RefreshTokenResponse; + } finally { + deleteSessionCookies(c); + return c.redirect(c.var.prevUrl); + } }); app.get('/login', withPrevUrl, async (c) => { @@ -453,13 +469,20 @@ app.get('/callback', async (c) => { app.get('/me', withSession, async (c) => { const { user } = c.var.session; - const response = fetch('https://openapi.naver.com/v1/nid/me', { + const response = await fetch('https://openapi.naver.com/v1/nid/me', { headers: { 'Authorization': `Bearer ${user.accessToken}`, }, }); - const result = await response.then(r => r.json()) as NidMeResponse; + if (!response.ok) { + if (response.status === 401) { + throw new HTTPException(401, { message: 'Unauthorized' }); + } + throw new HTTPException(500, { message: 'Internal Server Error' }); + } + + const result = await response.json() as NidMeResponse; return c.json({ name: result.response.nickname, From 34b457b6b3e3601d3f397f0c233f9b7bac4b7d63 Mon Sep 17 00:00:00 2001 From: Xvezda Date: Sun, 28 Apr 2024 14:43:48 +0900 Subject: [PATCH 10/30] =?UTF-8?q?chore:=20=EA=B0=B1=EC=8B=A0=20=ED=95=9C?= =?UTF-8?q?=EB=8F=84=2030=EB=B6=84=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/src/services/auth/v1/route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api/src/services/auth/v1/route.ts b/apps/api/src/services/auth/v1/route.ts index 00026d1..6aee4cf 100644 --- a/apps/api/src/services/auth/v1/route.ts +++ b/apps/api/src/services/auth/v1/route.ts @@ -190,7 +190,7 @@ const withSession: MiddlewareHandler<{ const payload = await verifyToken(c, sessionId); let user = payload['http:cheda.kr/user']; - const threshold = 1000 * 60 * 10; + const threshold = 1000 * 60 * 30; if (new Date(payload.exp! * 1000).getTime() <= Date.now() + threshold) { const securedToken = await decryptToken(c, sessionSid); const securedPayload = await verifyToken(c, securedToken); From a538db300c2e130655e7b3f7839b08ff163a3982 Mon Sep 17 00:00:00 2001 From: Xvezda Date: Sun, 28 Apr 2024 14:44:04 +0900 Subject: [PATCH 11/30] =?UTF-8?q?chore:=20=EC=9B=B9=20=EA=B8=B0=EB=B3=B8?= =?UTF-8?q?=20=ED=8F=AC=ED=8A=B8=20=EB=B2=88=ED=98=B8=203001=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/package.json b/apps/web/package.json index 88c97a4..54b4bc9 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev", + "dev": "next dev --port 3001", "build": "next build", "start": "next start", "lint": "next lint", From 10f6f94e0a3d4b7a54584d85793cb9cf6707c604 Mon Sep 17 00:00:00 2001 From: Xvezda Date: Sun, 28 Apr 2024 18:39:17 +0900 Subject: [PATCH 12/30] =?UTF-8?q?chore:=20=EA=B0=9C=EB=B0=9C=20=ED=94=84?= =?UTF-8?q?=EB=A1=9D=EC=8B=9C=20=ED=99=98=EA=B2=BD=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/src/index.ts | 13 ++++++++ apps/web/.env | 1 + apps/web/.env.development | 1 + apps/web/app/login/page.tsx | 15 ++++++++++ .../components/naver-login-button/button.tsx | 30 ++++++++++++------- 5 files changed, 50 insertions(+), 10 deletions(-) create mode 100644 apps/web/.env create mode 100644 apps/web/.env.development create mode 100644 apps/web/app/login/page.tsx diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 0bdd72d..1350c17 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -38,4 +38,17 @@ app.get('/', async (c) => { app.route('/services/auth/v1', auth); +app.use('/services/buffer/v1/*', async (c, next) => { + if (c.env.DEV) { + const reqUrl = new URL(c.req.url); + reqUrl.host = 'localhost:8788'; + + const response = await fetch(reqUrl, { + headers: c.req.header() + }); + c.res = new Response(response.body, response); + } + await next(); +}); + export default app; diff --git a/apps/web/.env b/apps/web/.env new file mode 100644 index 0000000..3fc73b4 --- /dev/null +++ b/apps/web/.env @@ -0,0 +1 @@ +NEXT_PUBLIC_API_ORIGIN=https://api.cheda.kr diff --git a/apps/web/.env.development b/apps/web/.env.development new file mode 100644 index 0000000..ddedbf7 --- /dev/null +++ b/apps/web/.env.development @@ -0,0 +1 @@ +NEXT_PUBLIC_API_ORIGIN=http://localhost:8787 diff --git a/apps/web/app/login/page.tsx b/apps/web/app/login/page.tsx new file mode 100644 index 0000000..25368ed --- /dev/null +++ b/apps/web/app/login/page.tsx @@ -0,0 +1,15 @@ +"use client"; + +import Link from 'next/link'; +import { useSearchParams } from 'next/navigation'; +import NaverLoginButton from '@/components/naver-login-button/button'; + +export default function LoginPage() { + const searchParams = useSearchParams(); + + return ( +
+ +
+ ); +} diff --git a/apps/web/components/naver-login-button/button.tsx b/apps/web/components/naver-login-button/button.tsx index ebaac47..301d09d 100644 --- a/apps/web/components/naver-login-button/button.tsx +++ b/apps/web/components/naver-login-button/button.tsx @@ -6,16 +6,27 @@ import Link from 'next/link'; import Image from 'next/image'; import { Skeleton } from "@/components/ui/skeleton" -import loginButtonImage from './btnG_short.png'; -import logoutButtonImage from './btnG_logout.png'; +// import loginButtonImage from './btnG_short.png'; +// import logoutButtonImage from './btnG_logout.png'; -export default function NaverLoginButton() { +import loginButtonImage from './btnG_official.png'; +const logoutButtonImage = loginButtonImage; + +declare global { + namespace NodeJS { + interface ProcessEnv { + NEXT_PUBLIC_API_ORIGIN: string; + } + } +} + +export default function NaverLoginButton({ prevUrl }: { prevUrl?: string }) { const queryClient = useQueryClient(); const query = useQuery({ queryKey: ['auth'], queryFn: async () => { - const response = await fetch('http://localhost:8787/services/auth/v1/me', { + const response = await fetch(`${process.env.NEXT_PUBLIC_API_ORIGIN}/services/auth/v1/me`, { credentials: 'include', }); if (!response.ok) { @@ -26,23 +37,22 @@ export default function NaverLoginButton() { }); if (query.isLoading) { - return ; + return ; } if (query.data) { return ( { queryClient.invalidateQueries({ queryKey: ['auth'] }); - }} href={`http://localhost:8787/services/auth/v1/logout`}> - Naver 로그아웃 + }} href={`${process.env.NEXT_PUBLIC_API_ORIGIN}/services/auth/v1/logout`}> + Naver 로그아웃 ); } return ( - - Naver 로그인 + + Naver 로그인 ); } - From 55bf99cb48c790e4ebcaf411c85ab21d53b7000e Mon Sep 17 00:00:00 2001 From: Xvezda Date: Sun, 28 Apr 2024 22:19:41 +0900 Subject: [PATCH 13/30] =?UTF-8?q?chore:=20=ED=81=AC=EB=A1=9C=EC=8A=A4=20?= =?UTF-8?q?=EC=82=AC=EC=9D=B4=ED=8A=B8=20=EC=BF=A0=ED=82=A4=20=ED=94=8C?= =?UTF-8?q?=EB=9E=98=EA=B7=B8=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/src/services/auth/v1/route.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/api/src/services/auth/v1/route.ts b/apps/api/src/services/auth/v1/route.ts index 6aee4cf..a3f6ea8 100644 --- a/apps/api/src/services/auth/v1/route.ts +++ b/apps/api/src/services/auth/v1/route.ts @@ -232,8 +232,9 @@ const withSession: MiddlewareHandler<{ setCookie(c, 'session_id', session, { expires, + sameSite: 'None', + secure: true, ...c.env.DEV ? {} : { - secure: true, domain: '.cheda.kr', }, }); @@ -448,17 +449,19 @@ app.get('/callback', async (c) => { setCookie(c, 'session_id', session, { expires, + sameSite: 'None', + secure: true, ...c.env.DEV ? {} : { - secure: true, domain: '.cheda.kr', }, }); setCookie(c, 'session_sid', securedSession, { httpOnly: true, + sameSite: 'None', + secure: true, expires, ...c.env.DEV ? {} : { - secure: true, domain: '.cheda.kr', }, }); From e012cdb1617f7c546996b373f5eedf9ad41de34e Mon Sep 17 00:00:00 2001 From: Xvezda Date: Mon, 29 Apr 2024 00:03:48 +0900 Subject: [PATCH 14/30] =?UTF-8?q?fix:=20`useSearchParams`=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/app/login/page.tsx | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/apps/web/app/login/page.tsx b/apps/web/app/login/page.tsx index 25368ed..e5218f2 100644 --- a/apps/web/app/login/page.tsx +++ b/apps/web/app/login/page.tsx @@ -1,15 +1,21 @@ "use client"; -import Link from 'next/link'; +import { Suspense } from 'react'; import { useSearchParams } from 'next/navigation'; import NaverLoginButton from '@/components/naver-login-button/button'; export default function LoginPage() { - const searchParams = useSearchParams(); - return (
- + + +
); } + +function LoginButton() { + const searchParams = useSearchParams(); + + return ; +} From 5d3f72c65d35fc7da3cf73f2162d2d6b893be108 Mon Sep 17 00:00:00 2001 From: Xvezda Date: Mon, 29 Apr 2024 00:04:07 +0900 Subject: [PATCH 15/30] =?UTF-8?q?chore:=20=EB=93=A4=EC=97=AC=EC=93=B0?= =?UTF-8?q?=EA=B8=B0=20=EB=B0=8F=20=EA=B8=B0=ED=83=80=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/src/index.ts | 1 - apps/api/src/services/auth/v1/route.ts | 1 + apps/web/app/globals.css | 106 ++++++++++++------------- 3 files changed, 54 insertions(+), 54 deletions(-) diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 1350c17..fe0acea 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -20,7 +20,6 @@ app.use('*', cors({ if (c.env.DEV) { return origin; } - try { const originUrl = new URL(origin); if (originUrl.hostname === 'cheda.kr' || originUrl.hostname.endsWith('.cheda.kr')) { diff --git a/apps/api/src/services/auth/v1/route.ts b/apps/api/src/services/auth/v1/route.ts index a3f6ea8..1ed07c3 100644 --- a/apps/api/src/services/auth/v1/route.ts +++ b/apps/api/src/services/auth/v1/route.ts @@ -488,6 +488,7 @@ app.get('/me', withSession, async (c) => { const result = await response.json() as NidMeResponse; return c.json({ + id: result.response.id, name: result.response.nickname, image: result.response.profile_image, }); diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index c72ca84..8abdb15 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -1,76 +1,76 @@ @tailwind base; - @tailwind components; - @tailwind utilities; +@tailwind components; +@tailwind utilities; - @layer base { - :root { - --background: 0 0% 100%; - --foreground: 222.2 84% 4.9%; +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 222.2 84% 4.9%; - --card: 0 0% 100%; - --card-foreground: 222.2 84% 4.9%; + --card: 0 0% 100%; + --card-foreground: 222.2 84% 4.9%; - --popover: 0 0% 100%; - --popover-foreground: 222.2 84% 4.9%; + --popover: 0 0% 100%; + --popover-foreground: 222.2 84% 4.9%; - --primary: 222.2 47.4% 11.2%; - --primary-foreground: 210 40% 98%; + --primary: 222.2 47.4% 11.2%; + --primary-foreground: 210 40% 98%; - --secondary: 210 40% 96.1%; - --secondary-foreground: 222.2 47.4% 11.2%; + --secondary: 210 40% 96.1%; + --secondary-foreground: 222.2 47.4% 11.2%; - --muted: 210 40% 96.1%; - --muted-foreground: 215.4 16.3% 46.9%; + --muted: 210 40% 96.1%; + --muted-foreground: 215.4 16.3% 46.9%; - --accent: 210 40% 96.1%; - --accent-foreground: 222.2 47.4% 11.2%; + --accent: 210 40% 96.1%; + --accent-foreground: 222.2 47.4% 11.2%; - --destructive: 0 84.2% 60.2%; - --destructive-foreground: 210 40% 98%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 210 40% 98%; - --border: 214.3 31.8% 91.4%; - --input: 214.3 31.8% 91.4%; - --ring: 222.2 84% 4.9%; + --border: 214.3 31.8% 91.4%; + --input: 214.3 31.8% 91.4%; + --ring: 222.2 84% 4.9%; - --radius: 0.5rem; - } + --radius: 0.5rem; + } - .dark { - --background: 222.2 84% 4.9%; - --foreground: 210 40% 98%; + .dark { + --background: 222.2 84% 4.9%; + --foreground: 210 40% 98%; - --card: 222.2 84% 4.9%; - --card-foreground: 210 40% 98%; + --card: 222.2 84% 4.9%; + --card-foreground: 210 40% 98%; - --popover: 222.2 84% 4.9%; - --popover-foreground: 210 40% 98%; + --popover: 222.2 84% 4.9%; + --popover-foreground: 210 40% 98%; - --primary: 210 40% 98%; - --primary-foreground: 222.2 47.4% 11.2%; + --primary: 210 40% 98%; + --primary-foreground: 222.2 47.4% 11.2%; - --secondary: 217.2 32.6% 17.5%; - --secondary-foreground: 210 40% 98%; + --secondary: 217.2 32.6% 17.5%; + --secondary-foreground: 210 40% 98%; - --muted: 217.2 32.6% 17.5%; - --muted-foreground: 215 20.2% 65.1%; + --muted: 217.2 32.6% 17.5%; + --muted-foreground: 215 20.2% 65.1%; - --accent: 217.2 32.6% 17.5%; - --accent-foreground: 210 40% 98%; + --accent: 217.2 32.6% 17.5%; + --accent-foreground: 210 40% 98%; - --destructive: 0 62.8% 30.6%; - --destructive-foreground: 210 40% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 210 40% 98%; - --border: 217.2 32.6% 17.5%; - --input: 217.2 32.6% 17.5%; - --ring: 212.7 26.8% 83.9%; - } + --border: 217.2 32.6% 17.5%; + --input: 217.2 32.6% 17.5%; + --ring: 212.7 26.8% 83.9%; } +} - @layer base { - * { - @apply border-border; - } - body { - @apply bg-background text-foreground; - } +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; } +} From 76536e518b1c37085d1c93d764e4d95b31fb0bf5 Mon Sep 17 00:00:00 2001 From: Xvezda Date: Fri, 3 May 2024 23:36:37 +0900 Subject: [PATCH 16/30] =?UTF-8?q?chore:=20=EC=9E=AC=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=EC=A3=BC=EA=B8=B0=20=EB=8B=AC=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/src/services/auth/v1/route.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/api/src/services/auth/v1/route.ts b/apps/api/src/services/auth/v1/route.ts index 1ed07c3..8837faa 100644 --- a/apps/api/src/services/auth/v1/route.ts +++ b/apps/api/src/services/auth/v1/route.ts @@ -435,7 +435,7 @@ app.get('/callback', async (c) => { expires ); - const weekLater = new Date(Date.now() + 1000 * 60 * 60 * 24 * 7); + const monthLater = new Date(Date.now() + 1000 * 60 * 60 * 24 * 30); let securedSession = await signToken( c, prefixRoot(JWT_PREFIX, { @@ -443,7 +443,7 @@ app.get('/callback', async (c) => { refreshToken: user.refreshToken, }, }) satisfies SecuredSessionPayload, - weekLater, + monthLater, ); securedSession = await encryptToken(c, securedSession); From 1c04b7219f79af4913de8d7030915c2cd56bef3d Mon Sep 17 00:00:00 2001 From: Xvezda Date: Fri, 3 May 2024 23:39:58 +0900 Subject: [PATCH 17/30] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=ED=83=88?= =?UTF-8?q?=ED=87=B4=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/src/services/auth/v1/route.ts | 35 +++++++++++++++++--------- 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/apps/api/src/services/auth/v1/route.ts b/apps/api/src/services/auth/v1/route.ts index 8837faa..8a46567 100644 --- a/apps/api/src/services/auth/v1/route.ts +++ b/apps/api/src/services/auth/v1/route.ts @@ -272,18 +272,6 @@ app.use('*', cors({ })); app.get('/logout', withPrevUrl, async (c) => { - /* - const url = new URL('https://nid.naver.com/oauth2.0/token'); - url.searchParams.append('grant_type', 'delete'); - url.searchParams.append('client_id', c.env.OAUTH_CLIENT_ID_NAVER); - url.searchParams.append('client_secret', c.env.OAUTH_CLIENT_SECRET_NAVER); - url.searchParams.append('access_token', user.accessToken); - url.searchParams.append('service_provider', 'NAVER'); - - const response = await fetch(url); - const result = await response.json() as DeleteTokenRespone; - */ - try { // access token 갱신 요청으로 이전 토큰을 무효화 const sessionSid = getCookie(c, 'session_sid')!; @@ -494,4 +482,27 @@ app.get('/me', withSession, async (c) => { }); }); +app.delete('/me', withSession, async (c) => { + const { user } = c.var.session; + + const url = new URL('https://nid.naver.com/oauth2.0/token'); + url.searchParams.append('grant_type', 'delete'); + url.searchParams.append('client_id', c.env.OAUTH_CLIENT_ID_NAVER); + url.searchParams.append('client_secret', c.env.OAUTH_CLIENT_SECRET_NAVER); + url.searchParams.append('access_token', user.accessToken); + url.searchParams.append('service_provider', 'NAVER'); + + const response = await fetch(url); + const result = await response.json() as DeleteTokenRespone; + + const db = drizzle(c.env.DB); + + await db.delete(usersTable) + .where(eq(usersTable.userId, user.userId)); + + deleteSessionCookies(c); + + return c.json(result); +}); + export default app; From 82a0b10ce6b5d6238e86f65bdfc384001b7852a6 Mon Sep 17 00:00:00 2001 From: Xvezda Date: Fri, 3 May 2024 23:56:27 +0900 Subject: [PATCH 18/30] =?UTF-8?q?chore:=20=EA=B0=B1=EC=8B=A0=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/src/services/auth/v1/route.ts | 109 ++++++++++++------------- 1 file changed, 51 insertions(+), 58 deletions(-) diff --git a/apps/api/src/services/auth/v1/route.ts b/apps/api/src/services/auth/v1/route.ts index 8a46567..59d6c33 100644 --- a/apps/api/src/services/auth/v1/route.ts +++ b/apps/api/src/services/auth/v1/route.ts @@ -180,73 +180,66 @@ const withSession: MiddlewareHandler<{ }; }; }> = async (c, next) => { - class InvalidToken extends Error {} + const sessionId = getCookie(c, 'session_id'); + const sessionSid = getCookie(c, 'session_sid'); + if (!sessionId || !sessionSid) { + deleteSessionCookies(c); + return c.json({ message: 'Unauthorized' }, 401); + } + let user; try { - const sessionId = getCookie(c, 'session_id'); - const sessionSid = getCookie(c, 'session_sid'); - if (!sessionId || !sessionSid) throw new InvalidToken(); - const payload = await verifyToken(c, sessionId); - let user = payload['http:cheda.kr/user']; - - const threshold = 1000 * 60 * 30; - if (new Date(payload.exp! * 1000).getTime() <= Date.now() + threshold) { - const securedToken = await decryptToken(c, sessionSid); - const securedPayload = await verifyToken(c, securedToken); - const { refreshToken } = securedPayload['http:cheda.kr/user']; + user = payload['http:cheda.kr/user']; + } catch (e) { + const securedToken = await decryptToken(c, sessionSid); + const securedPayload = await verifyToken(c, securedToken); + const { refreshToken } = securedPayload['http:cheda.kr/user']; - const url = new URL('https://nid.naver.com/oauth2.0/token'); - url.searchParams.append('grant_type', 'refresh_token'); - url.searchParams.append('client_id', c.env.OAUTH_CLIENT_ID_NAVER); - url.searchParams.append('client_secret', c.env.OAUTH_CLIENT_SECRET_NAVER); - url.searchParams.append('refresh_token', refreshToken); + const url = new URL('https://nid.naver.com/oauth2.0/token'); + url.searchParams.append('grant_type', 'refresh_token'); + url.searchParams.append('client_id', c.env.OAUTH_CLIENT_ID_NAVER); + url.searchParams.append('client_secret', c.env.OAUTH_CLIENT_SECRET_NAVER); + url.searchParams.append('refresh_token', refreshToken); - const response = await fetch(url); - const result = await response.json() as RefreshTokenResponse; + const response = await fetch(url); + const result = await response.json() as RefreshTokenResponse; - const headers = { - 'Authorization': `Bearer ${result.access_token}`, - }; + const headers = { + 'Authorization': `Bearer ${result.access_token}`, + }; - const [meResult, verifyResult] = await Promise.all([ - fetch('https://openapi.naver.com/v1/nid/me', { headers }).then(r => r.json()) as Promise, - fetch('https://openapi.naver.com/v1/nid/verify?info=true', { headers }).then(r => r.json()) as Promise - ]); + const [meResult, verifyResult] = await Promise.all([ + fetch('https://openapi.naver.com/v1/nid/me', { headers }).then(r => r.json()) as Promise, + fetch('https://openapi.naver.com/v1/nid/verify?info=true', { headers }).then(r => r.json()) as Promise + ]); - user = { - userId: meResult.response.id, - userName: meResult.response.nickname, - userImage: meResult.response.profile_image, - accessToken: result.access_token, - }; - const expires = new Date(verifyResult.response.expire_date); - - const session = await signToken( - c, - prefixRoot(JWT_PREFIX, { - user, - }) satisfies SessionPayload, - expires - ); - - setCookie(c, 'session_id', session, { - expires, - sameSite: 'None', - secure: true, - ...c.env.DEV ? {} : { - domain: '.cheda.kr', - }, - }); - } - c.set('session', { user }); - } catch (e) { - if (e instanceof InvalidToken) { - deleteSessionCookies(c); - return c.json({ message: 'Unauthorized' }, 401); - } - throw e; + user = { + userId: meResult.response.id, + userName: meResult.response.nickname, + userImage: meResult.response.profile_image, + accessToken: result.access_token, + }; + const expires = new Date(verifyResult.response.expire_date); + + const session = await signToken( + c, + prefixRoot(JWT_PREFIX, { + user, + }) satisfies SessionPayload, + expires + ); + + setCookie(c, 'session_id', session, { + expires, + sameSite: 'None', + secure: true, + ...c.env.DEV ? {} : { + domain: '.cheda.kr', + }, + }); } + c.set('session', { user }); await next(); }; From 0a353a53c20259466bdd3f5e3fc1e9fe96de6413 Mon Sep 17 00:00:00 2001 From: Xvezda Date: Sat, 4 May 2024 16:18:20 +0900 Subject: [PATCH 19/30] =?UTF-8?q?chore:=20=EA=B2=80=EC=A6=9D=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/src/services/auth/v1/route.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/apps/api/src/services/auth/v1/route.ts b/apps/api/src/services/auth/v1/route.ts index 59d6c33..c14cccd 100644 --- a/apps/api/src/services/auth/v1/route.ts +++ b/apps/api/src/services/auth/v1/route.ts @@ -182,17 +182,13 @@ const withSession: MiddlewareHandler<{ }> = async (c, next) => { const sessionId = getCookie(c, 'session_id'); const sessionSid = getCookie(c, 'session_sid'); - if (!sessionId || !sessionSid) { - deleteSessionCookies(c); - return c.json({ message: 'Unauthorized' }, 401); - } let user; try { - const payload = await verifyToken(c, sessionId); + const payload = await verifyToken(c, sessionId ?? ''); user = payload['http:cheda.kr/user']; } catch (e) { - const securedToken = await decryptToken(c, sessionSid); + const securedToken = await decryptToken(c, sessionSid ?? ''); const securedPayload = await verifyToken(c, securedToken); const { refreshToken } = securedPayload['http:cheda.kr/user']; From 9d4d8441f9b450655d5565af750a8b06eae3fb84 Mon Sep 17 00:00:00 2001 From: Xvezda Date: Sat, 4 May 2024 18:42:27 +0900 Subject: [PATCH 20/30] =?UTF-8?q?fix:=20=EC=BF=A0=ED=82=A4=20=EC=9C=A0?= =?UTF-8?q?=ED=9A=A8=EA=B8=B0=EA=B0=84=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/src/services/auth/v1/route.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/api/src/services/auth/v1/route.ts b/apps/api/src/services/auth/v1/route.ts index c14cccd..5e1ab49 100644 --- a/apps/api/src/services/auth/v1/route.ts +++ b/apps/api/src/services/auth/v1/route.ts @@ -139,7 +139,14 @@ const verifyToken = async , C extends Context = Co return payload; }; -const signToken = async , C extends Context = Context>(context: C, payload: T, expires: string | number | Date) => { +const signToken = async < + T extends Record, + C extends Context = Context +>( + context: C, + payload: T, + expires: string | number | Date +) => { const privateKey = await jose.importPKCS8(u8ToString(jose.base64url.decode(context.env.JWT_SECRET_KEY)), 'ES256'); const jwt = await new jose.SignJWT(payload) .setProtectedHeader({ alg: 'ES256' }) @@ -437,7 +444,7 @@ app.get('/callback', async (c) => { httpOnly: true, sameSite: 'None', secure: true, - expires, + expires: monthLater, ...c.env.DEV ? {} : { domain: '.cheda.kr', }, From b54e22b1d165e0433aeee1a9e80e1374cfb53e7a Mon Sep 17 00:00:00 2001 From: Xvezda Date: Sun, 5 May 2024 00:59:26 +0900 Subject: [PATCH 21/30] =?UTF-8?q?chore:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=EC=9D=B8=EB=8D=B1=EC=8A=A4=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/src/db/schema.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index 7222172..cd5cacb 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -11,7 +11,5 @@ export const users = sqliteTable('users', { return { idxUserId: uniqueIndex('idx_users_user_id').on(table.userId), idxUserName: index('idx_users_user_name').on(table.userName), - idxCreatedAt: index('idx_users_created_at').on(table.createdAt), - idxUpdatedAt: index('idx_users_updated_at').on(table.updatedAt), }; }); From e3cb783f5eed7e110d056a0c8ab6ebeae9868e5a Mon Sep 17 00:00:00 2001 From: Xvezda Date: Tue, 7 May 2024 00:35:25 +0900 Subject: [PATCH 22/30] =?UTF-8?q?chore:=20CORS=20`credentials:=20true`=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index fe0acea..2c173eb 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -16,6 +16,7 @@ app.onError((err, c) => { }); app.use('*', cors({ + credentials: true, origin: (origin, c) => { if (c.env.DEV) { return origin; From d87f18bf9a18043fd82664c99abfd9f104f1e240 Mon Sep 17 00:00:00 2001 From: Xvezda Date: Tue, 7 May 2024 00:36:02 +0900 Subject: [PATCH 23/30] =?UTF-8?q?chore:=20=ED=94=84=EB=A1=9D=EC=8B=9C=20?= =?UTF-8?q?=EB=A6=AC=ED=80=98=EC=8A=A4=ED=8A=B8=20clone=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/src/index.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 2c173eb..dc64d1a 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -43,9 +43,7 @@ app.use('/services/buffer/v1/*', async (c, next) => { const reqUrl = new URL(c.req.url); reqUrl.host = 'localhost:8788'; - const response = await fetch(reqUrl, { - headers: c.req.header() - }); + const response = await fetch(reqUrl, c.req.raw.clone()); c.res = new Response(response.body, response); } await next(); From 40c47da7168a0d9d25e082b1b154f2c27b3baff8 Mon Sep 17 00:00:00 2001 From: Xvezda Date: Tue, 7 May 2024 19:01:17 +0900 Subject: [PATCH 24/30] =?UTF-8?q?fix:=20=EC=98=88=EC=99=B8=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/src/services/auth/v1/route.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/api/src/services/auth/v1/route.ts b/apps/api/src/services/auth/v1/route.ts index 5e1ab49..b2906ac 100644 --- a/apps/api/src/services/auth/v1/route.ts +++ b/apps/api/src/services/auth/v1/route.ts @@ -195,6 +195,9 @@ const withSession: MiddlewareHandler<{ const payload = await verifyToken(c, sessionId ?? ''); user = payload['http:cheda.kr/user']; } catch (e) { + if (!sessionSid) { + return c.json({ message: 'Unauthorized' }, 401); + } const securedToken = await decryptToken(c, sessionSid ?? ''); const securedPayload = await verifyToken(c, securedToken); const { refreshToken } = securedPayload['http:cheda.kr/user']; From c30ba4de6653c7b4c1f070da2dcd8a451b10dca1 Mon Sep 17 00:00:00 2001 From: Xvezda Date: Tue, 7 May 2024 19:35:53 +0900 Subject: [PATCH 25/30] =?UTF-8?q?chore:=20`user=5Ftype`=20=EC=BB=AC?= =?UTF-8?q?=EB=9F=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../0000_marvelous_christian_walker.sql | 12 +++++ apps/api/drizzle/0000_stale_doorman.sql | 17 ------- apps/api/drizzle/meta/0000_snapshot.json | 51 +++---------------- apps/api/drizzle/meta/_journal.json | 4 +- apps/api/src/db/schema.ts | 1 + apps/api/src/services/auth/v1/route.ts | 1 + 6 files changed, 24 insertions(+), 62 deletions(-) create mode 100644 apps/api/drizzle/0000_marvelous_christian_walker.sql delete mode 100644 apps/api/drizzle/0000_stale_doorman.sql diff --git a/apps/api/drizzle/0000_marvelous_christian_walker.sql b/apps/api/drizzle/0000_marvelous_christian_walker.sql new file mode 100644 index 0000000..abfbaa0 --- /dev/null +++ b/apps/api/drizzle/0000_marvelous_christian_walker.sql @@ -0,0 +1,12 @@ +CREATE TABLE `users` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `user_id` text NOT NULL, + `user_name` text NOT NULL, + `user_image` text, + `user_type` text NOT NULL, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `idx_users_user_id` ON `users` (`user_id`);--> statement-breakpoint +CREATE INDEX `idx_users_user_name` ON `users` (`user_name`); \ No newline at end of file diff --git a/apps/api/drizzle/0000_stale_doorman.sql b/apps/api/drizzle/0000_stale_doorman.sql deleted file mode 100644 index 49f0a59..0000000 --- a/apps/api/drizzle/0000_stale_doorman.sql +++ /dev/null @@ -1,17 +0,0 @@ -CREATE TABLE `users` ( - `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, - `user_id` text NOT NULL, - `user_name` text NOT NULL, - `user_image` text, - `created_at` integer NOT NULL, - `updated_at` integer NOT NULL, - `access_token` text, - `refresh_token` text, - `token_type` text, - `expire_at` integer -); ---> statement-breakpoint -CREATE UNIQUE INDEX `idx_users_user_id` ON `users` (`user_id`);--> statement-breakpoint -CREATE INDEX `idx_users_user_name` ON `users` (`user_name`);--> statement-breakpoint -CREATE INDEX `idx_users_created_at` ON `users` (`created_at`);--> statement-breakpoint -CREATE INDEX `idx_users_updated_at` ON `users` (`updated_at`); \ No newline at end of file diff --git a/apps/api/drizzle/meta/0000_snapshot.json b/apps/api/drizzle/meta/0000_snapshot.json index ce60bdf..05aadef 100644 --- a/apps/api/drizzle/meta/0000_snapshot.json +++ b/apps/api/drizzle/meta/0000_snapshot.json @@ -1,7 +1,7 @@ { "version": "5", "dialect": "sqlite", - "id": "adb7d579-e149-4d0e-9c32-f5c0f77d5f8a", + "id": "21a996cf-3dbe-41e4-b796-aeae28f4e6fd", "prevId": "00000000-0000-0000-0000-000000000000", "tables": { "users": { @@ -35,6 +35,13 @@ "notNull": false, "autoincrement": false }, + "user_type": { + "name": "user_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, "created_at": { "name": "created_at", "type": "integer", @@ -48,34 +55,6 @@ "primaryKey": false, "notNull": true, "autoincrement": false - }, - "access_token": { - "name": "access_token", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "refresh_token": { - "name": "refresh_token", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "token_type": { - "name": "token_type", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "expire_at": { - "name": "expire_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false } }, "indexes": { @@ -92,20 +71,6 @@ "user_name" ], "isUnique": false - }, - "idx_users_created_at": { - "name": "idx_users_created_at", - "columns": [ - "created_at" - ], - "isUnique": false - }, - "idx_users_updated_at": { - "name": "idx_users_updated_at", - "columns": [ - "updated_at" - ], - "isUnique": false } }, "foreignKeys": {}, diff --git a/apps/api/drizzle/meta/_journal.json b/apps/api/drizzle/meta/_journal.json index 6f3b325..5e69b17 100644 --- a/apps/api/drizzle/meta/_journal.json +++ b/apps/api/drizzle/meta/_journal.json @@ -5,8 +5,8 @@ { "idx": 0, "version": "5", - "when": 1713153522431, - "tag": "0000_stale_doorman", + "when": 1715078125961, + "tag": "0000_marvelous_christian_walker", "breakpoints": true } ] diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index cd5cacb..c14cd6a 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -5,6 +5,7 @@ export const users = sqliteTable('users', { userId: text('user_id').notNull(), userName: text('user_name').notNull(), userImage: text('user_image'), + userType: text('user_type').notNull(), createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(), updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(), }, (table) => { diff --git a/apps/api/src/services/auth/v1/route.ts b/apps/api/src/services/auth/v1/route.ts index b2906ac..1e20121 100644 --- a/apps/api/src/services/auth/v1/route.ts +++ b/apps/api/src/services/auth/v1/route.ts @@ -396,6 +396,7 @@ app.get('/callback', async (c) => { userId: meResult.response.id, userName: meResult.response.nickname, userImage: meResult.response.profile_image, + userType: 'normal', createdAt: now, updatedAt: now, }); From e6f7cea772d8aa0b6db9a9afd368de6ef9c73979 Mon Sep 17 00:00:00 2001 From: Xvezda Date: Mon, 13 May 2024 12:50:05 +0900 Subject: [PATCH 26/30] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EB=B2=84=ED=8A=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/src/services/auth/v1/route.ts | 13 +++ apps/web/app/login/page.tsx | 23 +++++- apps/web/components/header/index.tsx | 6 ++ apps/web/components/header/login-button.tsx | 38 +++++++++ apps/web/components/ui/avatar.tsx | 50 +++++++++++ apps/web/hooks/useAuth.ts | 91 +++++++++++++++++++++ apps/web/package.json | 8 +- pnpm-lock.yaml | 78 ++++++++++++++++++ 8 files changed, 302 insertions(+), 5 deletions(-) create mode 100644 apps/web/components/header/login-button.tsx create mode 100644 apps/web/components/ui/avatar.tsx create mode 100644 apps/web/hooks/useAuth.ts diff --git a/apps/api/src/services/auth/v1/route.ts b/apps/api/src/services/auth/v1/route.ts index 1e20121..d6ead47 100644 --- a/apps/api/src/services/auth/v1/route.ts +++ b/apps/api/src/services/auth/v1/route.ts @@ -1,5 +1,6 @@ import { Hono, MiddlewareHandler, Context } from 'hono'; import { cors } from 'hono/cors'; +import { csrf } from 'hono/csrf' import { HTTPException } from 'hono/http-exception'; import { setCookie, getCookie, deleteCookie } from 'hono/cookie'; import * as jose from 'jose'; @@ -270,6 +271,18 @@ app.use('*', cors({ credentials: true, })); +app.get( + '/logout', + csrf({ + origin: (origin, c) => { + if (c.env.DEV) { + return true; + } + return /https:\/\/(\w+\.)cheda\.kr/.test(origin); + }, + }) +); + app.get('/logout', withPrevUrl, async (c) => { try { // access token 갱신 요청으로 이전 토큰을 무효화 diff --git a/apps/web/app/login/page.tsx b/apps/web/app/login/page.tsx index e5218f2..75c4c85 100644 --- a/apps/web/app/login/page.tsx +++ b/apps/web/app/login/page.tsx @@ -1,12 +1,16 @@ "use client"; -import { Suspense } from 'react'; -import { useSearchParams } from 'next/navigation'; +import { useEffect, Suspense } from 'react'; +import { useSearchParams, useRouter } from 'next/navigation'; import NaverLoginButton from '@/components/naver-login-button/button'; +import useAuth from '@/hooks/useAuth'; export default function LoginPage() { return ( -
+
+

+ 체다 서비스에 로그인 +

@@ -15,7 +19,18 @@ export default function LoginPage() { } function LoginButton() { + const auth = useAuth(); + + const router = useRouter(); const searchParams = useSearchParams(); - return ; + const prevUrl = decodeURIComponent(searchParams.get('prevUrl') ?? ''); + + useEffect(() => { + if (auth.data?.loggedIn) { + router.push(prevUrl || '/'); + } + }, [auth.data]); + + return ; } diff --git a/apps/web/components/header/index.tsx b/apps/web/components/header/index.tsx index 414b76b..68b754b 100644 --- a/apps/web/components/header/index.tsx +++ b/apps/web/components/header/index.tsx @@ -1,3 +1,5 @@ +"use client"; + import Image from 'next/image'; import Link from 'next/link'; @@ -6,6 +8,7 @@ import { Button } from "@/components/ui/button" import ThemeToggle from "@/components/theme-toggle"; import { blackHanSans } from '@/app/fonts'; import { cn } from '@/lib/utils'; +import LoginButton from './login-button'; import logoImage from './cheda-transparent.png'; export default function Header() { @@ -26,6 +29,9 @@ export default function Header() { +
+ +
); diff --git a/apps/web/components/header/login-button.tsx b/apps/web/components/header/login-button.tsx new file mode 100644 index 0000000..7c640c3 --- /dev/null +++ b/apps/web/components/header/login-button.tsx @@ -0,0 +1,38 @@ +'use client'; + +import Link from 'next/link'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Button } from "@/components/ui/button"; +// import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" +import useAuth from '@/hooks/useAuth'; + +export default function LoginButton() { + const auth = useAuth(); + + if (auth.isLoading) { + return ; + } + + if (!auth.data?.loggedIn) { + return ( + + ); + } + + // return ( + // + // + // {auth.data.user.userName.substring(0, 1)} + // + // ); + + return ( + + ); +} diff --git a/apps/web/components/ui/avatar.tsx b/apps/web/components/ui/avatar.tsx new file mode 100644 index 0000000..51e507b --- /dev/null +++ b/apps/web/components/ui/avatar.tsx @@ -0,0 +1,50 @@ +"use client" + +import * as React from "react" +import * as AvatarPrimitive from "@radix-ui/react-avatar" + +import { cn } from "@/lib/utils" + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Avatar.displayName = AvatarPrimitive.Root.displayName + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarImage.displayName = AvatarPrimitive.Image.displayName + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName + +export { Avatar, AvatarImage, AvatarFallback } diff --git a/apps/web/hooks/useAuth.ts b/apps/web/hooks/useAuth.ts new file mode 100644 index 0000000..cbbf716 --- /dev/null +++ b/apps/web/hooks/useAuth.ts @@ -0,0 +1,91 @@ +import { useAtomValue } from 'jotai'; +import { atomWithQuery } from 'jotai-tanstack-query'; +import { importSPKI, jwtVerify } from 'jose'; + +const JWT_PREFIX = 'http:cheda.kr/'; + +type PrefixRoot = { + [k in keyof TValue as k extends string ? `${TPrefix}${k}` : never]: TValue[k]; +}; + +type SessionPayload = PrefixRoot; + +type SecuredSessionPayload = PrefixRoot; + +type StatePayload = PrefixRoot; + +type Auth = { + loggedIn: false; + user: null; +} | { + loggedIn: true; + user: { + userId: string; + userName: string; + userImage: string; + }; +}; + +const authAtom = atomWithQuery(() => ({ + queryKey: ['auth'], + queryFn: async () => { + const sessionId = document.cookie + .split('; ') + .find((cookie) => cookie.startsWith('session_id=')) + ?.match(/^([^=]+)=(.*)/) + ?.[2]; + + try { + const publicKey = await importSPKI(process.env.NEXT_PUBLIC_JWT_KEY!, 'ES256'); + const token = await jwtVerify(sessionId ?? '', publicKey); + + return { + loggedIn: true, + user: token.payload['http:cheda.kr/user'], + }; + } catch (e) { + const response = await fetch(`${process.env.NEXT_PUBLIC_API_ORIGIN}/services/auth/v1/me`, { + credentials: 'include', + }); + + if (!response.ok) { + return { + loggedIn: false, + user: null, + }; + } + + const result = await response.json(); + + return { + loggedIn: true, + user: { + userId: result.id, + userName: result.name, + userImage: result.image, + }, + }; + } + }, +})); + +export default function useAuth() { + return useAtomValue(authAtom); +} + diff --git a/apps/web/package.json b/apps/web/package.json index 54b4bc9..2dd6b8d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -12,18 +12,24 @@ "dependencies": { "@next/third-parties": "^14.2.1", "@radix-ui/react-accordion": "^1.1.2", + "@radix-ui/react-avatar": "^1.0.4", "@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-slot": "^1.0.2", + "@tanstack/query-core": "^5.36.0", "@tanstack/react-query": "^5.29.2", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", + "jose": "^5.2.4", + "jotai": "^2.8.0", + "jotai-tanstack-query": "^0.8.5", "lucide-react": "^0.368.0", "next": "14.1.4", "next-themes": "^0.3.0", "react": "^18", "react-dom": "^18", "tailwind-merge": "^2.2.2", - "tailwindcss-animate": "^1.0.7" + "tailwindcss-animate": "^1.0.7", + "wonka": "^6.3.4" }, "devDependencies": { "@types/node": "^20", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9845b07..41999ab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -54,12 +54,18 @@ importers: '@radix-ui/react-accordion': specifier: ^1.1.2 version: 1.1.2(@types/react-dom@18.0.0)(@types/react@18.0.0)(react-dom@18.0.0)(react@18.0.0) + '@radix-ui/react-avatar': + specifier: ^1.0.4 + version: 1.0.4(@types/react-dom@18.0.0)(@types/react@18.0.0)(react-dom@18.0.0)(react@18.0.0) '@radix-ui/react-dropdown-menu': specifier: ^2.0.6 version: 2.0.6(@types/react-dom@18.0.0)(@types/react@18.0.0)(react-dom@18.0.0)(react@18.0.0) '@radix-ui/react-slot': specifier: ^1.0.2 version: 1.0.2(@types/react@18.0.0)(react@18.0.0) + '@tanstack/query-core': + specifier: ^5.36.0 + version: 5.36.0 '@tanstack/react-query': specifier: ^5.29.2 version: 5.29.2(react@18.0.0) @@ -69,6 +75,15 @@ importers: clsx: specifier: ^2.1.0 version: 2.1.0 + jose: + specifier: ^5.2.4 + version: 5.2.4 + jotai: + specifier: ^2.8.0 + version: 2.8.0(@types/react@18.0.0)(react@18.0.0) + jotai-tanstack-query: + specifier: ^0.8.5 + version: 0.8.5(@tanstack/query-core@5.36.0)(jotai@2.8.0)(wonka@6.3.4) lucide-react: specifier: ^0.368.0 version: 0.368.0(react@18.0.0) @@ -90,6 +105,9 @@ importers: tailwindcss-animate: specifier: ^1.0.7 version: 1.0.7(tailwindcss@3.3.0) + wonka: + specifier: ^6.3.4 + version: 6.3.4 devDependencies: '@types/node': specifier: ^20 @@ -1469,6 +1487,30 @@ packages: react-dom: 18.0.0(react@18.0.0) dev: false + /@radix-ui/react-avatar@1.0.4(@types/react-dom@18.0.0)(@types/react@18.0.0)(react-dom@18.0.0)(react@18.0.0): + resolution: {integrity: sha512-kVK2K7ZD3wwj3qhle0ElXhOjbezIgyl2hVvgwfIdexL3rN6zJmy5AqqIf+D31lxVppdzV8CjAfZ6PklkmInZLw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.24.1 + '@radix-ui/react-context': 1.0.1(@types/react@18.0.0)(react@18.0.0) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.0.0)(@types/react@18.0.0)(react-dom@18.0.0)(react@18.0.0) + '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.0.0)(react@18.0.0) + '@radix-ui/react-use-layout-effect': 1.0.1(@types/react@18.0.0)(react@18.0.0) + '@types/react': 18.0.0 + '@types/react-dom': 18.0.0 + react: 18.0.0 + react-dom: 18.0.0(react@18.0.0) + dev: false + /@radix-ui/react-collapsible@1.0.3(@types/react-dom@18.0.0)(@types/react@18.0.0)(react-dom@18.0.0)(react@18.0.0): resolution: {integrity: sha512-UBmVDkmR6IvDsloHVN+3rtx4Mi5TFvylYXpluuv0f37dtaz3H99bp8No0LGXRigVpl3UAT4l9j6bIchh42S/Gg==} peerDependencies: @@ -2075,6 +2117,10 @@ packages: resolution: {integrity: sha512-WgPTRs58hm9CMzEr5jpISe8HXa3qKQ8CxewdYZeVnA54JrPY9B1CZiwsCoLpLkf0dGRZq+LcX5OiJb0bEsOFww==} dev: false + /@tanstack/query-core@5.36.0: + resolution: {integrity: sha512-B5BD3pg/mztDR36i77hGcyySKKeYrbM5mnogOROTBi1SUml5ByRK7PGUUl16vvubvQC+mSnqziFG/VIy/DE3FQ==} + dev: false + /@tanstack/react-query@5.29.2(react@18.0.0): resolution: {integrity: sha512-nyuWILR4u7H5moLGSiifLh8kIqQDLNOHGuSz0rcp+J75fNc8aQLyr5+I2JCHU3n+nJrTTW1ssgAD8HiKD7IFBQ==} peerDependencies: @@ -4312,6 +4358,34 @@ packages: resolution: {integrity: sha512-6ScbIk2WWCeXkmzF6bRPmEuaqy1m8SbsRFMa/FLrSCkGIhj8OLVG/IH+XHVmNMx/KUo8cVWEE6oKR4dJ+S0Rkg==} dev: false + /jotai-tanstack-query@0.8.5(@tanstack/query-core@5.36.0)(jotai@2.8.0)(wonka@6.3.4): + resolution: {integrity: sha512-cFq+1sE7Qkt7Kh9Db2KE8LXdbPiGwCiy8S5YSEnjUDxF59A4XhoXTDJBuPiMIA1dD1/yMsNKr1ADfN5CvscYZw==} + peerDependencies: + '@tanstack/query-core': '*' + jotai: '>=2.0.0' + wonka: ^6.3.4 + dependencies: + '@tanstack/query-core': 5.36.0 + jotai: 2.8.0(@types/react@18.0.0)(react@18.0.0) + wonka: 6.3.4 + dev: false + + /jotai@2.8.0(@types/react@18.0.0)(react@18.0.0): + resolution: {integrity: sha512-yZNMC36FdLOksOr8qga0yLf14miCJlEThlp5DeFJNnqzm2+ZG7wLcJzoOyij5K6U6Xlc5ljQqPDlJRgqW0Y18g==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=17.0.0' + react: '>=17.0.0' + peerDependenciesMeta: + '@types/react': + optional: true + react: + optional: true + dependencies: + '@types/react': 18.0.0 + react: 18.0.0 + dev: false + /js-base64@3.7.7: resolution: {integrity: sha512-7rCnleh0z2CkXhH67J8K1Ytz0b2Y+yxTPL+/KOJoa20hfnVQ/3/T6W/KflYI4bRHRagNeXeU2bkNGI3v1oS/lw==} dev: false @@ -6143,6 +6217,10 @@ packages: stackback: 0.0.2 dev: true + /wonka@6.3.4: + resolution: {integrity: sha512-CjpbqNtBGNAeyNS/9W6q3kSkKE52+FjIj7AkFlLr11s/VWGUu6a2CdYSdGxocIhIVjaW/zchesBQUKPVU69Cqg==} + dev: false + /wordwrap@1.0.0: resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} dev: true From 258efe4a07b5bb55dd35d98999dfc6ed83e9989c Mon Sep 17 00:00:00 2001 From: Xvezda Date: Mon, 13 May 2024 12:58:25 +0900 Subject: [PATCH 27/30] =?UTF-8?q?chore:=20=EA=B3=B5=EA=B0=9C=ED=82=A4=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/wrangler.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/api/wrangler.toml b/apps/api/wrangler.toml index d2b4176..7c4388e 100644 --- a/apps/api/wrangler.toml +++ b/apps/api/wrangler.toml @@ -17,6 +17,7 @@ routes = [ [vars] OAUTH_CLIENT_ID_NAVER = "aBv6Idu9xDrzo71qfpak" API_ORIGIN = "https://api.cheda.kr" +JWT_PUBLIC_KEY = "LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUZrd0V3WUhLb1pJemowQ0FRWUlLb1pJemowREFRY0RRZ0FFckFqM2hEQnpSTmZuSXhWS2lOOHFWNWVXZmJYSAp3RHVyTXNQWGx6Q2dRY3lyNzV2dThYaXpOcTVaVHdma003R1ZPTkhRNEJ6OUVHNnNKbGQzZE8yR3dnPT0KLS0tLS1FTkQgUFVCTElDIEtFWS0tLS0tCg" # Bind the Workers AI model catalog. Run machine learning models, powered by serverless GPUs, on Cloudflare’s global network # Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#workers-ai From 9ce852663f485c1bf6e9ae68f8931722c2149525 Mon Sep 17 00:00:00 2001 From: Xvezda Date: Mon, 13 May 2024 17:54:59 +0900 Subject: [PATCH 28/30] =?UTF-8?q?chore:=20=EB=86=92=EC=9D=B4=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/package.json | 2 +- apps/web/app/login/page.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/api/package.json b/apps/api/package.json index 0599945..57ea057 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -22,4 +22,4 @@ "hono": "^4.2.4", "jose": "^5.2.4" } -} \ No newline at end of file +} diff --git a/apps/web/app/login/page.tsx b/apps/web/app/login/page.tsx index 75c4c85..2ca4767 100644 --- a/apps/web/app/login/page.tsx +++ b/apps/web/app/login/page.tsx @@ -7,7 +7,7 @@ import useAuth from '@/hooks/useAuth'; export default function LoginPage() { return ( -
+

체다 서비스에 로그인

From 8cd09c6e618c32003489042c3e4a0a9ab597de87 Mon Sep 17 00:00:00 2001 From: Xvezda Date: Mon, 13 May 2024 17:57:43 +0900 Subject: [PATCH 29/30] =?UTF-8?q?chore:=20=EC=9D=98=EC=A1=B4=EC=84=B1=20?= =?UTF-8?q?=EB=88=84=EB=9D=BD=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/app/login/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/app/login/page.tsx b/apps/web/app/login/page.tsx index 2ca4767..fe39482 100644 --- a/apps/web/app/login/page.tsx +++ b/apps/web/app/login/page.tsx @@ -30,7 +30,7 @@ function LoginButton() { if (auth.data?.loggedIn) { router.push(prevUrl || '/'); } - }, [auth.data]); + }, [router, prevUrl, auth.data]); return ; } From b0123ae698c8e43b23bdbf8e36aecd59145278c5 Mon Sep 17 00:00:00 2001 From: Xvezda Date: Mon, 13 May 2024 18:06:26 +0900 Subject: [PATCH 30/30] =?UTF-8?q?chore:=20=EB=A9=94=EB=89=B4=20=EB=B0=8F?= =?UTF-8?q?=20=ED=94=84=EB=A1=9C=ED=95=84=20=EC=82=AC=EC=A7=84=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/components/header/index.tsx | 2 +- apps/web/components/header/login-button.tsx | 46 ++++++++++++++------- 2 files changed, 33 insertions(+), 15 deletions(-) diff --git a/apps/web/components/header/index.tsx b/apps/web/components/header/index.tsx index 68b754b..b09ba72 100644 --- a/apps/web/components/header/index.tsx +++ b/apps/web/components/header/index.tsx @@ -29,7 +29,7 @@ export default function Header() { -
+
diff --git a/apps/web/components/header/login-button.tsx b/apps/web/components/header/login-button.tsx index 7c640c3..883841a 100644 --- a/apps/web/components/header/login-button.tsx +++ b/apps/web/components/header/login-button.tsx @@ -3,7 +3,15 @@ import Link from 'next/link'; import { Skeleton } from '@/components/ui/skeleton'; import { Button } from "@/components/ui/button"; -// import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" import useAuth from '@/hooks/useAuth'; export default function LoginButton() { @@ -15,24 +23,34 @@ export default function LoginButton() { if (!auth.data?.loggedIn) { return ( - ); } - // return ( - // - // - // {auth.data.user.userName.substring(0, 1)} - // - // ); - return ( - + + + + + {auth.data.user.userName.substring(0, 1)} + + + + + {auth.data.user.userName} + + + + + 로그아웃 + + + + ); }