refactor: Redesign the auth process

This commit is contained in:
Tuluobo 2024-09-15 19:04:00 +08:00
parent 19d063ae14
commit 1069de389c
16 changed files with 232 additions and 114 deletions

View File

@ -35,6 +35,7 @@ CREATE TABLE "codes" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"deletedAt" TIMESTAMP(3),
"userId" TEXT NOT NULL,
"clientId" TEXT NOT NULL,
@ -52,6 +53,18 @@ CREATE TABLE "access_tokens" (
CONSTRAINT "access_tokens_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "authorizations" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"userId" TEXT NOT NULL,
"clientId" TEXT NOT NULL,
"scope" TEXT,
CONSTRAINT "authorizations_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "users_username_key" ON "users"("username");
@ -67,6 +80,9 @@ CREATE UNIQUE INDEX "codes_code_key" ON "codes"("code");
-- CreateIndex
CREATE UNIQUE INDEX "access_tokens_token_key" ON "access_tokens"("token");
-- CreateIndex
CREATE UNIQUE INDEX "authorizations_userId_clientId_key" ON "authorizations"("userId", "clientId");
-- AddForeignKey
ALTER TABLE "clients" ADD CONSTRAINT "clients_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@ -81,3 +97,9 @@ ALTER TABLE "access_tokens" ADD CONSTRAINT "access_tokens_userId_fkey" FOREIGN K
-- AddForeignKey
ALTER TABLE "access_tokens" ADD CONSTRAINT "access_tokens_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "clients"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "authorizations" ADD CONSTRAINT "authorizations_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "authorizations" ADD CONSTRAINT "authorizations_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "clients"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -24,6 +24,8 @@ model User {
codes Code[]
accessTokens AccessToken[]
authorizations Authorization[]
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @default(now()) @map(name: "updated_at")
@ -47,13 +49,16 @@ model Client {
authCodes Code[]
accessTokens AccessToken[]
authorizations Authorization[]
@@map("clients")
}
model Code {
id String @id @default(cuid())
code String @unique
id String @id @default(cuid())
code String @unique
expiresAt DateTime
deletedAt DateTime?
userId String
user User @relation(fields: [userId], references: [id])
@ -77,3 +82,20 @@ model AccessToken {
@@map("access_tokens")
}
model Authorization {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
userId String
user User @relation(fields: [userId], references: [id])
clientId String
client Client @relation(fields: [clientId], references: [id])
scope String?
@@unique([userId, clientId])
@@map("authorizations")
}

View File

@ -0,0 +1,23 @@
"use server";
import { createAuthorization } from "@/lib/dto/authorization";
import { getAuthorizeUrl } from "@/lib/oauth/authorize-url";
export async function handleAuthorizeAction(
oauth: string,
userId: string,
clientId: string,
scope: string,
) {
const oauthParams = new URLSearchParams(atob(oauth));
const redirectUrl = getAuthorizeUrl(oauthParams);
// 保存授权
await createAuthorization({
userId,
clientId,
scope,
});
return redirectUrl;
}

View File

@ -1,45 +0,0 @@
"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();
}

View File

@ -1,13 +1,51 @@
import { Suspense } from "react";
import { redirect } from "next/navigation";
import { Authorizing } from "@/components/auth/authorizing";
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 { AuthorizationCard } from "@/components/auth/authorization-card";
export interface DiscourseCallbackParams extends Record<string, string> {
sig: string;
sso: string;
oauth: string;
}
export default async function DiscourseCallbackPage({
searchParams,
}: {
searchParams: DiscourseCallbackParams;
}) {
const oauthParams = new URLSearchParams(atob(searchParams.oauth));
// check client info
const client = await getClientByClientId(
oauthParams.get("client_id") as string,
);
if (!client) {
throw new Error("Client Id invalid (code: -1004).");
}
// verify discourse callback
const user = await discourseCallbackVerify(
searchParams.sso,
searchParams.sig,
);
// check authorization
const authorization = await findAuthorization(user.id, client.id);
if (authorization) {
const redirectUrl = await getAuthorizeUrl(oauthParams);
return redirect(redirectUrl);
}
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 />
<AuthorizationCard client={client} oauthParams={searchParams.oauth} />
</Suspense>
</div>
</main>

View File

@ -1,8 +1,5 @@
import { redirect } from "next/navigation";
import { getClientByClientId } from "@/lib/dto/client";
import { prisma } from "@/lib/prisma";
import { AuthorizationCard } from "@/components/auth/authorization-card";
import { Authorizing } from "@/components/auth/authorizing";
export interface AuthorizeParams extends Record<string, string> {
scope: string;
@ -16,6 +13,7 @@ export default async function OAuthAuthorization({
}: {
searchParams: AuthorizeParams;
}) {
// params invalid
if (
!searchParams.response_type ||
!searchParams.client_id ||
@ -24,34 +22,16 @@ export default async function OAuthAuthorization({
throw new Error("Params invalid");
}
const client = await getClient({
clientId: searchParams.client_id,
redirectUri: searchParams.redirect_uri,
});
if (!client) {
// client invalid
const client = await getClientByClientId(searchParams.client_id);
if (!client || client.redirectUri !== searchParams.redirect_uri) {
throw new Error("Client not found");
}
// Authorizing ...
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50 p-4">
<AuthorizationCard client={client} />
<Authorizing />
</div>
);
}
async function getClient({
clientId,
redirectUri,
}: {
clientId: string;
redirectUri: string;
}) {
const client = await getClientByClientId(clientId);
if (client && client.redirectUri === redirectUri) {
return client;
}
return null;
}

View File

@ -1,31 +1,31 @@
import { NextResponse } from "next/server";
import { createAccessToken } from "@/lib/dto/accessToken";
import { deleteCode, getCodeByCode } from "@/lib/dto/code";
import { deleteCode, getUnexpiredCodeByCode } from "@/lib/dto/code";
import { generateRandomKey } from "@/lib/utils";
export async function POST(req: Request) {
const formData = await req.formData();
// get code
const code = formData.get("code") as string;
if (!code) {
console.log(`code: ${code}`);
return new NextResponse("Invalid code credentials.", { status: 400 });
return new NextResponse("Invalid code params.", { status: 400 });
}
const authorizeCode = await getCodeByCode(code);
const authorizeCode = await getUnexpiredCodeByCode(code);
await deleteCode(code);
if (!authorizeCode) {
console.log(`code: ${code}`);
return new NextResponse("Invalid code credentials.", { status: 400 });
}
// verify redirect uri
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 });
}
// generate access token
const expiresIn = 3600 * 24 * 7;
const token = "tk_" + generateRandomKey();
const token = "at_" + generateRandomKey(32);
await createAccessToken({
token,
expiresAt: new Date(Date.now() + expiresIn * 1000),

View File

@ -5,6 +5,7 @@ import { getAccessTokenByToken } from "@/lib/dto/accessToken";
export async function GET(req: Request) {
const authorization = req.headers.get("Authorization");
// verify access token
const token = authorization?.slice(7); // remove "Bearer "
if (!token) {
return new NextResponse("Invalid access token (code: -1000).", {
@ -18,8 +19,8 @@ export async function GET(req: Request) {
});
}
// return user
let user = accessToken.user;
return Response.json({
id: user.id,
email: user.email,

View File

@ -1,7 +1,7 @@
import type { NextAuthConfig } from "next-auth";
import Credentials from "next-auth/providers/credentials";
import { verify } from "./lib/discourse-verify";
import { discourseCallbackVerify } from "./lib/discourse/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 verify(sso, sig);
const user = await discourseCallbackVerify(sso, sig);
return user;
},
}),

View File

@ -1,8 +1,8 @@
"use client";
import { useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { getDiscourseSSOUrl } from "@/actions/discourse-sso-url";
import { useRouter } from "next/navigation";
import { handleAuthorizeAction } from "@/actions/authorizing";
import { Client } from "@prisma/client";
import {
ChevronsDownUp,
@ -35,19 +35,29 @@ const permissions: Permission[] = [
},
];
export function AuthorizationCard({ client }: { client: Client }) {
export function AuthorizationCard({
client,
oauthParams,
}: {
client: Client;
oauthParams: string;
}) {
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());
const url = await handleAuthorizeAction(
oauthParams,
client.userId,
client.id,
permissions[0].id,
);
router.push(url);
};

View File

@ -2,41 +2,36 @@
import { useCallback, useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { handleDiscourseCallbackAction } from "@/actions/discourse-callback";
import { getDiscourseSSOUrl } from "@/actions/discourse-sso-url";
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());
const url = await getDiscourseSSOUrl(searchParams.toString());
router.push(url);
setIsLoading(false);
} catch (error) {
setError(error);
setIsLoading(false);
}
}, []);
useEffect(() => {
// Delay 3s get sso url go to ...
const timer = setTimeout(signInCallback, 3);
return () => {
clearTimeout(timer);
};
}, []);
return (
<>
{error ? (
<p className="text-center"></p>
<p className="text-center"></p>
) : (
<p className="text-center"> ...</p>
<p className="text-center"> ...</p>
)}
</>
);

View File

@ -10,7 +10,7 @@ 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) {
export async function discourseCallbackVerify(sso: string, sig: string) {
// 校验数据正确性
if (hmacSHA256(sso, DISCOUSE_SECRET).toString(Hex) != sig) {
throw new Error("Request params is invalid (code: -1001).");
@ -25,7 +25,7 @@ export async function verify(sso: string, sig: string) {
if (cookieStore.get(AUTH_NONCE)?.value != nonce) {
throw new Error("Request params is invalid (code: -1003).");
}
cookieStore.delete(AUTH_NONCE);
// cookieStore.delete(AUTH_NONCE);
const id = searchParams.get("external_id");
const email = searchParams.get("email");

View File

@ -9,8 +9,14 @@ export async function createAccessToken(
}
export async function getAccessTokenByToken(token: string) {
return prisma.accessToken.findUnique({
where: { token },
const now = new Date();
return prisma.accessToken.findFirst({
where: {
token,
expiresAt: {
gt: now,
},
},
include: { user: true },
});
}

View File

@ -0,0 +1,22 @@
import { Authorization } from "@prisma/client";
import { prisma } from "../prisma";
export async function createAuthorization(
data: Omit<Authorization, "id" | "createdAt" | "updatedAt">,
) {
await prisma.authorization.create({
data,
});
}
export async function findAuthorization(userId: string, clientId: string) {
return await prisma.authorization.findUnique({
where: {
userId_clientId: {
userId,
clientId,
},
},
});
}

View File

@ -2,17 +2,25 @@ import { Code } from "@prisma/client";
import { prisma } from "@/lib/prisma";
export async function createCode(data: Omit<Code, "id">) {
export async function createCode(data: Omit<Code, "id" | "deletedAt">) {
return prisma.code.create({ data });
}
export async function getCodeByCode(code: string) {
return prisma.code.findUnique({
where: { code },
export async function getUnexpiredCodeByCode(code: string) {
const now = new Date();
return prisma.code.findFirst({
where: {
code,
expiresAt: { gt: now },
deletedAt: null,
},
include: { client: true, user: true },
});
}
export async function deleteCode(code: string) {
await prisma.code.delete({ where: { code } });
await prisma.code.update({
where: { code },
data: { deletedAt: new Date() },
});
}

View File

@ -0,0 +1,36 @@
import "server-only";
import WordArray from "crypto-js/lib-typedarrays";
import { getClientByClientId } from "@/lib/dto/client";
import { createCode } from "@/lib/dto/code";
export async function getAuthorizeUrl(params: URLSearchParams) {
// client
const client = await getClientByClientId(params.get("client_id") as string);
if (!client) {
throw new Error("Client Id invalid (code: -1004).");
}
// redirect url
const redirect_uri = new URL(client.redirectUri);
if (params.has("state")) {
redirect_uri.searchParams.append("state", params.get("state") || "");
}
const code = WordArray.random(32).toString();
redirect_uri.searchParams.append("code", code);
// storage code
try {
await createCode({
code,
expiresAt: new Date(Date.now() + 10 * 60 * 1000),
clientId: client.id,
userId: client.userId,
});
} catch {
throw new Error("Create code error (code: -1005).");
}
return redirect_uri.toString();
}