diff --git a/prisma/migrations/20240908121115_init/migration.sql b/prisma/migrations/20240915095258_init/migration.sql similarity index 75% rename from prisma/migrations/20240908121115_init/migration.sql rename to prisma/migrations/20240915095258_init/migration.sql index d42aa2c..306746a 100644 --- a/prisma/migrations/20240908121115_init/migration.sql +++ b/prisma/migrations/20240915095258_init/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f447b6e..4742646 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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") +} diff --git a/src/actions/authorizing.ts b/src/actions/authorizing.ts new file mode 100644 index 0000000..8c789b7 --- /dev/null +++ b/src/actions/authorizing.ts @@ -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; +} diff --git a/src/actions/discourse-callback.ts b/src/actions/discourse-callback.ts deleted file mode 100644 index 3227fe0..0000000 --- a/src/actions/discourse-callback.ts +++ /dev/null @@ -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(); -} diff --git a/src/app/(oauth)/discourse/callback/page.tsx b/src/app/(oauth)/discourse/callback/page.tsx index 91f57f7..3059649 100644 --- a/src/app/(oauth)/discourse/callback/page.tsx +++ b/src/app/(oauth)/discourse/callback/page.tsx @@ -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 { + 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 (
- +
diff --git a/src/app/(oauth)/oauth/authorize/page.tsx b/src/app/(oauth)/oauth/authorize/page.tsx index 591104d..700ef60 100644 --- a/src/app/(oauth)/oauth/authorize/page.tsx +++ b/src/app/(oauth)/oauth/authorize/page.tsx @@ -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 { 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 (
- +
); } - -async function getClient({ - clientId, - redirectUri, -}: { - clientId: string; - redirectUri: string; -}) { - const client = await getClientByClientId(clientId); - - if (client && client.redirectUri === redirectUri) { - return client; - } - - return null; -} diff --git a/src/app/api/oauth/access_token/route.ts b/src/app/api/oauth/access_token/route.ts index a7d2684..d6a87ea 100644 --- a/src/app/api/oauth/access_token/route.ts +++ b/src/app/api/oauth/access_token/route.ts @@ -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), diff --git a/src/app/api/oauth/user/route.ts b/src/app/api/oauth/user/route.ts index 873c1bc..4978fa8 100644 --- a/src/app/api/oauth/user/route.ts +++ b/src/app/api/oauth/user/route.ts @@ -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, diff --git a/src/auth.config.ts b/src/auth.config.ts index 8c7516b..b93490c 100644 --- a/src/auth.config.ts +++ b/src/auth.config.ts @@ -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; }, }), diff --git a/src/components/auth/authorization-card.tsx b/src/components/auth/authorization-card.tsx index 19cf9a4..be4e41a 100644 --- a/src/components/auth/authorization-card.tsx +++ b/src/components/auth/authorization-card.tsx @@ -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( 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); }; diff --git a/src/components/auth/authorizing.tsx b/src/components/auth/authorizing.tsx index 8440402..c907ca9 100644 --- a/src/components/auth/authorizing.tsx +++ b/src/components/auth/authorizing.tsx @@ -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(false); const [error, setError] = useState(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 ? ( -

登录异常,授权失败!

+

授权异常,登录失败!

) : ( -

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

+

获取授权信息,等待跳转中,请稍等...

)} ); diff --git a/src/lib/discourse-verify.ts b/src/lib/discourse/verify.ts similarity index 94% rename from src/lib/discourse-verify.ts rename to src/lib/discourse/verify.ts index 35c1ad4..07604a3 100644 --- a/src/lib/discourse-verify.ts +++ b/src/lib/discourse/verify.ts @@ -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"); diff --git a/src/lib/dto/accessToken.ts b/src/lib/dto/accessToken.ts index dc7b304..fd8d9b5 100644 --- a/src/lib/dto/accessToken.ts +++ b/src/lib/dto/accessToken.ts @@ -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 }, }); } diff --git a/src/lib/dto/authorization.ts b/src/lib/dto/authorization.ts new file mode 100644 index 0000000..4896312 --- /dev/null +++ b/src/lib/dto/authorization.ts @@ -0,0 +1,22 @@ +import { Authorization } from "@prisma/client"; + +import { prisma } from "../prisma"; + +export async function createAuthorization( + data: Omit, +) { + await prisma.authorization.create({ + data, + }); +} + +export async function findAuthorization(userId: string, clientId: string) { + return await prisma.authorization.findUnique({ + where: { + userId_clientId: { + userId, + clientId, + }, + }, + }); +} diff --git a/src/lib/dto/code.ts b/src/lib/dto/code.ts index 92d80b1..885e4b5 100644 --- a/src/lib/dto/code.ts +++ b/src/lib/dto/code.ts @@ -2,17 +2,25 @@ import { Code } from "@prisma/client"; import { prisma } from "@/lib/prisma"; -export async function createCode(data: Omit) { +export async function createCode(data: Omit) { 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() }, + }); } diff --git a/src/lib/oauth/authorize-url.ts b/src/lib/oauth/authorize-url.ts new file mode 100644 index 0000000..7b8c948 --- /dev/null +++ b/src/lib/oauth/authorize-url.ts @@ -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(); +}