refactor: Replace Discourse SSO with Q58 authentication flow

This commit is contained in:
wood chen 2025-02-17 05:56:15 +08:00
parent 25f262c1ac
commit 0c1d7fceea
8 changed files with 111 additions and 24 deletions

View File

@ -15,7 +15,7 @@ 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 return_url = `${appHost}/q58/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 });

View File

@ -1,22 +1,22 @@
import { Suspense } from "react";
import { redirect } from "next/navigation";
import { discourseCallbackVerify } from "@/lib/discourse/verify";
import { findAuthorization } from "@/lib/dto/authorization";
import { getClientByClientId } from "@/lib/dto/client";
import { getAuthorizeUrl } from "@/lib/oauth/authorize-url";
import { q58CallbackVerify } from "@/lib/q58/verify";
import { AuthorizationCard } from "@/components/auth/authorization-card";
export interface DiscourseCallbackParams extends Record<string, string> {
export interface Q58CallbackParams extends Record<string, string> {
sig: string;
sso: string;
oauth: string;
}
export default async function DiscourseCallbackPage({
export default async function Q58CallbackPage({
searchParams,
}: {
searchParams: DiscourseCallbackParams;
searchParams: Q58CallbackParams;
}) {
const oauthParams = new URLSearchParams(atob(searchParams.oauth));
// check client info
@ -27,11 +27,8 @@ export default async function DiscourseCallbackPage({
throw new Error("Client Id invalid (code: -1004).");
}
// verify discourse callback
const user = await discourseCallbackVerify(
searchParams.sso,
searchParams.sig,
);
// verify q58 callback
const user = await q58CallbackVerify(searchParams.sso, searchParams.sig);
// check authorization
const authorization = await findAuthorization(user.id, client.id);

View File

@ -1,7 +1,7 @@
import type { NextAuthConfig } from "next-auth";
import Credentials from "next-auth/providers/credentials";
import { discourseCallbackVerify } from "./lib/discourse/verify";
import { q58CallbackVerify } from "./lib/q58/verify";
// Notice this is only an object, not a full Auth.js instance
export default {
@ -14,7 +14,7 @@ export default {
authorize: async (credentials) => {
const sso = credentials.sso as string;
const sig = credentials.sig as string;
const user = await discourseCallbackVerify(sso, sig);
const user = await q58CallbackVerify(sso, sig);
return user;
},
}),

View File

@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { handleAuthorizeAction } from "@/actions/authorizing";
import { Client } from "@prisma/client";
@ -75,9 +76,19 @@ export function AuthorizationCard({
<div className="group relative">
<div className="absolute -inset-0.5 rounded-full bg-gradient-to-r from-pink-600 to-purple-600 opacity-50 blur transition duration-300 group-hover:opacity-75"></div>
<div className="relative flex h-20 w-20 items-center justify-center rounded-full bg-white">
<span className="bg-gradient-to-r from-pink-600 to-purple-600 bg-clip-text text-4xl font-bold text-transparent">
{client.name[0].toUpperCase()}
</span>
{client.logo ? (
<Image
src={client.logo}
alt={client.name}
width={64}
height={64}
className="rounded-full object-cover"
/>
) : (
<span className="bg-gradient-to-r from-pink-600 to-purple-600 bg-clip-text text-4xl font-bold text-transparent">
{client.name[0].toUpperCase()}
</span>
)}
</div>
</div>
</div>
@ -90,18 +101,23 @@ export function AuthorizationCard({
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-3 rounded-lg bg-gray-50 p-4">
<h3 className="text-lg font-semibold"></h3>
<div className="space-y-3 rounded-lg bg-gray-100 p-4 dark:bg-gray-800">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
</h3>
{permissions.map((permission) => (
<div
key={permission.id}
className="rounded-md border bg-white p-4 transition-all duration-200 hover:border-purple-200"
className="rounded-md border border-gray-200 bg-white p-4 shadow-sm transition-all duration-200 hover:border-purple-200 dark:border-gray-700 dark:bg-gray-900"
onClick={() => togglePermission(permission.id)}
>
<div className="flex cursor-pointer items-center justify-between">
<div className="flex items-center space-x-2">
<Checkbox id={permission.id} checked disabled />
<label htmlFor={permission.id} className="font-medium">
<label
htmlFor={permission.id}
className="font-medium text-gray-900 dark:text-gray-100"
>
{permission.name}
</label>
</div>
@ -112,7 +128,7 @@ export function AuthorizationCard({
)}
</div>
{expandedPermission === permission.id && (
<div className="mt-2 pl-6 text-sm text-gray-500">
<div className="mt-2 pl-6 text-sm text-gray-600 dark:text-gray-400">
{permission.description}
</div>
)}

View File

@ -22,7 +22,7 @@ export function UserAuthForm({
const signIn = () => {
React.startTransition(async () => {
const response = await fetch("/api/auth/discourse", { method: "POST" });
const response = await fetch("/api/auth/q58", { method: "POST" });
if (!response.ok || response.status !== 200) {
setIsLoading(false);
toast({

View File

@ -8,11 +8,11 @@ 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.DISCOURSE_SECRET as string;
const Q58_SECRET = process.env.DISCOURSE_SECRET as string;
export async function discourseCallbackVerify(sso: string, sig: string) {
export async function q58CallbackVerify(sso: string, sig: string) {
// 校验数据正确性
if (hmacSHA256(sso, DISCOUSE_SECRET).toString(Hex) != sig) {
if (hmacSHA256(sso, Q58_SECRET).toString(Hex) != sig) {
throw new Error("Request params is invalid (code: -1001).");
}
// 校验 nonce

74
src/lib/q58/verify.ts Normal file
View File

@ -0,0 +1,74 @@
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 Q58_SECRET = process.env.DISCOURSE_SECRET as string;
export async function q58CallbackVerify(sso: string, sig: string) {
// 校验数据正确性
if (hmacSHA256(sso, Q58_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";
const moderator = searchParams.get("moderator") == "true";
const groups = searchParams.get("groups")?.split(",");
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,
moderator,
groups,
});
} else {
// 创建
dbUser = await createUser({
id,
username,
email,
name,
avatarUrl,
role: isAdmin ? UserRole.ADMIN : UserRole.USER,
moderator,
groups,
});
}
if (!dbUser) {
throw new Error("User not create success.");
}
return dbUser;
}