finalized adding support for access tokens

This commit is contained in:
daniel31x13 2024-01-24 15:48:40 -05:00
parent 05563134b4
commit 5be194235c
18 changed files with 366 additions and 106 deletions

View File

@ -1,25 +1,25 @@
import React, { useState } from "react";
import TextInput from "@/components/TextInput";
import { KeyExpiry } from "@/types/global";
import { useSession } from "next-auth/react";
import { TokenExpiry } from "@/types/global";
import toast from "react-hot-toast";
import Modal from "../Modal";
import useTokenStore from "@/store/tokens";
type Props = {
onClose: Function;
};
export default function NewKeyModal({ onClose }: Props) {
const { data } = useSession();
export default function NewTokenModal({ onClose }: Props) {
const [newToken, setNewToken] = useState("");
const { addToken } = useTokenStore();
const initial = {
name: "",
expires: 0 as KeyExpiry,
expires: 0 as TokenExpiry,
};
const [key, setKey] = useState(initial as any);
const [token, setToken] = useState(initial as any);
const [submitLoader, setSubmitLoader] = useState(false);
@ -27,30 +27,18 @@ export default function NewKeyModal({ onClose }: Props) {
if (!submitLoader) {
setSubmitLoader(true);
let response;
const load = toast.loading("Creating...");
response = await fetch("/api/v1/tokens", {
method: "POST",
body: JSON.stringify({
name: key.name,
expires: key.expires,
}),
});
const data = await response.json();
const { ok, data } = await addToken(token);
toast.dismiss(load);
if (response.ok) {
if (ok) {
toast.success(`Created!`);
setNewToken(data.response);
} else toast.error(data.response as string);
setNewToken((data as any).secretKey);
} else toast.error(data as string);
setSubmitLoader(false);
return response;
}
};
@ -90,8 +78,8 @@ export default function NewKeyModal({ onClose }: Props) {
<p className="mb-2">Name</p>
<TextInput
value={key.name}
onChange={(e) => setKey({ ...key, name: e.target.value })}
value={token.name}
onChange={(e) => setToken({ ...token, name: e.target.value })}
placeholder="e.g. For the iOS shortcut"
className="bg-base-200"
/>
@ -106,11 +94,11 @@ export default function NewKeyModal({ onClose }: Props) {
role="button"
className="btn btn-outline w-36 flex items-center btn-sm h-10"
>
{key.expires === KeyExpiry.sevenDays && "7 Days"}
{key.expires === KeyExpiry.oneMonth && "30 Days"}
{key.expires === KeyExpiry.twoMonths && "60 Days"}
{key.expires === KeyExpiry.threeMonths && "90 Days"}
{key.expires === KeyExpiry.never && "No Expiration"}
{token.expires === TokenExpiry.sevenDays && "7 Days"}
{token.expires === TokenExpiry.oneMonth && "30 Days"}
{token.expires === TokenExpiry.twoMonths && "60 Days"}
{token.expires === TokenExpiry.threeMonths && "90 Days"}
{token.expires === TokenExpiry.never && "No Expiration"}
</div>
<ul className="dropdown-content z-[30] menu shadow bg-base-200 border border-neutral-content rounded-xl w-52 mt-1">
<li>
@ -123,10 +111,13 @@ export default function NewKeyModal({ onClose }: Props) {
type="radio"
name="sort-radio"
className="radio checked:bg-primary"
checked={key.expires === KeyExpiry.sevenDays}
checked={token.expires === TokenExpiry.sevenDays}
onChange={() => {
(document?.activeElement as HTMLElement)?.blur();
setKey({ ...key, expires: KeyExpiry.sevenDays });
setToken({
...token,
expires: TokenExpiry.sevenDays,
});
}}
/>
<span className="label-text">7 Days</span>
@ -142,10 +133,10 @@ export default function NewKeyModal({ onClose }: Props) {
type="radio"
name="sort-radio"
className="radio checked:bg-primary"
checked={key.expires === KeyExpiry.oneMonth}
checked={token.expires === TokenExpiry.oneMonth}
onChange={() => {
(document?.activeElement as HTMLElement)?.blur();
setKey({ ...key, expires: KeyExpiry.oneMonth });
setToken({ ...token, expires: TokenExpiry.oneMonth });
}}
/>
<span className="label-text">30 Days</span>
@ -161,10 +152,13 @@ export default function NewKeyModal({ onClose }: Props) {
type="radio"
name="sort-radio"
className="radio checked:bg-primary"
checked={key.expires === KeyExpiry.twoMonths}
checked={token.expires === TokenExpiry.twoMonths}
onChange={() => {
(document?.activeElement as HTMLElement)?.blur();
setKey({ ...key, expires: KeyExpiry.twoMonths });
setToken({
...token,
expires: TokenExpiry.twoMonths,
});
}}
/>
<span className="label-text">60 Days</span>
@ -180,10 +174,13 @@ export default function NewKeyModal({ onClose }: Props) {
type="radio"
name="sort-radio"
className="radio checked:bg-primary"
checked={key.expires === KeyExpiry.threeMonths}
checked={token.expires === TokenExpiry.threeMonths}
onChange={() => {
(document?.activeElement as HTMLElement)?.blur();
setKey({ ...key, expires: KeyExpiry.threeMonths });
setToken({
...token,
expires: TokenExpiry.threeMonths,
});
}}
/>
<span className="label-text">90 Days</span>
@ -199,10 +196,10 @@ export default function NewKeyModal({ onClose }: Props) {
type="radio"
name="sort-radio"
className="radio checked:bg-primary"
checked={key.expires === KeyExpiry.never}
checked={token.expires === TokenExpiry.never}
onChange={() => {
(document?.activeElement as HTMLElement)?.blur();
setKey({ ...key, expires: KeyExpiry.never });
setToken({ ...token, expires: TokenExpiry.never });
}}
/>
<span className="label-text">No Expiration</span>

View File

@ -0,0 +1,62 @@
import React, { useEffect, useState } from "react";
import useLinkStore from "@/store/links";
import toast from "react-hot-toast";
import Modal from "../Modal";
import { useRouter } from "next/router";
import { AccessToken } from "@prisma/client";
import useTokenStore from "@/store/tokens";
type Props = {
onClose: Function;
activeToken: AccessToken;
};
export default function DeleteTokenModal({ onClose, activeToken }: Props) {
const [token, setToken] = useState<AccessToken>(activeToken);
const { revokeToken } = useTokenStore();
const [submitLoader, setSubmitLoader] = useState(false);
const router = useRouter();
useEffect(() => {
setToken(activeToken);
}, []);
const deleteLink = async () => {
console.log(token);
const load = toast.loading("Deleting...");
const response = await revokeToken(token.id as number);
toast.dismiss(load);
response.ok && toast.success(`Token Revoked.`);
onClose();
};
return (
<Modal toggleModal={onClose}>
<p className="text-xl font-thin text-red-500">Revoke Token</p>
<div className="divider mb-3 mt-1"></div>
<div className="flex flex-col gap-3">
<p>
Are you sure you want to revoke this Access Token? Any apps or
services using this token will no longer be able to access Linkwarden
using it.
</p>
<button
className={`ml-auto btn w-fit text-white flex items-center gap-2 duration-100 bg-red-500 hover:bg-red-400 hover:dark:bg-red-600 cursor-pointer`}
onClick={deleteLink}
>
<i className="bi-trash text-xl" />
Revoke
</button>
</div>
</Modal>
);
}

View File

@ -73,7 +73,7 @@ export default function SettingsSidebar({ className }: { className?: string }) {
} duration-100 py-5 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
>
<i className="bi-key text-primary text-2xl"></i>
<p className="truncate w-full pr-7">Access Token</p>
<p className="truncate w-full pr-7">Access Tokens</p>
</div>
</Link>

View File

@ -1,14 +1,13 @@
import { prisma } from "@/lib/api/db";
import { KeyExpiry } from "@/types/global";
import bcrypt from "bcrypt";
import crypto from "crypto";
export default async function getToken(userId: number) {
const getTokens = await prisma.apiKey.findMany({
const getTokens = await prisma.accessToken.findMany({
where: {
userId,
revoked: false,
},
select: {
id: true,
name: true,
expires: true,
createdAt: true,

View File

@ -1,12 +1,12 @@
import { prisma } from "@/lib/api/db";
import { KeyExpiry } from "@/types/global";
import { TokenExpiry } from "@/types/global";
import crypto from "crypto";
import { decode, encode, getToken } from "next-auth/jwt";
import { decode, encode } from "next-auth/jwt";
export default async function postToken(
body: {
name: string;
expires: KeyExpiry;
expires: TokenExpiry;
},
userId: number
) {
@ -20,9 +20,10 @@ export default async function postToken(
status: 400,
};
const checkIfTokenExists = await prisma.apiKey.findFirst({
const checkIfTokenExists = await prisma.accessToken.findFirst({
where: {
name: body.name,
revoked: false,
userId,
},
});
@ -39,16 +40,16 @@ export default async function postToken(
const oneDayInSeconds = 86400;
let expiryDateSecond = 7 * oneDayInSeconds;
if (body.expires === KeyExpiry.oneMonth) {
if (body.expires === TokenExpiry.oneMonth) {
expiryDate.setDate(expiryDate.getDate() + 30);
expiryDateSecond = 30 * oneDayInSeconds;
} else if (body.expires === KeyExpiry.twoMonths) {
} else if (body.expires === TokenExpiry.twoMonths) {
expiryDate.setDate(expiryDate.getDate() + 60);
expiryDateSecond = 60 * oneDayInSeconds;
} else if (body.expires === KeyExpiry.threeMonths) {
} else if (body.expires === TokenExpiry.threeMonths) {
expiryDate.setDate(expiryDate.getDate() + 90);
expiryDateSecond = 90 * oneDayInSeconds;
} else if (body.expires === KeyExpiry.never) {
} else if (body.expires === TokenExpiry.never) {
expiryDate.setDate(expiryDate.getDate() + 73000); // 200 years (not really never)
expiryDateSecond = 73050 * oneDayInSeconds;
} else {
@ -72,7 +73,7 @@ export default async function postToken(
secret: process.env.NEXTAUTH_SECRET,
});
const createToken = await prisma.apiKey.create({
const createToken = await prisma.accessToken.create({
data: {
name: body.name,
userId,
@ -82,7 +83,10 @@ export default async function postToken(
});
return {
response: token,
response: {
secretKey: token,
token: createToken,
},
status: 200,
};
}

View File

@ -1,16 +1,24 @@
import { prisma } from "@/lib/api/db";
import { KeyExpiry } from "@/types/global";
export default async function deleteToken(userId: number, tokenId: number) {
if (!tokenId)
return { response: "Please choose a valid token.", status: 401 };
const deletedToken = await prisma.apiKey.delete({
const tokenExists = await prisma.accessToken.findFirst({
where: {
id: tokenId,
userId,
},
});
return { response: deletedToken, status: 200 };
const revokedToken = await prisma.accessToken.update({
where: {
id: tokenExists?.id,
},
data: {
revoked: true,
},
});
return { response: revokedToken, status: 200 };
}

36
lib/api/verifyToken.ts Normal file
View File

@ -0,0 +1,36 @@
import { NextApiRequest } from "next";
import { JWT, getToken } from "next-auth/jwt";
import { prisma } from "./db";
type Props = {
req: NextApiRequest;
};
export default async function verifyToken({
req,
}: Props): Promise<JWT | string> {
const token = await getToken({ req });
const userId = token?.id;
if (!userId) {
return "You must be logged in.";
}
if (token.exp < Date.now() / 1000) {
return "Your session has expired, please log in again.";
}
// check if token is revoked
const revoked = await prisma.accessToken.findFirst({
where: {
token: token.jti,
revoked: true,
},
});
if (revoked) {
return "Your session has expired, please log in again.";
}
return token;
}

View File

@ -1,8 +1,8 @@
import { NextApiRequest, NextApiResponse } from "next";
import { getToken } from "next-auth/jwt";
import { prisma } from "./db";
import { User } from "@prisma/client";
import verifySubscription from "./verifySubscription";
import verifyToken from "./verifyToken";
type Props = {
req: NextApiRequest;
@ -15,21 +15,15 @@ export default async function verifyUser({
req,
res,
}: Props): Promise<User | null> {
const token = await getToken({ req });
const token = await verifyToken({ req });
if (typeof token === "string") {
res.status(401).json({ response: token });
return null;
}
const userId = token?.id;
if (!userId) {
res.status(401).json({ response: "You must be logged in." });
return null;
}
if (token.exp < Date.now() / 1000) {
res
.status(401)
.json({ response: "Your session has expired, please log in again." });
return null;
}
const user = await prisma.user.findUnique({
where: {
id: userId,

View File

@ -1,6 +1,5 @@
import type { NextApiRequest, NextApiResponse } from "next";
import readFile from "@/lib/api/storage/readFile";
import { getToken } from "next-auth/jwt";
import { prisma } from "@/lib/api/db";
import { ArchivedFormat } from "@/types/global";
import verifyUser from "@/lib/api/verifyUser";
@ -9,6 +8,7 @@ import { UsersAndCollections } from "@prisma/client";
import formidable from "formidable";
import createFile from "@/lib/api/storage/createFile";
import fs from "fs";
import verifyToken from "@/lib/api/verifyToken";
export const config = {
api: {
@ -33,8 +33,8 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) {
return res.status(401).json({ response: "Invalid parameters." });
if (req.method === "GET") {
const token = await getToken({ req });
const userId = token?.id;
const token = await verifyToken({ req });
const userId = typeof token === "string" ? undefined : token?.id;
const collectionIsAccessible = await prisma.collection.findFirst({
where: {

View File

@ -1,7 +1,7 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { prisma } from "@/lib/api/db";
import readFile from "@/lib/api/storage/readFile";
import { getToken } from "next-auth/jwt";
import verifyToken from "@/lib/api/verifyToken";
export default async function Index(req: NextApiRequest, res: NextApiResponse) {
const queryId = Number(req.query.id);
@ -12,8 +12,8 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) {
.status(401)
.send("Invalid parameters.");
const token = await getToken({ req });
const userId = token?.id;
const token = await verifyToken({ req });
const userId = typeof token === "string" ? undefined : token?.id;
if (req.method === "GET") {
const targetUser = await prisma.user.findUnique({

View File

@ -1,10 +1,10 @@
import type { NextApiRequest, NextApiResponse } from "next";
import getPublicUser from "@/lib/api/controllers/public/users/getPublicUser";
import { getToken } from "next-auth/jwt";
import verifyToken from "@/lib/api/verifyToken";
export default async function users(req: NextApiRequest, res: NextApiResponse) {
const token = await getToken({ req });
const requestingId = token?.id;
const token = await verifyToken({ req });
const requestingId = typeof token === "string" ? undefined : token?.id;
const lookupId = req.query.id as string;

View File

@ -2,26 +2,22 @@ import type { NextApiRequest, NextApiResponse } from "next";
import getUserById from "@/lib/api/controllers/users/userId/getUserById";
import updateUserById from "@/lib/api/controllers/users/userId/updateUserById";
import deleteUserById from "@/lib/api/controllers/users/userId/deleteUserById";
import { getToken } from "next-auth/jwt";
import { prisma } from "@/lib/api/db";
import verifySubscription from "@/lib/api/verifySubscription";
import verifyToken from "@/lib/api/verifyToken";
const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY;
export default async function users(req: NextApiRequest, res: NextApiResponse) {
const token = await getToken({ req });
const token = await verifyToken({ req });
if (typeof token === "string") {
res.status(401).json({ response: token });
return null;
}
const userId = token?.id;
if (!userId) {
return res.status(401).json({ response: "You must be logged in." });
}
if (token.exp < Date.now() / 1000) {
return res
.status(401)
.json({ response: "Your session has expired, please log in again." });
}
if (userId !== Number(req.query.id))
return res.status(401).json({ response: "Permission denied." });

View File

@ -1,13 +1,33 @@
import SettingsLayout from "@/layouts/SettingsLayout";
import React, { useState } from "react";
import NewKeyModal from "@/components/ModalContent/NewKeyModal";
import React, { useEffect, useState } from "react";
import NewTokenModal from "@/components/ModalContent/NewTokenModal";
import RevokeTokenModal from "@/components/ModalContent/RevokeTokenModal";
import { AccessToken } from "@prisma/client";
import useTokenStore from "@/store/tokens";
export default function Api() {
const [newKeyModal, setNewKeyModal] = useState(false);
export default function AccessTokens() {
const [newTokenModal, setNewTokenModal] = useState(false);
const [revokeTokenModal, setRevokeTokenModal] = useState(false);
const [selectedToken, setSelectedToken] = useState<AccessToken | null>(null);
const openRevokeModal = (token: AccessToken) => {
setSelectedToken(token);
setRevokeTokenModal(true);
};
const { setTokens, tokens } = useTokenStore();
useEffect(() => {
fetch("/api/v1/tokens")
.then((res) => res.json())
.then((data) => {
if (data.response) setTokens(data.response as AccessToken[]);
});
}, []);
return (
<SettingsLayout>
<p className="capitalize text-3xl font-thin inline">API Keys</p>
<p className="capitalize text-3xl font-thin inline">Access Tokens</p>
<div className="divider my-3"></div>
@ -20,16 +40,68 @@ export default function Api() {
<button
className={`btn btn-accent dark:border-violet-400 text-white tracking-wider w-fit flex items-center gap-2`}
onClick={() => {
setNewKeyModal(true);
setNewTokenModal(true);
}}
>
New Access Token
</button>
{tokens.length > 0 ? (
<>
<div className="divider"></div>
<table className="table">
{/* head */}
<thead>
<tr>
<th></th>
<th>Name</th>
<th>Created</th>
<th>Expires</th>
<th></th>
</tr>
</thead>
<tbody>
{tokens.map((token, i) => (
<React.Fragment key={i}>
<tr>
<th>{i + 1}</th>
<td>{token.name}</td>
<td>
{new Date(token.createdAt || "").toLocaleDateString()}
</td>
<td>
{new Date(token.expires || "").toLocaleDateString()}
</td>
<td>
<button
className="btn btn-sm btn-ghost btn-square hover:bg-red-500"
onClick={() => openRevokeModal(token as AccessToken)}
>
<i className="bi-x text-lg"></i>
</button>
</td>
</tr>
</React.Fragment>
))}
</tbody>
</table>
</>
) : undefined}
</div>
{newKeyModal ? (
<NewKeyModal onClose={() => setNewKeyModal(false)} />
{newTokenModal ? (
<NewTokenModal onClose={() => setNewTokenModal(false)} />
) : undefined}
{revokeTokenModal && selectedToken && (
<RevokeTokenModal
onClose={() => {
setRevokeTokenModal(false);
setSelectedToken(null);
}}
activeToken={selectedToken}
/>
)}
</SettingsLayout>
);
}

View File

@ -0,0 +1,35 @@
/*
Warnings:
- You are about to drop the `ApiKey` table. If the table is not empty, all the data it contains will be lost.
*/
-- DropForeignKey
ALTER TABLE "ApiKey" DROP CONSTRAINT "ApiKey_userId_fkey";
-- DropTable
DROP TABLE "ApiKey";
-- CreateTable
CREATE TABLE "AccessToken" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"userId" INTEGER NOT NULL,
"token" TEXT NOT NULL,
"revoked" BOOLEAN NOT NULL DEFAULT false,
"expires" TIMESTAMP(3) NOT NULL,
"lastUsedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AccessToken_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "AccessToken_token_key" ON "AccessToken"("token");
-- CreateIndex
CREATE UNIQUE INDEX "AccessToken_name_userId_key" ON "AccessToken"("name", "userId");
-- AddForeignKey
ALTER TABLE "AccessToken" ADD CONSTRAINT "AccessToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -0,0 +1,2 @@
-- DropIndex
DROP INDEX "AccessToken_name_userId_key";

View File

@ -39,7 +39,7 @@ model User {
pinnedLinks Link[]
collectionsJoined UsersAndCollections[]
whitelistedUsers WhitelistedUser[]
apiKeys ApiKey[]
accessTokens AccessToken[]
subscriptions Subscription?
archiveAsScreenshot Boolean @default(true)
archiveAsPDF Boolean @default(true)
@ -142,16 +142,15 @@ model Subscription {
updatedAt DateTime @default(now()) @updatedAt
}
model ApiKey {
model AccessToken {
id Int @id @default(autoincrement())
name String
user User @relation(fields: [userId], references: [id])
userId Int
token String @unique
revoked Boolean @default(false)
expires DateTime
lastUsedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
@@unique([name, userId])
}

56
store/tokens.ts Normal file
View File

@ -0,0 +1,56 @@
import { AccessToken } from "@prisma/client";
import { create } from "zustand";
// Token store
type ResponseObject = {
ok: boolean;
data: object | string;
};
type TokenStore = {
tokens: Partial<AccessToken>[];
setTokens: (data: Partial<AccessToken>[]) => void;
addToken: (body: Partial<AccessToken>[]) => Promise<ResponseObject>;
revokeToken: (tokenId: number) => Promise<ResponseObject>;
};
const useTokenStore = create<TokenStore>((set) => ({
tokens: [],
setTokens: async (data) => {
set(() => ({
tokens: data,
}));
},
addToken: async (body) => {
const response = await fetch("/api/v1/tokens", {
body: JSON.stringify(body),
method: "POST",
});
const data = await response.json();
if (response.ok)
set((state) => ({
tokens: [...state.tokens, data.response.token],
}));
return { ok: response.ok, data: data.response };
},
revokeToken: async (tokenId) => {
const response = await fetch(`/api/v1/tokens/${tokenId}`, {
method: "DELETE",
});
const data = await response.json();
if (response.ok)
set((state) => ({
tokens: state.tokens.filter((token) => token.id !== tokenId),
}));
return { ok: response.ok, data: data.response };
},
}));
export default useTokenStore;

View File

@ -135,7 +135,7 @@ export enum LinkType {
image,
}
export enum KeyExpiry {
export enum TokenExpiry {
sevenDays,
oneMonth,
twoMonths,