From 19d063ae14754a3b71de14ae9034849b4d3948c4 Mon Sep 17 00:00:00 2001 From: Tuluobo Date: Sun, 8 Sep 2024 22:37:42 +0800 Subject: [PATCH] feat: OAuth authorization finished --- package.json | 2 +- src/actions/add-client.ts | 40 +++--- src/actions/discourse-callback.ts | 45 +++++++ src/actions/discourse-sso-url.ts | 23 ++++ .../(dashboard)/dashboard/clients/page.tsx | 33 ++--- src/app/(oauth)/discourse/callback/page.tsx | 15 +++ src/app/(oauth)/layout.tsx | 7 + src/app/(oauth)/oauth/authorize/page.tsx | 57 ++++++++ src/app/api/oauth/access_token/route.ts | 41 ++++++ src/app/api/oauth/user/route.ts | 31 +++++ src/auth.config.ts | 65 +-------- src/components/auth/authorization-card.tsx | 125 ++++++++++++++++++ src/components/auth/authorizing.tsx | 43 ++++++ src/lib/discourse-verify.ts | 71 ++++++++++ src/lib/dto/accessToken.ts | 16 +++ src/lib/dto/client.ts | 26 ++++ src/lib/dto/code.ts | 18 +++ src/lib/utils.ts | 4 +- 18 files changed, 553 insertions(+), 109 deletions(-) create mode 100644 src/actions/discourse-callback.ts create mode 100644 src/actions/discourse-sso-url.ts create mode 100644 src/app/(oauth)/discourse/callback/page.tsx create mode 100644 src/app/(oauth)/layout.tsx create mode 100644 src/app/(oauth)/oauth/authorize/page.tsx create mode 100644 src/app/api/oauth/access_token/route.ts create mode 100644 src/app/api/oauth/user/route.ts create mode 100644 src/components/auth/authorization-card.tsx create mode 100644 src/components/auth/authorizing.tsx create mode 100644 src/lib/discourse-verify.ts create mode 100644 src/lib/dto/accessToken.ts create mode 100644 src/lib/dto/client.ts create mode 100644 src/lib/dto/code.ts diff --git a/package.json b/package.json index e930e0c..abb2973 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "next-shadcn-auth-template", + "name": "discourse-oauth", "version": "0.1.0", "private": true, "scripts": { diff --git a/src/actions/add-client.ts b/src/actions/add-client.ts index 3a3ad5f..591c0e3 100644 --- a/src/actions/add-client.ts +++ b/src/actions/add-client.ts @@ -1,8 +1,8 @@ "use server"; -import { prisma } from "@/lib/prisma"; +import { createClient, getClientByClientId } from "@/lib/dto/client"; import { getCurrentUser } from "@/lib/session"; -import { generateClientKeyId, generateSecretKey } from "@/lib/utils"; +import { generateRandomKey, generateSecretWords } from "@/lib/utils"; export async function AddClientAction(formData: FormData) { const name = formData.get("name") as string; @@ -14,24 +14,22 @@ export async function AddClientAction(formData: FormData) { const user = await getCurrentUser(); // Generate a unique client ID and secret - let clientId = generateClientKeyId(); - while (await findClientByClientId(clientId)) { - clientId = generateClientKeyId(); + let clientId = generateRandomKey(); + while (await getClientByClientId(clientId)) { + clientId = generateRandomKey(); } - const clientSecret = generateSecretKey(); + const clientSecret = generateSecretWords(); try { - const newClient = await prisma.client.create({ - data: { - name, - home, - logo, - redirectUri, - description, - clientId, - clientSecret, - userId: user?.id, - }, + const newClient = await createClient({ + name, + home, + logo, + redirectUri, + description, + clientId, + clientSecret, + userId: user?.id || "", }); console.log("New client created:", newClient); @@ -41,11 +39,3 @@ export async function AddClientAction(formData: FormData) { return { success: false, error: "Failed to create client" }; } } - -async function findClientByClientId(clientId: string) { - return await prisma.client.findUnique({ - where: { - clientId, - }, - }); -} diff --git a/src/actions/discourse-callback.ts b/src/actions/discourse-callback.ts new file mode 100644 index 0000000..3227fe0 --- /dev/null +++ b/src/actions/discourse-callback.ts @@ -0,0 +1,45 @@ +"use server"; + +import WordArray from "crypto-js/lib-typedarrays"; + +import { verify } from "@/lib/discourse-verify"; +import { getClientByClientId } from "@/lib/dto/client"; +import { createCode } from "@/lib/dto/code"; + +export async function handleDiscourseCallbackAction(searchParams: string) { + const params = new URLSearchParams(searchParams); + const sig = params.get("sig") as string; + const sso = params.get("sso") as string; + const oauth = params.get("oauth") as string; + + const user = await verify(sso, sig); + // code redirect ... + const oauthParams = new URLSearchParams(atob(oauth)); + const client = await getClientByClientId( + oauthParams.get("client_id") as string, + ); + if (!client) { + throw new Error("Client Id invalid (code: -1004)."); + } + + const redirect_uri = new URL(client.redirectUri); + if (oauthParams.has("state")) { + redirect_uri.searchParams.append("state", oauthParams.get("state") || ""); + } + const code = WordArray.random(32).toString(); + redirect_uri.searchParams.append("code", code); + + // storage + try { + await createCode({ + code, + expiresAt: new Date(Date.now() + 10 * 60 * 1000), + clientId: client.id, + userId: user.id, + }); + } catch { + throw new Error("Create code error (code: -1005)."); + } + + return redirect_uri.toString(); +} diff --git a/src/actions/discourse-sso-url.ts b/src/actions/discourse-sso-url.ts new file mode 100644 index 0000000..dfce5db --- /dev/null +++ b/src/actions/discourse-sso-url.ts @@ -0,0 +1,23 @@ +"use server"; + +import { cookies } from "next/headers"; +import Hex from "crypto-js/enc-hex"; +import hmacSHA256 from "crypto-js/hmac-sha256"; +import WordArray from "crypto-js/lib-typedarrays"; + +import { AUTH_NONCE } from "@/lib/constants"; + +const appHost = process.env.NEXT_PUBLIC_HOST_URL; +const oauthSecret = process.env.DISCOUSE_SECRET || ""; + +export async function getDiscourseSSOUrl(searchParams: string) { + console.log(`searchParams: ${searchParams}`); + + const nonce = WordArray.random(16).toString(); + const return_url = `${appHost}/discourse/callback?oauth=${btoa(searchParams)}`; + const sso = btoa(`nonce=${nonce}&return_sso_url=${encodeURI(return_url)}`); + const sig = hmacSHA256(sso, oauthSecret).toString(Hex); + cookies().set(AUTH_NONCE, nonce, { maxAge: 60 * 10 }); + + return `https://shuzimumin.com/session/sso_provider?sso=${sso}&sig=${sig}`; +} diff --git a/src/app/(dashboard)/dashboard/clients/page.tsx b/src/app/(dashboard)/dashboard/clients/page.tsx index 2a36009..55161ea 100644 --- a/src/app/(dashboard)/dashboard/clients/page.tsx +++ b/src/app/(dashboard)/dashboard/clients/page.tsx @@ -1,4 +1,7 @@ -import { prisma } from "@/lib/prisma"; +import { redirect } from "next/navigation"; + +import { getClientsByUserId } from "@/lib/dto/client"; +import { getCurrentUser } from "@/lib/session"; import { Button } from "@/components/ui/button"; import { Table, @@ -11,25 +14,16 @@ import { import { AddClientButton } from "@/components/clients/add-client"; // 创建 Prisma 客户端实例 -async function fetchClients() { - return await prisma.client.findMany({ - select: { - id: true, - name: true, - clientId: true, - redirectUri: true, - user: { - select: { - name: true, - email: true, - }, - }, - }, - }); +async function fetchClients(userId: string) { + return await getClientsByUserId(userId); } export default async function ClientsPage() { - const clients = await fetchClients(); + const user = await getCurrentUser(); + if (!user) { + redirect("sign-in"); + } + const clients = await fetchClients(user.id as string); return (
@@ -52,6 +46,7 @@ export default async function ClientsPage() { Name Client ID + Client Secret Key Redirect URI Actions @@ -61,10 +56,8 @@ export default async function ClientsPage() { {client.name} {client.clientId} + {client.clientSecret} {client.redirectUri} - - {client.user.name} ({client.user.email}) - + +
+

+ 授权将重定向到 {client.redirectUri} +

+
+
+ + 数字牧民社区运营 +
+
+ + + ); +} diff --git a/src/components/auth/authorizing.tsx b/src/components/auth/authorizing.tsx new file mode 100644 index 0000000..8440402 --- /dev/null +++ b/src/components/auth/authorizing.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { handleDiscourseCallbackAction } from "@/actions/discourse-callback"; + +export function Authorizing() { + const router = useRouter(); + const searchParams = useSearchParams(); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const signInCallback = useCallback(async () => { + if (isLoading) { + return; + } + setIsLoading(true); + try { + const url = await handleDiscourseCallbackAction(searchParams.toString()); + router.push(url); + setIsLoading(false); + } catch (error) { + setError(error); + setIsLoading(false); + } + }, []); + + useEffect(() => { + const timer = setTimeout(signInCallback, 3); + return () => { + clearTimeout(timer); + }; + }, []); + return ( + <> + {error ? ( +

登录异常,授权失败!

+ ) : ( +

授权信息验证,准备跳转中,请稍等...

+ )} + + ); +} diff --git a/src/lib/discourse-verify.ts b/src/lib/discourse-verify.ts new file mode 100644 index 0000000..35c1ad4 --- /dev/null +++ b/src/lib/discourse-verify.ts @@ -0,0 +1,71 @@ +import "server-only"; + +import { cookies } from "next/headers"; +import { UserRole } from "@prisma/client"; +import Hex from "crypto-js/enc-hex"; +import hmacSHA256 from "crypto-js/hmac-sha256"; + +import { AUTH_NONCE } from "@/lib/constants"; +import { createUser, getUserById, updateUser } from "@/lib/dto/user"; + +const DISCOUSE_SECRET = process.env.DISCOUSE_SECRET as string; + +export async function verify(sso: string, sig: string) { + // 校验数据正确性 + if (hmacSHA256(sso, DISCOUSE_SECRET).toString(Hex) != sig) { + throw new Error("Request params is invalid (code: -1001)."); + } + // 校验 nonce + const cookieStore = cookies(); + let searchParams = new URLSearchParams(atob(sso as string)); + const nonce = searchParams.get("nonce"); + if (!cookieStore.has(AUTH_NONCE) || !nonce) { + throw new Error("Request params is invalid (code: -1002)."); + } + if (cookieStore.get(AUTH_NONCE)?.value != nonce) { + throw new Error("Request params is invalid (code: -1003)."); + } + cookieStore.delete(AUTH_NONCE); + + const id = searchParams.get("external_id"); + const email = searchParams.get("email"); + const username = searchParams.get("username"); + const name = searchParams.get("name"); + const avatarUrl = searchParams.get("avatar_url"); + const isAdmin = searchParams.get("admin") == "true"; + if (!id || !email || !username) { + throw new Error("User not found."); + } + + // 查数据库是否有人 + let dbUser = await getUserById(id); + if (dbUser) { + // 更新 + dbUser = await updateUser(id, { + username, + email, + name, + avatarUrl, + role: isAdmin ? UserRole.ADMIN : UserRole.USER, + updatedAt: new Date(), + }); + } else { + // 创建 + dbUser = await createUser({ + id, + username, + email, + name, + avatarUrl, + role: isAdmin ? UserRole.ADMIN : UserRole.USER, + createdAt: new Date(), + updatedAt: new Date(), + }); + } + + if (!dbUser) { + throw new Error("User not create success."); + } + + return dbUser; +} diff --git a/src/lib/dto/accessToken.ts b/src/lib/dto/accessToken.ts new file mode 100644 index 0000000..dc7b304 --- /dev/null +++ b/src/lib/dto/accessToken.ts @@ -0,0 +1,16 @@ +import { AccessToken } from "@prisma/client"; + +import { prisma } from "@/lib/prisma"; + +export async function createAccessToken( + data: Omit, +): Promise { + return prisma.accessToken.create({ data }); +} + +export async function getAccessTokenByToken(token: string) { + return prisma.accessToken.findUnique({ + where: { token }, + include: { user: true }, + }); +} diff --git a/src/lib/dto/client.ts b/src/lib/dto/client.ts new file mode 100644 index 0000000..986a191 --- /dev/null +++ b/src/lib/dto/client.ts @@ -0,0 +1,26 @@ +import { Client } from "@prisma/client"; + +import { prisma } from "@/lib/prisma"; + +export async function getClientByClientId( + clientId: string, +): Promise { + return prisma.client.findUnique({ where: { clientId } }); +} + +export async function createClient(data: Omit): Promise { + return prisma.client.create({ data }); +} + +export async function getClientsByUserId(userId: string) { + return prisma.client.findMany({ + where: { userId }, + select: { + id: true, + name: true, + clientId: true, + clientSecret: true, + redirectUri: true, + }, + }); +} diff --git a/src/lib/dto/code.ts b/src/lib/dto/code.ts new file mode 100644 index 0000000..92d80b1 --- /dev/null +++ b/src/lib/dto/code.ts @@ -0,0 +1,18 @@ +import { Code } from "@prisma/client"; + +import { prisma } from "@/lib/prisma"; + +export async function createCode(data: Omit) { + return prisma.code.create({ data }); +} + +export async function getCodeByCode(code: string) { + return prisma.code.findUnique({ + where: { code }, + include: { client: true, user: true }, + }); +} + +export async function deleteCode(code: string) { + await prisma.code.delete({ where: { code } }); +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 2669b74..f7b09b7 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -6,7 +6,7 @@ export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } -export function generateClientKeyId(length: number = 16): string { +export function generateRandomKey(length: number = 16): string { const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; const charactersLength = characters.length; @@ -21,7 +21,7 @@ export function generateClientKeyId(length: number = 16): string { return result; } -export function generateSecretKey(length: number = 32): string { +export function generateSecretWords(length: number = 32): string { const buffer = WordArray.random(length); return buffer.toString(); }