mirror of
https://github.com/woodchen-ink/Q58Connect.git
synced 2025-07-18 14:01:55 +08:00
refactor: Replace Discourse SSO with Q58 authentication flow
This commit is contained in:
parent
25f262c1ac
commit
0c1d7fceea
@ -15,7 +15,7 @@ export async function getDiscourseSSOUrl(searchParams: string) {
|
|||||||
console.log(`searchParams: ${searchParams}`);
|
console.log(`searchParams: ${searchParams}`);
|
||||||
|
|
||||||
const nonce = WordArray.random(16).toString();
|
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 sso = btoa(`nonce=${nonce}&return_sso_url=${encodeURI(return_url)}`);
|
||||||
const sig = hmacSHA256(sso, oauthSecret).toString(Hex);
|
const sig = hmacSHA256(sso, oauthSecret).toString(Hex);
|
||||||
cookies().set(AUTH_NONCE, nonce, { maxAge: 60 * 10 });
|
cookies().set(AUTH_NONCE, nonce, { maxAge: 60 * 10 });
|
||||||
|
@ -1,22 +1,22 @@
|
|||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
import { discourseCallbackVerify } from "@/lib/discourse/verify";
|
|
||||||
import { findAuthorization } from "@/lib/dto/authorization";
|
import { findAuthorization } from "@/lib/dto/authorization";
|
||||||
import { getClientByClientId } from "@/lib/dto/client";
|
import { getClientByClientId } from "@/lib/dto/client";
|
||||||
import { getAuthorizeUrl } from "@/lib/oauth/authorize-url";
|
import { getAuthorizeUrl } from "@/lib/oauth/authorize-url";
|
||||||
|
import { q58CallbackVerify } from "@/lib/q58/verify";
|
||||||
import { AuthorizationCard } from "@/components/auth/authorization-card";
|
import { AuthorizationCard } from "@/components/auth/authorization-card";
|
||||||
|
|
||||||
export interface DiscourseCallbackParams extends Record<string, string> {
|
export interface Q58CallbackParams extends Record<string, string> {
|
||||||
sig: string;
|
sig: string;
|
||||||
sso: string;
|
sso: string;
|
||||||
oauth: string;
|
oauth: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function DiscourseCallbackPage({
|
export default async function Q58CallbackPage({
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
searchParams: DiscourseCallbackParams;
|
searchParams: Q58CallbackParams;
|
||||||
}) {
|
}) {
|
||||||
const oauthParams = new URLSearchParams(atob(searchParams.oauth));
|
const oauthParams = new URLSearchParams(atob(searchParams.oauth));
|
||||||
// check client info
|
// check client info
|
||||||
@ -27,11 +27,8 @@ export default async function DiscourseCallbackPage({
|
|||||||
throw new Error("Client Id invalid (code: -1004).");
|
throw new Error("Client Id invalid (code: -1004).");
|
||||||
}
|
}
|
||||||
|
|
||||||
// verify discourse callback
|
// verify q58 callback
|
||||||
const user = await discourseCallbackVerify(
|
const user = await q58CallbackVerify(searchParams.sso, searchParams.sig);
|
||||||
searchParams.sso,
|
|
||||||
searchParams.sig,
|
|
||||||
);
|
|
||||||
|
|
||||||
// check authorization
|
// check authorization
|
||||||
const authorization = await findAuthorization(user.id, client.id);
|
const authorization = await findAuthorization(user.id, client.id);
|
@ -1,7 +1,7 @@
|
|||||||
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 { discourseCallbackVerify } from "./lib/discourse/verify";
|
import { q58CallbackVerify } from "./lib/q58/verify";
|
||||||
|
|
||||||
// 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 {
|
||||||
@ -14,7 +14,7 @@ export default {
|
|||||||
authorize: async (credentials) => {
|
authorize: async (credentials) => {
|
||||||
const sso = credentials.sso as string;
|
const sso = credentials.sso as string;
|
||||||
const sig = credentials.sig as string;
|
const sig = credentials.sig as string;
|
||||||
const user = await discourseCallbackVerify(sso, sig);
|
const user = await q58CallbackVerify(sso, sig);
|
||||||
return user;
|
return user;
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import Image from "next/image";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { handleAuthorizeAction } from "@/actions/authorizing";
|
import { handleAuthorizeAction } from "@/actions/authorizing";
|
||||||
import { Client } from "@prisma/client";
|
import { Client } from "@prisma/client";
|
||||||
@ -75,9 +76,19 @@ export function AuthorizationCard({
|
|||||||
<div className="group relative">
|
<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="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">
|
<div className="relative flex h-20 w-20 items-center justify-center rounded-full bg-white">
|
||||||
|
{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">
|
<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()}
|
{client.name[0].toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -90,18 +101,23 @@ export function AuthorizationCard({
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="space-y-3 rounded-lg bg-gray-50 p-4">
|
<div className="space-y-3 rounded-lg bg-gray-100 p-4 dark:bg-gray-800">
|
||||||
<h3 className="text-lg font-semibold">请求的权限</h3>
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
请求的权限
|
||||||
|
</h3>
|
||||||
{permissions.map((permission) => (
|
{permissions.map((permission) => (
|
||||||
<div
|
<div
|
||||||
key={permission.id}
|
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)}
|
onClick={() => togglePermission(permission.id)}
|
||||||
>
|
>
|
||||||
<div className="flex cursor-pointer items-center justify-between">
|
<div className="flex cursor-pointer items-center justify-between">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Checkbox id={permission.id} checked disabled />
|
<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}
|
{permission.name}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@ -112,7 +128,7 @@ export function AuthorizationCard({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{expandedPermission === permission.id && (
|
{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}
|
{permission.description}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
@ -22,7 +22,7 @@ export function UserAuthForm({
|
|||||||
|
|
||||||
const signIn = () => {
|
const signIn = () => {
|
||||||
React.startTransition(async () => {
|
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) {
|
if (!response.ok || response.status !== 200) {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
toast({
|
toast({
|
||||||
|
@ -8,11 +8,11 @@ import hmacSHA256 from "crypto-js/hmac-sha256";
|
|||||||
import { AUTH_NONCE } from "@/lib/constants";
|
import { AUTH_NONCE } from "@/lib/constants";
|
||||||
import { createUser, getUserById, updateUser } from "@/lib/dto/user";
|
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).");
|
throw new Error("Request params is invalid (code: -1001).");
|
||||||
}
|
}
|
||||||
// 校验 nonce
|
// 校验 nonce
|
||||||
|
74
src/lib/q58/verify.ts
Normal file
74
src/lib/q58/verify.ts
Normal 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;
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user