mirror of
https://github.com/woodchen-ink/Q58Connect.git
synced 2025-07-18 14:01:55 +08:00
feat: OAuth authorization finished
This commit is contained in:
parent
b92fe7d572
commit
19d063ae14
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "next-shadcn-auth-template",
|
"name": "discourse-oauth",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { prisma } from "@/lib/prisma";
|
import { createClient, getClientByClientId } from "@/lib/dto/client";
|
||||||
import { getCurrentUser } from "@/lib/session";
|
import { getCurrentUser } from "@/lib/session";
|
||||||
import { generateClientKeyId, generateSecretKey } from "@/lib/utils";
|
import { generateRandomKey, generateSecretWords } from "@/lib/utils";
|
||||||
|
|
||||||
export async function AddClientAction(formData: FormData) {
|
export async function AddClientAction(formData: FormData) {
|
||||||
const name = formData.get("name") as string;
|
const name = formData.get("name") as string;
|
||||||
@ -14,15 +14,14 @@ export async function AddClientAction(formData: FormData) {
|
|||||||
const user = await getCurrentUser();
|
const user = await getCurrentUser();
|
||||||
|
|
||||||
// Generate a unique client ID and secret
|
// Generate a unique client ID and secret
|
||||||
let clientId = generateClientKeyId();
|
let clientId = generateRandomKey();
|
||||||
while (await findClientByClientId(clientId)) {
|
while (await getClientByClientId(clientId)) {
|
||||||
clientId = generateClientKeyId();
|
clientId = generateRandomKey();
|
||||||
}
|
}
|
||||||
const clientSecret = generateSecretKey();
|
const clientSecret = generateSecretWords();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const newClient = await prisma.client.create({
|
const newClient = await createClient({
|
||||||
data: {
|
|
||||||
name,
|
name,
|
||||||
home,
|
home,
|
||||||
logo,
|
logo,
|
||||||
@ -30,8 +29,7 @@ export async function AddClientAction(formData: FormData) {
|
|||||||
description,
|
description,
|
||||||
clientId,
|
clientId,
|
||||||
clientSecret,
|
clientSecret,
|
||||||
userId: user?.id,
|
userId: user?.id || "",
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("New client created:", newClient);
|
console.log("New client created:", newClient);
|
||||||
@ -41,11 +39,3 @@ export async function AddClientAction(formData: FormData) {
|
|||||||
return { success: false, error: "Failed to create client" };
|
return { success: false, error: "Failed to create client" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function findClientByClientId(clientId: string) {
|
|
||||||
return await prisma.client.findUnique({
|
|
||||||
where: {
|
|
||||||
clientId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
45
src/actions/discourse-callback.ts
Normal file
45
src/actions/discourse-callback.ts
Normal file
@ -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();
|
||||||
|
}
|
23
src/actions/discourse-sso-url.ts
Normal file
23
src/actions/discourse-sso-url.ts
Normal file
@ -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}`;
|
||||||
|
}
|
@ -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 { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
@ -11,25 +14,16 @@ import {
|
|||||||
import { AddClientButton } from "@/components/clients/add-client";
|
import { AddClientButton } from "@/components/clients/add-client";
|
||||||
|
|
||||||
// 创建 Prisma 客户端实例
|
// 创建 Prisma 客户端实例
|
||||||
async function fetchClients() {
|
async function fetchClients(userId: string) {
|
||||||
return await prisma.client.findMany({
|
return await getClientsByUserId(userId);
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
clientId: true,
|
|
||||||
redirectUri: true,
|
|
||||||
user: {
|
|
||||||
select: {
|
|
||||||
name: true,
|
|
||||||
email: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function ClientsPage() {
|
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 (
|
return (
|
||||||
<div className="container mx-auto py-10">
|
<div className="container mx-auto py-10">
|
||||||
@ -52,6 +46,7 @@ export default async function ClientsPage() {
|
|||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Name</TableHead>
|
<TableHead>Name</TableHead>
|
||||||
<TableHead>Client ID</TableHead>
|
<TableHead>Client ID</TableHead>
|
||||||
|
<TableHead>Client Secret Key</TableHead>
|
||||||
<TableHead>Redirect URI</TableHead>
|
<TableHead>Redirect URI</TableHead>
|
||||||
<TableHead>Actions</TableHead>
|
<TableHead>Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@ -61,10 +56,8 @@ export default async function ClientsPage() {
|
|||||||
<TableRow key={client.id}>
|
<TableRow key={client.id}>
|
||||||
<TableCell>{client.name}</TableCell>
|
<TableCell>{client.name}</TableCell>
|
||||||
<TableCell>{client.clientId}</TableCell>
|
<TableCell>{client.clientId}</TableCell>
|
||||||
|
<TableCell>{client.clientSecret}</TableCell>
|
||||||
<TableCell>{client.redirectUri}</TableCell>
|
<TableCell>{client.redirectUri}</TableCell>
|
||||||
<TableCell>
|
|
||||||
{client.user.name} ({client.user.email})
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Button variant="outline" size="sm" className="mr-2">
|
<Button variant="outline" size="sm" className="mr-2">
|
||||||
Edit
|
Edit
|
||||||
|
15
src/app/(oauth)/discourse/callback/page.tsx
Normal file
15
src/app/(oauth)/discourse/callback/page.tsx
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { Suspense } from "react";
|
||||||
|
|
||||||
|
import { Authorizing } from "@/components/auth/authorizing";
|
||||||
|
|
||||||
|
export default function AuthPage() {
|
||||||
|
return (
|
||||||
|
<main className="flex min-h-screen flex-col items-center justify-center">
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<Suspense>
|
||||||
|
<Authorizing />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
7
src/app/(oauth)/layout.tsx
Normal file
7
src/app/(oauth)/layout.tsx
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
export default async function OAuthLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return <div className="min-h-screen">{children}</div>;
|
||||||
|
}
|
57
src/app/(oauth)/oauth/authorize/page.tsx
Normal file
57
src/app/(oauth)/oauth/authorize/page.tsx
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
import { getClientByClientId } from "@/lib/dto/client";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { AuthorizationCard } from "@/components/auth/authorization-card";
|
||||||
|
|
||||||
|
export interface AuthorizeParams extends Record<string, string> {
|
||||||
|
scope: string;
|
||||||
|
response_type: string;
|
||||||
|
client_id: string;
|
||||||
|
redirect_uri: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function OAuthAuthorization({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: AuthorizeParams;
|
||||||
|
}) {
|
||||||
|
if (
|
||||||
|
!searchParams.response_type ||
|
||||||
|
!searchParams.client_id ||
|
||||||
|
!searchParams.redirect_uri
|
||||||
|
) {
|
||||||
|
throw new Error("Params invalid");
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await getClient({
|
||||||
|
clientId: searchParams.client_id,
|
||||||
|
redirectUri: searchParams.redirect_uri,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
throw new Error("Client not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-gray-50 p-4">
|
||||||
|
<AuthorizationCard client={client} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getClient({
|
||||||
|
clientId,
|
||||||
|
redirectUri,
|
||||||
|
}: {
|
||||||
|
clientId: string;
|
||||||
|
redirectUri: string;
|
||||||
|
}) {
|
||||||
|
const client = await getClientByClientId(clientId);
|
||||||
|
|
||||||
|
if (client && client.redirectUri === redirectUri) {
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
41
src/app/api/oauth/access_token/route.ts
Normal file
41
src/app/api/oauth/access_token/route.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { createAccessToken } from "@/lib/dto/accessToken";
|
||||||
|
import { deleteCode, getCodeByCode } from "@/lib/dto/code";
|
||||||
|
import { generateRandomKey } from "@/lib/utils";
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const formData = await req.formData();
|
||||||
|
const code = formData.get("code") as string;
|
||||||
|
if (!code) {
|
||||||
|
console.log(`code: ${code}`);
|
||||||
|
return new NextResponse("Invalid code credentials.", { status: 400 });
|
||||||
|
}
|
||||||
|
const authorizeCode = await getCodeByCode(code);
|
||||||
|
await deleteCode(code);
|
||||||
|
if (!authorizeCode) {
|
||||||
|
console.log(`code: ${code}`);
|
||||||
|
return new NextResponse("Invalid code credentials.", { status: 400 });
|
||||||
|
}
|
||||||
|
if (authorizeCode.client.redirectUri !== formData.get("redirect_uri")) {
|
||||||
|
console.log(
|
||||||
|
`redirectUri: ${authorizeCode.client.redirectUri} !== formData.get("redirect_uri"): ${formData.get("redirect_uri")}`,
|
||||||
|
);
|
||||||
|
return new NextResponse("Invalid redirect uri.", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const expiresIn = 3600 * 24 * 7;
|
||||||
|
const token = "tk_" + generateRandomKey();
|
||||||
|
await createAccessToken({
|
||||||
|
token,
|
||||||
|
expiresAt: new Date(Date.now() + expiresIn * 1000),
|
||||||
|
clientId: authorizeCode.client.id,
|
||||||
|
userId: authorizeCode.user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
access_token: token,
|
||||||
|
expires_in: expiresIn,
|
||||||
|
token_type: "bearer",
|
||||||
|
});
|
||||||
|
}
|
31
src/app/api/oauth/user/route.ts
Normal file
31
src/app/api/oauth/user/route.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { UserRole } from "@prisma/client";
|
||||||
|
|
||||||
|
import { getAccessTokenByToken } from "@/lib/dto/accessToken";
|
||||||
|
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
const authorization = req.headers.get("Authorization");
|
||||||
|
const token = authorization?.slice(7); // remove "Bearer "
|
||||||
|
if (!token) {
|
||||||
|
return new NextResponse("Invalid access token (code: -1000).", {
|
||||||
|
status: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const accessToken = await getAccessTokenByToken(token);
|
||||||
|
if (!accessToken) {
|
||||||
|
return new NextResponse("Invalid access token (code: -1001).", {
|
||||||
|
status: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let user = accessToken.user;
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
username: user.username,
|
||||||
|
admin: user.role == UserRole.ADMIN,
|
||||||
|
avatar_url: user.avatarUrl,
|
||||||
|
name: user.name,
|
||||||
|
});
|
||||||
|
}
|
@ -1,12 +1,7 @@
|
|||||||
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 type { NextAuthConfig } from "next-auth";
|
import type { NextAuthConfig } from "next-auth";
|
||||||
import Credentials from "next-auth/providers/credentials";
|
import Credentials from "next-auth/providers/credentials";
|
||||||
|
|
||||||
import { AUTH_NONCE } from "@/lib/constants";
|
import { verify } from "./lib/discourse-verify";
|
||||||
import { createUser, getUserById, updateUser } from "@/lib/dto/user";
|
|
||||||
|
|
||||||
// Notice this is only an object, not a full Auth.js instance
|
// Notice this is only an object, not a full Auth.js instance
|
||||||
export default {
|
export default {
|
||||||
@ -17,62 +12,10 @@ export default {
|
|||||||
sig: {},
|
sig: {},
|
||||||
},
|
},
|
||||||
authorize: async (credentials) => {
|
authorize: async (credentials) => {
|
||||||
const DISCOUSE_SECRET = process.env.DISCOUSE_SECRET as string;
|
|
||||||
const sso = credentials.sso as string;
|
const sso = credentials.sso as string;
|
||||||
const sig = credentials.sig;
|
const sig = credentials.sig as string;
|
||||||
// 校验数据正确性
|
const user = await verify(sso, sig);
|
||||||
if (hmacSHA256(sso, DISCOUSE_SECRET).toString(Hex) != sig) {
|
return user;
|
||||||
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(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return dbUser;
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
125
src/components/auth/authorization-card.tsx
Normal file
125
src/components/auth/authorization-card.tsx
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { getDiscourseSSOUrl } from "@/actions/discourse-sso-url";
|
||||||
|
import { Client } from "@prisma/client";
|
||||||
|
import {
|
||||||
|
ChevronsDownUp,
|
||||||
|
ChevronsUpDown,
|
||||||
|
GithubIcon,
|
||||||
|
Users,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
|
||||||
|
interface Permission {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const permissions: Permission[] = [
|
||||||
|
{
|
||||||
|
id: "read_profile",
|
||||||
|
name: "个人用户数据",
|
||||||
|
description: "电子邮件地址(只读), 个人资料信息(只读)",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function AuthorizationCard({ client }: { client: Client }) {
|
||||||
|
const [expandedPermission, setExpandedPermission] = useState<string | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
const togglePermission = (id: string) => {
|
||||||
|
setExpandedPermission(expandedPermission === id ? null : id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const authorizingHandler = async () => {
|
||||||
|
const url = await getDiscourseSSOUrl(searchParams.toString());
|
||||||
|
router.push(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="w-full max-w-2xl">
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<div className="mb-4 flex items-center justify-center space-x-4">
|
||||||
|
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-red-100">
|
||||||
|
<span className="text-3xl font-bold text-red-500">B</span>
|
||||||
|
</div>
|
||||||
|
<GithubIcon className="h-16 w-16" />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-2xl font-bold">授权 {client.name}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="mb-6 text-center">
|
||||||
|
{client.description}
|
||||||
|
<br />
|
||||||
|
想要访问您的 Shuzimumin-Community 账户
|
||||||
|
</p>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{permissions.map((permission) => (
|
||||||
|
<div key={permission.id} className="rounded-lg border p-4">
|
||||||
|
<div
|
||||||
|
className="flex cursor-pointer items-center justify-between"
|
||||||
|
onClick={() => togglePermission(permission.id)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<Checkbox
|
||||||
|
id={permission.id}
|
||||||
|
checked={permission.id === "read_profile"}
|
||||||
|
disabled={permission.id === "read_profile"}
|
||||||
|
/>
|
||||||
|
<label htmlFor={permission.id} className="font-medium">
|
||||||
|
{permission.name}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{expandedPermission === permission.id ? (
|
||||||
|
<ChevronsDownUp />
|
||||||
|
) : (
|
||||||
|
<ChevronsUpDown />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{expandedPermission === permission.id && (
|
||||||
|
<p className="mt-2 text-sm text-gray-600">
|
||||||
|
{permission.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="flex flex-col items-center">
|
||||||
|
<div className="mb-4 flex space-x-4">
|
||||||
|
<Button variant="outline">取消</Button>
|
||||||
|
<Button
|
||||||
|
className="bg-green-600 text-white hover:bg-green-700"
|
||||||
|
onClick={authorizingHandler}
|
||||||
|
>
|
||||||
|
授权 {client.name}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="mb-4 text-sm text-gray-500">
|
||||||
|
授权将重定向到 {client.redirectUri}
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-center space-x-8 text-sm text-gray-500">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Users className="mr-1 h-4 w-4" />
|
||||||
|
<span>数字牧民社区运营</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
43
src/components/auth/authorizing.tsx
Normal file
43
src/components/auth/authorizing.tsx
Normal file
@ -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<boolean>(false);
|
||||||
|
const [error, setError] = useState<unknown | null>(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 ? (
|
||||||
|
<p className="text-center">登录异常,授权失败!</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-center"> 授权信息验证,准备跳转中,请稍等...</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
71
src/lib/discourse-verify.ts
Normal file
71
src/lib/discourse-verify.ts
Normal file
@ -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;
|
||||||
|
}
|
16
src/lib/dto/accessToken.ts
Normal file
16
src/lib/dto/accessToken.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { AccessToken } from "@prisma/client";
|
||||||
|
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export async function createAccessToken(
|
||||||
|
data: Omit<AccessToken, "id">,
|
||||||
|
): Promise<AccessToken> {
|
||||||
|
return prisma.accessToken.create({ data });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAccessTokenByToken(token: string) {
|
||||||
|
return prisma.accessToken.findUnique({
|
||||||
|
where: { token },
|
||||||
|
include: { user: true },
|
||||||
|
});
|
||||||
|
}
|
26
src/lib/dto/client.ts
Normal file
26
src/lib/dto/client.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { Client } from "@prisma/client";
|
||||||
|
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export async function getClientByClientId(
|
||||||
|
clientId: string,
|
||||||
|
): Promise<Client | null> {
|
||||||
|
return prisma.client.findUnique({ where: { clientId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createClient(data: Omit<Client, "id">): Promise<Client> {
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
18
src/lib/dto/code.ts
Normal file
18
src/lib/dto/code.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { Code } from "@prisma/client";
|
||||||
|
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export async function createCode(data: Omit<Code, "id">) {
|
||||||
|
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 } });
|
||||||
|
}
|
@ -6,7 +6,7 @@ export function cn(...inputs: ClassValue[]) {
|
|||||||
return twMerge(clsx(inputs));
|
return twMerge(clsx(inputs));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function generateClientKeyId(length: number = 16): string {
|
export function generateRandomKey(length: number = 16): string {
|
||||||
const characters =
|
const characters =
|
||||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||||
const charactersLength = characters.length;
|
const charactersLength = characters.length;
|
||||||
@ -21,7 +21,7 @@ export function generateClientKeyId(length: number = 16): string {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function generateSecretKey(length: number = 32): string {
|
export function generateSecretWords(length: number = 32): string {
|
||||||
const buffer = WordArray.random(length);
|
const buffer = WordArray.random(length);
|
||||||
return buffer.toString();
|
return buffer.toString();
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user