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