From d91ebb3fa2aced31d256448786f5e88912b90f46 Mon Sep 17 00:00:00 2001 From: daniel31x13 Date: Sat, 13 Jan 2024 01:20:06 -0500 Subject: [PATCH 1/3] added post key route --- components/ModalContent/NewKeyModal.tsx | 199 ++++++++++++++++++ components/SettingsSidebar.tsx | 6 +- lib/api/controllers/tokens/getTokens.ts | 22 ++ lib/api/controllers/tokens/postToken.ts | 77 +++++++ .../tokens/tokenId/deleteTokenById.ts | 16 ++ pages/api/v1/tokens/[id].ts | 13 ++ pages/api/v1/tokens/index.ts | 20 ++ pages/settings/access-tokens.tsx | 35 +++ pages/settings/api.tsx | 78 ------- .../migration.sql | 8 + .../20240113060555_minor_fix/migration.sql | 14 ++ prisma/schema.prisma | 4 +- types/global.ts | 8 + 13 files changed, 417 insertions(+), 83 deletions(-) create mode 100644 components/ModalContent/NewKeyModal.tsx create mode 100644 lib/api/controllers/tokens/getTokens.ts create mode 100644 lib/api/controllers/tokens/postToken.ts create mode 100644 lib/api/controllers/tokens/tokenId/deleteTokenById.ts create mode 100644 pages/api/v1/tokens/[id].ts create mode 100644 pages/api/v1/tokens/index.ts create mode 100644 pages/settings/access-tokens.tsx delete mode 100644 pages/settings/api.tsx create mode 100644 prisma/migrations/20240113051701_make_key_names_unique/migration.sql create mode 100644 prisma/migrations/20240113060555_minor_fix/migration.sql diff --git a/components/ModalContent/NewKeyModal.tsx b/components/ModalContent/NewKeyModal.tsx new file mode 100644 index 0000000..e36f724 --- /dev/null +++ b/components/ModalContent/NewKeyModal.tsx @@ -0,0 +1,199 @@ +import React, { useState } from "react"; +import TextInput from "@/components/TextInput"; +import { KeyExpiry } from "@/types/global"; +import { useSession } from "next-auth/react"; +import toast from "react-hot-toast"; +import Modal from "../Modal"; + +type Props = { + onClose: Function; +}; + +export default function NewKeyModal({ onClose }: Props) { + const { data } = useSession(); + + const initial = { + name: "", + expires: 0 as KeyExpiry, + }; + + const [key, setKey] = useState(initial as any); + + const [submitLoader, setSubmitLoader] = useState(false); + + const submit = async () => { + 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(); + + toast.dismiss(load); + + if (response.ok) { + toast.success(`Created!`); + onClose(); + } else toast.error(data.response as string); + + setSubmitLoader(false); + + return response; + } + }; + + return ( + +

Create an Access Token

+ +
+ +
+
+

Name

+ + setKey({ ...key, name: e.target.value })} + placeholder="e.g. For the Mobile App" + className="bg-base-200" + /> +
+ +
+

Date of Expiry

+ +
+
+ {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"} +
+
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+
+
+
+ +
+ +
+
+ ); +} diff --git a/components/SettingsSidebar.tsx b/components/SettingsSidebar.tsx index d4558f8..3ffbc5f 100644 --- a/components/SettingsSidebar.tsx +++ b/components/SettingsSidebar.tsx @@ -64,16 +64,16 @@ export default function SettingsSidebar({ className }: { className?: string }) { - +
-

API Keys

+

Access Token

diff --git a/lib/api/controllers/tokens/getTokens.ts b/lib/api/controllers/tokens/getTokens.ts new file mode 100644 index 0000000..d525ac7 --- /dev/null +++ b/lib/api/controllers/tokens/getTokens.ts @@ -0,0 +1,22 @@ +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({ + where: { + userId, + }, + select: { + name: true, + expires: true, + createdAt: true, + }, + }); + + return { + response: getTokens, + status: 200, + }; +} diff --git a/lib/api/controllers/tokens/postToken.ts b/lib/api/controllers/tokens/postToken.ts new file mode 100644 index 0000000..24a8cba --- /dev/null +++ b/lib/api/controllers/tokens/postToken.ts @@ -0,0 +1,77 @@ +import { prisma } from "@/lib/api/db"; +import { KeyExpiry } from "@/types/global"; +import bcrypt from "bcrypt"; +import crypto from "crypto"; + +export default async function postToken( + body: { + name: string; + expires: KeyExpiry; + }, + userId: number +) { + console.log(body); + + const checkHasEmptyFields = !body.name || body.expires === undefined; + + if (checkHasEmptyFields) + return { + response: "Please fill out all the fields.", + status: 400, + }; + + const checkIfTokenExists = await prisma.apiKey.findFirst({ + where: { + name: body.name, + userId, + }, + }); + + if (checkIfTokenExists) { + return { + response: "Token with that name already exists.", + status: 400, + }; + } + + let expiryDate = new Date(); + + switch (body.expires) { + case KeyExpiry.sevenDays: + expiryDate.setDate(expiryDate.getDate() + 7); + break; + case KeyExpiry.oneMonth: + expiryDate.setDate(expiryDate.getDate() + 30); + break; + case KeyExpiry.twoMonths: + expiryDate.setDate(expiryDate.getDate() + 60); + break; + case KeyExpiry.threeMonths: + expiryDate.setDate(expiryDate.getDate() + 90); + break; + case KeyExpiry.never: + expiryDate.setDate(expiryDate.getDate() + 73000); // 200 years (not really never) + break; + default: + expiryDate.setDate(expiryDate.getDate() + 7); + break; + } + + const saltRounds = 10; + + const hashedKey = bcrypt.hashSync(crypto.randomUUID(), saltRounds); + + const createToken = await prisma.apiKey.create({ + data: { + name: body.name, + userId, + token: hashedKey, + expires: expiryDate, + }, + }); + + return { + response: createToken.token, + status: 200, + }; +} diff --git a/lib/api/controllers/tokens/tokenId/deleteTokenById.ts b/lib/api/controllers/tokens/tokenId/deleteTokenById.ts new file mode 100644 index 0000000..742016f --- /dev/null +++ b/lib/api/controllers/tokens/tokenId/deleteTokenById.ts @@ -0,0 +1,16 @@ +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({ + where: { + id: tokenId, + userId, + }, + }); + + return { response: deletedToken, status: 200 }; +} diff --git a/pages/api/v1/tokens/[id].ts b/pages/api/v1/tokens/[id].ts new file mode 100644 index 0000000..6c2e51b --- /dev/null +++ b/pages/api/v1/tokens/[id].ts @@ -0,0 +1,13 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import verifyUser from "@/lib/api/verifyUser"; +import deleteToken from "@/lib/api/controllers/tokens/tokenId/deleteTokenById"; + +export default async function token(req: NextApiRequest, res: NextApiResponse) { + const user = await verifyUser({ req, res }); + if (!user) return; + + if (req.method === "DELETE") { + const deleted = await deleteToken(user.id, Number(req.query.id) as number); + return res.status(deleted.status).json({ response: deleted.response }); + } +} diff --git a/pages/api/v1/tokens/index.ts b/pages/api/v1/tokens/index.ts new file mode 100644 index 0000000..8af63fb --- /dev/null +++ b/pages/api/v1/tokens/index.ts @@ -0,0 +1,20 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import verifyUser from "@/lib/api/verifyUser"; +import postToken from "@/lib/api/controllers/tokens/postToken"; +import getTokens from "@/lib/api/controllers/tokens/getTokens"; + +export default async function tokens( + req: NextApiRequest, + res: NextApiResponse +) { + const user = await verifyUser({ req, res }); + if (!user) return; + + if (req.method === "POST") { + const token = await postToken(JSON.parse(req.body), user.id); + return res.status(token.status).json({ response: token.response }); + } else if (req.method === "GET") { + const token = await getTokens(user.id); + return res.status(token.status).json({ response: token.response }); + } +} diff --git a/pages/settings/access-tokens.tsx b/pages/settings/access-tokens.tsx new file mode 100644 index 0000000..14912a2 --- /dev/null +++ b/pages/settings/access-tokens.tsx @@ -0,0 +1,35 @@ +import SettingsLayout from "@/layouts/SettingsLayout"; +import React, { useState } from "react"; +import NewKeyModal from "@/components/ModalContent/NewKeyModal"; + +export default function Api() { + const [newKeyModal, setNewKeyModal] = useState(false); + + return ( + +

API Keys

+ +
+ +
+

+ Access Tokens can be used to access Linkwarden from other apps and + services without giving away your Username and Password. +

+ + +
+ + {newKeyModal ? ( + setNewKeyModal(false)} /> + ) : undefined} +
+ ); +} diff --git a/pages/settings/api.tsx b/pages/settings/api.tsx deleted file mode 100644 index dc4bb9a..0000000 --- a/pages/settings/api.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import Checkbox from "@/components/Checkbox"; -import SubmitButton from "@/components/SubmitButton"; -import SettingsLayout from "@/layouts/SettingsLayout"; -import React, { useEffect, useState } from "react"; -import useAccountStore from "@/store/account"; -import { toast } from "react-hot-toast"; -import { AccountSettings } from "@/types/global"; -import TextInput from "@/components/TextInput"; - -export default function Api() { - const [submitLoader, setSubmitLoader] = useState(false); - const { account, updateAccount } = useAccountStore(); - const [user, setUser] = useState(account); - - const [archiveAsScreenshot, setArchiveAsScreenshot] = - useState(false); - const [archiveAsPDF, setArchiveAsPDF] = useState(false); - const [archiveAsWaybackMachine, setArchiveAsWaybackMachine] = - useState(false); - - useEffect(() => { - setUser({ - ...account, - archiveAsScreenshot, - archiveAsPDF, - archiveAsWaybackMachine, - }); - }, [account, archiveAsScreenshot, archiveAsPDF, archiveAsWaybackMachine]); - - function objectIsEmpty(obj: object) { - return Object.keys(obj).length === 0; - } - - useEffect(() => { - if (!objectIsEmpty(account)) { - setArchiveAsScreenshot(account.archiveAsScreenshot); - setArchiveAsPDF(account.archiveAsPDF); - setArchiveAsWaybackMachine(account.archiveAsWaybackMachine); - } - }, [account]); - - const submit = async () => { - // setSubmitLoader(true); - // const load = toast.loading("Applying..."); - // const response = await updateAccount({ - // ...user, - // }); - // toast.dismiss(load); - // if (response.ok) { - // toast.success("Settings Applied!"); - // } else toast.error(response.data as string); - // setSubmitLoader(false); - }; - - return ( - -

API Keys (Soon)

- -
- -
-
- Status: Under Development -
- -

This page will be for creating and managing your API keys.

- -

- For now, you can temporarily use your{" "} - - next-auth.session-token - {" "} - in your browser cookies as the API key for your integrations. -

-
-
- ); -} diff --git a/prisma/migrations/20240113051701_make_key_names_unique/migration.sql b/prisma/migrations/20240113051701_make_key_names_unique/migration.sql new file mode 100644 index 0000000..55efb95 --- /dev/null +++ b/prisma/migrations/20240113051701_make_key_names_unique/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - A unique constraint covering the columns `[name]` on the table `ApiKey` will be added. If there are existing duplicate values, this will fail. + +*/ +-- CreateIndex +CREATE UNIQUE INDEX "ApiKey_name_key" ON "ApiKey"("name"); diff --git a/prisma/migrations/20240113060555_minor_fix/migration.sql b/prisma/migrations/20240113060555_minor_fix/migration.sql new file mode 100644 index 0000000..d3999b6 --- /dev/null +++ b/prisma/migrations/20240113060555_minor_fix/migration.sql @@ -0,0 +1,14 @@ +/* + Warnings: + + - A unique constraint covering the columns `[name,userId]` on the table `ApiKey` will be added. If there are existing duplicate values, this will fail. + +*/ +-- DropIndex +DROP INDEX "ApiKey_name_key"; + +-- DropIndex +DROP INDEX "ApiKey_token_userId_key"; + +-- CreateIndex +CREATE UNIQUE INDEX "ApiKey_name_userId_key" ON "ApiKey"("name", "userId"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3f10539..60a03e1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -144,7 +144,7 @@ model Subscription { model ApiKey { id Int @id @default(autoincrement()) - name String + name String user User @relation(fields: [userId], references: [id]) userId Int token String @unique @@ -153,5 +153,5 @@ model ApiKey { createdAt DateTime @default(now()) updatedAt DateTime @default(now()) @updatedAt - @@unique([token, userId]) + @@unique([name, userId]) } diff --git a/types/global.ts b/types/global.ts index 8b3efcf..36e38c1 100644 --- a/types/global.ts +++ b/types/global.ts @@ -134,3 +134,11 @@ export enum LinkType { pdf, image, } + +export enum KeyExpiry { + sevenDays, + oneMonth, + twoMonths, + threeMonths, + never, +} From 05563134b4b793f7feed76aca37db6aef310d835 Mon Sep 17 00:00:00 2001 From: daniel31x13 Date: Wed, 24 Jan 2024 12:51:16 -0500 Subject: [PATCH 2/3] finished access token creation feature --- components/ModalContent/NewKeyModal.tsx | 297 +++++++++++++----------- components/TextInput.tsx | 3 + lib/api/controllers/tokens/postToken.ts | 59 +++-- lib/api/verifyUser.ts | 7 + pages/api/v1/auth/[...nextauth].ts | 101 ++++---- pages/api/v1/users/[id].ts | 6 + 6 files changed, 265 insertions(+), 208 deletions(-) diff --git a/components/ModalContent/NewKeyModal.tsx b/components/ModalContent/NewKeyModal.tsx index e36f724..e058561 100644 --- a/components/ModalContent/NewKeyModal.tsx +++ b/components/ModalContent/NewKeyModal.tsx @@ -12,6 +12,8 @@ type Props = { export default function NewKeyModal({ onClose }: Props) { const { data } = useSession(); + const [newToken, setNewToken] = useState(""); + const initial = { name: "", expires: 0 as KeyExpiry, @@ -43,7 +45,7 @@ export default function NewKeyModal({ onClose }: Props) { if (response.ok) { toast.success(`Created!`); - onClose(); + setNewToken(data.response); } else toast.error(data.response as string); setSubmitLoader(false); @@ -54,146 +56,173 @@ export default function NewKeyModal({ onClose }: Props) { return ( -

Create an Access Token

- -
- -
-
-

Name

- + {newToken ? ( +
+

Access Token Created

+

+ Your new token has been created. Please copy it and store it + somewhere safe. You will not be able to see it again. +

setKey({ ...key, name: e.target.value })} - placeholder="e.g. For the Mobile App" - className="bg-base-200" + spellCheck={false} + value={newToken} + onChange={() => {}} + className="w-full" /> +
+ ) : ( + <> +

Create an Access Token

-
-

Date of Expiry

+
-
-
- {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"} +
+
+

Name

+ + setKey({ ...key, name: e.target.value })} + placeholder="e.g. For the iOS shortcut" + className="bg-base-200" + />
-
    -
  • - -
  • -
  • - -
  • -
  • - -
  • -
  • - -
  • -
  • - -
  • -
-
-
-
-
- -
+
+

Expires in

+ +
+
+ {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"} +
+
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+
+
+
+ +
+ +
+ + )} ); } diff --git a/components/TextInput.tsx b/components/TextInput.tsx index 146a15c..c8099d0 100644 --- a/components/TextInput.tsx +++ b/components/TextInput.tsx @@ -8,6 +8,7 @@ type Props = { onChange: ChangeEventHandler; onKeyDown?: KeyboardEventHandler | undefined; className?: string; + spellCheck?: boolean; }; export default function TextInput({ @@ -18,9 +19,11 @@ export default function TextInput({ onChange, onKeyDown, className, + spellCheck, }: Props) { return ( Date: Wed, 24 Jan 2024 15:48:40 -0500 Subject: [PATCH 3/3] finalized adding support for access tokens --- .../{NewKeyModal.tsx => NewTokenModal.tsx} | 77 ++++++++-------- components/ModalContent/RevokeTokenModal.tsx | 62 +++++++++++++ components/SettingsSidebar.tsx | 2 +- lib/api/controllers/tokens/getTokens.ts | 7 +- lib/api/controllers/tokens/postToken.ts | 24 ++--- .../tokens/tokenId/deleteTokenById.ts | 14 ++- lib/api/verifyToken.ts | 36 ++++++++ lib/api/verifyUser.ts | 22 ++--- pages/api/v1/archives/[linkId].ts | 6 +- pages/api/v1/avatar/[id].ts | 6 +- pages/api/v1/public/users/[id].ts | 6 +- pages/api/v1/users/[id].ts | 20 ++--- pages/settings/access-tokens.tsx | 88 +++++++++++++++++-- .../migration.sql | 35 ++++++++ .../migration.sql | 2 + prisma/schema.prisma | 7 +- store/tokens.ts | 56 ++++++++++++ types/global.ts | 2 +- 18 files changed, 366 insertions(+), 106 deletions(-) rename components/ModalContent/{NewKeyModal.tsx => NewTokenModal.tsx} (75%) create mode 100644 components/ModalContent/RevokeTokenModal.tsx create mode 100644 lib/api/verifyToken.ts create mode 100644 prisma/migrations/20240124192212_added_revoke_field/migration.sql create mode 100644 prisma/migrations/20240124201018_removed_name_unique_constraint/migration.sql create mode 100644 store/tokens.ts diff --git a/components/ModalContent/NewKeyModal.tsx b/components/ModalContent/NewTokenModal.tsx similarity index 75% rename from components/ModalContent/NewKeyModal.tsx rename to components/ModalContent/NewTokenModal.tsx index e058561..15887d8 100644 --- a/components/ModalContent/NewKeyModal.tsx +++ b/components/ModalContent/NewTokenModal.tsx @@ -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) {

Name

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"}
  • @@ -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, + }); }} /> 7 Days @@ -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 }); }} /> 30 Days @@ -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, + }); }} /> 60 Days @@ -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, + }); }} /> 90 Days @@ -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 }); }} /> No Expiration diff --git a/components/ModalContent/RevokeTokenModal.tsx b/components/ModalContent/RevokeTokenModal.tsx new file mode 100644 index 0000000..9aef2d9 --- /dev/null +++ b/components/ModalContent/RevokeTokenModal.tsx @@ -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(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 ( + +

    Revoke Token

    + +
    + +
    +

    + 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. +

    + + +
    +
    + ); +} diff --git a/components/SettingsSidebar.tsx b/components/SettingsSidebar.tsx index 3ffbc5f..bcd1644 100644 --- a/components/SettingsSidebar.tsx +++ b/components/SettingsSidebar.tsx @@ -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`} > -

    Access Token

    +

    Access Tokens

diff --git a/lib/api/controllers/tokens/getTokens.ts b/lib/api/controllers/tokens/getTokens.ts index d525ac7..a5db351 100644 --- a/lib/api/controllers/tokens/getTokens.ts +++ b/lib/api/controllers/tokens/getTokens.ts @@ -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, diff --git a/lib/api/controllers/tokens/postToken.ts b/lib/api/controllers/tokens/postToken.ts index b6fb1c1..f88030d 100644 --- a/lib/api/controllers/tokens/postToken.ts +++ b/lib/api/controllers/tokens/postToken.ts @@ -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, }; } diff --git a/lib/api/controllers/tokens/tokenId/deleteTokenById.ts b/lib/api/controllers/tokens/tokenId/deleteTokenById.ts index 742016f..ea17f1f 100644 --- a/lib/api/controllers/tokens/tokenId/deleteTokenById.ts +++ b/lib/api/controllers/tokens/tokenId/deleteTokenById.ts @@ -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 }; } diff --git a/lib/api/verifyToken.ts b/lib/api/verifyToken.ts new file mode 100644 index 0000000..1c1abfc --- /dev/null +++ b/lib/api/verifyToken.ts @@ -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 { + 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; +} diff --git a/lib/api/verifyUser.ts b/lib/api/verifyUser.ts index 8c58143..847bdaf 100644 --- a/lib/api/verifyUser.ts +++ b/lib/api/verifyUser.ts @@ -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 { - 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, diff --git a/pages/api/v1/archives/[linkId].ts b/pages/api/v1/archives/[linkId].ts index 3734692..b13e690 100644 --- a/pages/api/v1/archives/[linkId].ts +++ b/pages/api/v1/archives/[linkId].ts @@ -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: { diff --git a/pages/api/v1/avatar/[id].ts b/pages/api/v1/avatar/[id].ts index 8c591ff..c39f420 100644 --- a/pages/api/v1/avatar/[id].ts +++ b/pages/api/v1/avatar/[id].ts @@ -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({ diff --git a/pages/api/v1/public/users/[id].ts b/pages/api/v1/public/users/[id].ts index f5b66ed..f34671d 100644 --- a/pages/api/v1/public/users/[id].ts +++ b/pages/api/v1/public/users/[id].ts @@ -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; diff --git a/pages/api/v1/users/[id].ts b/pages/api/v1/users/[id].ts index 2dd808a..cf75e16 100644 --- a/pages/api/v1/users/[id].ts +++ b/pages/api/v1/users/[id].ts @@ -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." }); diff --git a/pages/settings/access-tokens.tsx b/pages/settings/access-tokens.tsx index 14912a2..f43a459 100644 --- a/pages/settings/access-tokens.tsx +++ b/pages/settings/access-tokens.tsx @@ -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(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 ( -

API Keys

+

Access Tokens

@@ -20,16 +40,68 @@ export default function Api() { + + {tokens.length > 0 ? ( + <> +
+ + + {/* head */} + + + + + + + + + + + {tokens.map((token, i) => ( + + + + + + + + + + ))} + +
NameCreatedExpires
{i + 1}{token.name} + {new Date(token.createdAt || "").toLocaleDateString()} + + {new Date(token.expires || "").toLocaleDateString()} + + +
+ + ) : undefined} - {newKeyModal ? ( - setNewKeyModal(false)} /> + {newTokenModal ? ( + setNewTokenModal(false)} /> ) : undefined} + {revokeTokenModal && selectedToken && ( + { + setRevokeTokenModal(false); + setSelectedToken(null); + }} + activeToken={selectedToken} + /> + )}
); } diff --git a/prisma/migrations/20240124192212_added_revoke_field/migration.sql b/prisma/migrations/20240124192212_added_revoke_field/migration.sql new file mode 100644 index 0000000..b9802a0 --- /dev/null +++ b/prisma/migrations/20240124192212_added_revoke_field/migration.sql @@ -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; diff --git a/prisma/migrations/20240124201018_removed_name_unique_constraint/migration.sql b/prisma/migrations/20240124201018_removed_name_unique_constraint/migration.sql new file mode 100644 index 0000000..4a570db --- /dev/null +++ b/prisma/migrations/20240124201018_removed_name_unique_constraint/migration.sql @@ -0,0 +1,2 @@ +-- DropIndex +DROP INDEX "AccessToken_name_userId_key"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 60a03e1..aebc98f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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]) } diff --git a/store/tokens.ts b/store/tokens.ts new file mode 100644 index 0000000..eff1100 --- /dev/null +++ b/store/tokens.ts @@ -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[]; + setTokens: (data: Partial[]) => void; + addToken: (body: Partial[]) => Promise; + revokeToken: (tokenId: number) => Promise; +}; + +const useTokenStore = create((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; diff --git a/types/global.ts b/types/global.ts index 36e38c1..3c8de79 100644 --- a/types/global.ts +++ b/types/global.ts @@ -135,7 +135,7 @@ export enum LinkType { image, } -export enum KeyExpiry { +export enum TokenExpiry { sevenDays, oneMonth, twoMonths,