From bcc515f2186df29328edfbeaea16173a915e4590 Mon Sep 17 00:00:00 2001 From: Bernt Christian Egeland Date: Thu, 22 Aug 2024 20:57:20 +0000 Subject: [PATCH 01/24] new UserDevice model --- .../20240822202613_user_device/migration.sql | 29 +++++++++++++++++++ prisma/schema.prisma | 18 ++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 prisma/migrations/20240822202613_user_device/migration.sql diff --git a/prisma/migrations/20240822202613_user_device/migration.sql b/prisma/migrations/20240822202613_user_device/migration.sql new file mode 100644 index 00000000..41db03bb --- /dev/null +++ b/prisma/migrations/20240822202613_user_device/migration.sql @@ -0,0 +1,29 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "deviceIsValid" BOOLEAN NOT NULL DEFAULT true; + +-- CreateTable +CREATE TABLE "UserDevice" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "deviceType" TEXT NOT NULL, + "deviceId" TEXT NOT NULL, + "browser" TEXT NOT NULL, + "os" TEXT NOT NULL, + "lastActive" TIMESTAMP(3) NOT NULL, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "UserDevice_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "UserDevice_deviceId_key" ON "UserDevice"("deviceId"); + +-- CreateIndex +CREATE INDEX "UserDevice_userId_idx" ON "UserDevice"("userId"); + +-- CreateIndex +CREATE INDEX "UserDevice_deviceId_idx" ON "UserDevice"("deviceId"); + +-- AddForeignKey +ALTER TABLE "UserDevice" ADD CONSTRAINT "UserDevice_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 34acaf36..d510b315 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -262,6 +262,7 @@ model User { ActivityLog ActivityLog[] expiresAt DateTime? // null means it never expires isActive Boolean @default(true) + deviceIsValid Boolean @default(true) createdAt DateTime @default(now()) userGroup UserGroup? @relation(fields: [userGroupId], references: [id], onDelete: Restrict) options UserOptions? @@ -271,6 +272,23 @@ model User { apiTokens APIToken[] webhooks Webhook[] invitations Invitation[] @relation("InvitationsSent") + UserDevice UserDevice[] +} + +model UserDevice { + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + deviceType String + deviceId String @unique + browser String + os String + lastActive DateTime + isActive Boolean @default(true) + createdAt DateTime @default(now()) + + @@index([userId]) + @@index([deviceId]) } model VerificationToken { From de12286619222148fe4ff6c1a9948807eb5625c6 Mon Sep 17 00:00:00 2001 From: Bernt Christian Egeland Date: Thu, 22 Aug 2024 20:57:57 +0000 Subject: [PATCH 02/24] added ua-parser-js --- package-lock.json | 32 ++++++++++++++++++++++++++++++++ package.json | 4 +++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index 1be01808..bd6ee528 100644 --- a/package-lock.json +++ b/package-lock.json @@ -55,6 +55,7 @@ "socket.io": "^4.7.5", "socket.io-client": "^4.7.5", "superjson": "1.9.1", + "ua-parser-js": "^1.0.38", "unique-names-generator": "^4.7.1", "unzipper": "^0.10.14", "usehooks-ts": "^2.9.1", @@ -79,6 +80,7 @@ "@types/react": "^18.0.28", "@types/react-dom": "^18.0.11", "@types/testing-library__jest-dom": "^5.14.5", + "@types/ua-parser-js": "^0.7.39", "@types/unzipper": "^0.10.6", "autoprefixer": "^10.4.7", "dotenv": "^16.0.3", @@ -2855,6 +2857,13 @@ "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", "dev": true }, + "node_modules/@types/ua-parser-js": { + "version": "0.7.39", + "resolved": "https://registry.npmjs.org/@types/ua-parser-js/-/ua-parser-js-0.7.39.tgz", + "integrity": "sha512-P/oDfpofrdtF5xw433SPALpdSchtJmY7nsJItf8h3KXqOslkbySh8zq4dSWXH2oTjRvJ5PczVEoCZPow6GicLg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/unzipper": { "version": "0.10.9", "resolved": "https://registry.npmjs.org/@types/unzipper/-/unzipper-0.10.9.tgz", @@ -9449,6 +9458,29 @@ "node": ">=14.17" } }, + "node_modules/ua-parser-js": { + "version": "1.0.38", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.38.tgz", + "integrity": "sha512-Aq5ppTOfvrCMgAPneW1HfWj66Xi7XL+/mIy996R1/CLS/rcyJQm6QZdsKrUeivDFQ+Oc9Wyuwor8Ze8peEoUoQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + }, + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + } + ], + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/undici-types": { "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", diff --git a/package.json b/package.json index 24200887..242429f8 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "socket.io": "^4.7.5", "socket.io-client": "^4.7.5", "superjson": "1.9.1", + "ua-parser-js": "^1.0.38", "unique-names-generator": "^4.7.1", "unzipper": "^0.10.14", "usehooks-ts": "^2.9.1", @@ -86,6 +87,7 @@ "@types/react": "^18.0.28", "@types/react-dom": "^18.0.11", "@types/testing-library__jest-dom": "^5.14.5", + "@types/ua-parser-js": "^0.7.39", "@types/unzipper": "^0.10.6", "autoprefixer": "^10.4.7", "dotenv": "^16.0.3", @@ -107,4 +109,4 @@ "prisma": { "seed": "ts-node --compiler-options {\"module\":\"CommonJS\"} prisma/seed.ts" } -} \ No newline at end of file +} From 120b4a131fe0ea03565da475c2d09550ac5e6914 Mon Sep 17 00:00:00 2001 From: Bernt Christian Egeland Date: Thu, 22 Aug 2024 20:58:14 +0000 Subject: [PATCH 03/24] added userAgent --- src/components/auth/credentialsForm.tsx | 1 + src/components/auth/registerForm.tsx | 1 + src/components/auth/registerOrganizationInvite.tsx | 1 + 3 files changed, 3 insertions(+) diff --git a/src/components/auth/credentialsForm.tsx b/src/components/auth/credentialsForm.tsx index 937f61f9..75540723 100644 --- a/src/components/auth/credentialsForm.tsx +++ b/src/components/auth/credentialsForm.tsx @@ -42,6 +42,7 @@ const CredentialsForm: React.FC = () => { const response = await signIn("credentials", { redirect: false, totpCode, + userAgent: navigator.userAgent, ...formData, }); diff --git a/src/components/auth/registerForm.tsx b/src/components/auth/registerForm.tsx index 94701c15..28291835 100644 --- a/src/components/auth/registerForm.tsx +++ b/src/components/auth/registerForm.tsx @@ -47,6 +47,7 @@ const RegisterForm: React.FC = () => { void (async () => { const result = await signIn("credentials", { redirect: false, + userAgent: navigator.userAgent, ...formData, }); setLoading(false); diff --git a/src/components/auth/registerOrganizationInvite.tsx b/src/components/auth/registerOrganizationInvite.tsx index a8a6e599..f0ed39c2 100644 --- a/src/components/auth/registerOrganizationInvite.tsx +++ b/src/components/auth/registerOrganizationInvite.tsx @@ -88,6 +88,7 @@ const RegisterOrganizationInviteForm: React.FC = ({ void (async () => { const result = await signIn("credentials", { redirect: false, + userAgent: navigator.userAgent, ...formData, }); setLoading(false); From d742fb4d75d4ea73909a8807218446effab83cda Mon Sep 17 00:00:00 2001 From: Bernt Christian Egeland Date: Thu, 22 Aug 2024 20:58:24 +0000 Subject: [PATCH 04/24] list devices --- src/components/auth/userDevices.tsx | 105 ++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 src/components/auth/userDevices.tsx diff --git a/src/components/auth/userDevices.tsx b/src/components/auth/userDevices.tsx new file mode 100644 index 00000000..00a09ce3 --- /dev/null +++ b/src/components/auth/userDevices.tsx @@ -0,0 +1,105 @@ +import { useEffect, useState } from "react"; +import { useSession } from "next-auth/react"; +import UAParser from "ua-parser-js"; +import type { UserDevice } from "@prisma/client"; +import cn from "classnames"; +import Smartphone from "~/icons/smartphone"; +import Monitor from "~/icons/monitor"; +import Tablet from "~/icons/tablet"; +import { api } from "~/utils/api"; + +const DeviceIcon = ({ deviceType }: { deviceType: string }) => { + switch (deviceType.toLowerCase()) { + case "mobile": + return ; + case "tablet": + return ; + default: + return ; + } +}; + +const ListUserDevices: React.FC<{ devices: UserDevice[] }> = ({ devices }) => { + const { data: session, status } = useSession(); + const { refetch } = api.auth.me.useQuery(); + + const { mutate: deleteUserDevice } = api.auth.deleteUserDevice.useMutation({ + onSuccess: () => { + // Refresh the devices list + refetch(); + }, + }); + + const [currentDeviceInfo, setCurrentDeviceInfo] = useState<{ + browser: string; + os: string; + } | null>(null); + + useEffect(() => { + const ua = new UAParser(navigator.userAgent); + setCurrentDeviceInfo({ + browser: ua.getBrowser().name || "Unknown", + os: ua.getOS().name || "Unknown", + }); + }, []); + + if (status === "loading") { + return
Loading...
; + } + + if (status === "unauthenticated" || !session) { + return
Access Denied
; + } + + const isCurrentDevice = (device: UserDevice) => { + return ( + device.browser === currentDeviceInfo?.browser && device.os === currentDeviceInfo?.os + ); + }; + + return ( +
+

Enheter du er logget inn på

+
+ {devices && devices.length > 0 ? ( + devices.map((device) => ( +
+
+ +
+

{`${device.os} ${device.browser}`}

+

+ {new Date(device.lastActive).toLocaleString("no-NO")} +

+ {isCurrentDevice(device) && ( +

Aktiv nå

+ )} +
+
+ +
+ )) + ) : ( +

Ingen enheter funnet.

+ )} +
+
+ ); +}; + +export default ListUserDevices; From b6ab05ab4fb9773f3a11857edc41a8e6cb28e547 Mon Sep 17 00:00:00 2001 From: Bernt Christian Egeland Date: Thu, 22 Aug 2024 20:58:34 +0000 Subject: [PATCH 05/24] icons --- src/icons/monitor.tsx | 29 +++++++++++++++++++++++++++++ src/icons/smartphone.tsx | 29 +++++++++++++++++++++++++++++ src/icons/tablet.tsx | 29 +++++++++++++++++++++++++++++ 3 files changed, 87 insertions(+) create mode 100644 src/icons/monitor.tsx create mode 100644 src/icons/smartphone.tsx create mode 100644 src/icons/tablet.tsx diff --git a/src/icons/monitor.tsx b/src/icons/monitor.tsx new file mode 100644 index 00000000..973543ac --- /dev/null +++ b/src/icons/monitor.tsx @@ -0,0 +1,29 @@ +interface Icon { + // add optional className prop + className?: string; + onClick?: () => void; +} + +const Monitor = ({ className, onClick, ...rest }: Icon) => { + return ( + + + + + + ); +}; + +export default Monitor; diff --git a/src/icons/smartphone.tsx b/src/icons/smartphone.tsx new file mode 100644 index 00000000..14f9e828 --- /dev/null +++ b/src/icons/smartphone.tsx @@ -0,0 +1,29 @@ +interface Icon { + // add optional className prop + className?: string; + onClick?: () => void; +} + +const SmartPhone = ({ className, onClick, ...rest }: Icon) => { + return ( + + + + + + ); +}; + +export default SmartPhone; diff --git a/src/icons/tablet.tsx b/src/icons/tablet.tsx new file mode 100644 index 00000000..2ad7f39d --- /dev/null +++ b/src/icons/tablet.tsx @@ -0,0 +1,29 @@ +interface Icon { + // add optional className prop + className?: string; + onClick?: () => void; +} + +const Tablet = ({ className, onClick, ...rest }: Icon) => { + return ( + + + + + + ); +}; + +export default Tablet; From bd47d3dd09887d39be45384cb32667f33dae49cd Mon Sep 17 00:00:00 2001 From: Bernt Christian Egeland Date: Thu, 22 Aug 2024 20:58:50 +0000 Subject: [PATCH 06/24] invalidate user --- src/pages/api/auth/user/invalidateUser.ts | 59 +++++++++++++++++++++++ src/pages/user-settings/account/index.tsx | 25 +++++----- 2 files changed, 72 insertions(+), 12 deletions(-) create mode 100644 src/pages/api/auth/user/invalidateUser.ts diff --git a/src/pages/api/auth/user/invalidateUser.ts b/src/pages/api/auth/user/invalidateUser.ts new file mode 100644 index 00000000..7024542f --- /dev/null +++ b/src/pages/api/auth/user/invalidateUser.ts @@ -0,0 +1,59 @@ +import { NextApiRequest, NextApiResponse } from "next"; +import { getServerSession } from "next-auth/next"; +import { NextResponse } from "next/server"; +import { authOptions } from "~/server/auth"; +import { prisma } from "~/server/db"; + +export default async function handler( + request: NextApiRequest, + response: NextApiResponse, +) { + if (request.method !== "POST") { + return response.status(405).json({ message: "Method not allowed" }); + } + + try { + const session = await getServerSession(request, response, authOptions); + if (!session) { + return response.status(401).json({ message: "Not authenticated" }); + } + const { userId, deviceId } = await request.body; + + if (!userId || !deviceId) { + return NextResponse.json({ error: "Missing userId or deviceId" }, { status: 400 }); + } + + const userDevice = await prisma.userDevice.findUnique({ + where: { + userId, + deviceId, + }, + }); + + if (!userDevice) { + // Device doesn't exist, invalidate the token + await prisma.user.update({ + where: { id: userId }, + data: { isActive: false }, + }); + + return NextResponse.json({ message: "User invalidated" }, { status: 200 }); + } + + // Update lastActive field + await prisma.userDevice.update({ + where: { + userId, + deviceId, + }, + data: { + lastActive: new Date(), + }, + }); + + return NextResponse.json({ message: "Device updated" }, { status: 200 }); + } catch (error) { + console.error("Error in user invalidation:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/src/pages/user-settings/account/index.tsx b/src/pages/user-settings/account/index.tsx index b9e515d8..e5bbc5e2 100644 --- a/src/pages/user-settings/account/index.tsx +++ b/src/pages/user-settings/account/index.tsx @@ -16,6 +16,7 @@ import { useModalStore } from "~/utils/store"; import DisableTwoFactSetupModal from "~/components/auth/totpDisable"; import MultifactorNotEnabled from "~/components/auth/multifactorNotEnabledAlert"; import MenuSectionDividerWrapper from "~/components/shared/menuSectionDividerWrapper"; +import ListUserDevices from "~/components/auth/userDevices"; const defaultLocale = "en"; @@ -60,7 +61,6 @@ const Account = () => { if (userError) { toast.error(userError.message); } - return (
{
{!me?.twoFactorEnabled ? : null}
-
- -

- {t("userSettings.account.totp.title")} -

+ +

+ {t("userSettings.account.totp.title")} +

-

- {t("userSettings.account.totp.description")} -

-
-

+

+ {t("userSettings.account.totp.description")} +

+

{t("userSettings.account.totp.mfaNote")}

-
+
+ + {/* user devices */} +
Date: Thu, 22 Aug 2024 20:59:10 +0000 Subject: [PATCH 07/24] check session if device is valid --- src/server/auth.ts | 124 ++++++++++++++++++++++++++++++++++++--------- 1 file changed, 101 insertions(+), 23 deletions(-) diff --git a/src/server/auth.ts b/src/server/auth.ts index 2a7a087a..e9d80ef7 100644 --- a/src/server/auth.ts +++ b/src/server/auth.ts @@ -13,6 +13,8 @@ import { TOTP_MFA_TOKEN_SECRET, } from "~/utils/encryption"; import { authenticator } from "otplib"; +import { generateDeviceId } from "~/utils/devices"; +import UAParser from "ua-parser-js"; /** * Module augmentation for `next-auth` types. Allows us to add custom properties to the `session` @@ -25,10 +27,21 @@ import { authenticator } from "otplib"; const MAX_FAILED_ATTEMPTS = 5; const COOLDOWN_PERIOD = 1 * 60 * 1000; // 1 minute in milliseconds +interface IUserAgent { + userId: string; + deviceType: string; + browser: string; + os: string; + lastActive: Date; + deviceId: string; +} + declare module "next-auth" { interface Session extends DefaultSession { user: IUser; error: string; + // userAgent?: IUserAgent | unknown; + // deviceId?: string; update: { name?: string; email?: string; @@ -39,10 +52,8 @@ declare module "next-auth" { id?: string; name: string; role: string; - // ...other properties - // role: UserRole; - // email: string; - // password: string; + userAgent?: IUserAgent | unknown; + deviceId?: string; } } @@ -139,6 +150,7 @@ export const authOptions: NextAuthOptions = { placeholder: "mail@example.com", }, password: { label: "Password", type: "password" }, + userAgent: { type: "hidden" }, totpCode: { label: "Two-factor Code", type: "input", @@ -251,20 +263,11 @@ export const authOptions: NextAuthOptions = { return { ...user, + userAgent: _credentials?.userAgent, hash: null, }; }, }), - - /** - * ...add more providers here. - * - * Most other providers require a bit more work than the Discord provider. For example, the - * GitHub provider requires you to add the `refresh_token_expires_in` field to the Account - * model. Refer to the NextAuth.js docs for the provider you want to use. Example: - * - * @see https://next-auth.js.org/providers/github - */ ], session: { strategy: "jwt", @@ -301,9 +304,37 @@ export const authOptions: NextAuthOptions = { }, data: { lastLogin: new Date().toISOString(), + deviceIsValid: true, }, }); + // Update device information + if (user?.userAgent) { + const deviceId = generateDeviceId(user.userAgent as string, existingUser.id); + const ua = new UAParser(user.userAgent as string); + + const deviceInfo = { + deviceId, + userId: existingUser.id as string, + deviceType: ua.getDevice().type || "desktop", + browser: ua.getBrowser().name || "Unknown", + os: ua.getOS().name || "Unknown", + lastActive: new Date(), + }; + + await prisma.userDevice.upsert({ + where: { deviceId }, + update: { + lastActive: deviceInfo.lastActive, + isActive: true, + }, + create: deviceInfo, + }); + + // biome-ignore lint/suspicious/noExplicitAny: + (user as any).deviceId = deviceId; + } } + return true; } if (account.provider === "oauth") { @@ -426,11 +457,47 @@ export const authOptions: NextAuthOptions = { token.email = user.email; token.role = user.role; // Add other relevant properties as needed } - if (user) { - // token.id = user.id; - // token.name = user.name; - // token.role = user.role; - token = { ...user }; + + if (user?.id) { + token.id = user.id; + token.email = user.email; + token.name = user.name; + token.deviceId = user.deviceId; + } + + // Check if the device still exists and is valid + if (token.id && token.deviceId && typeof token.deviceId === "string") { + try { + const userDevice = await prisma.userDevice.findUnique({ + where: { + userId: token?.id, + deviceId: token.deviceId, + }, + }); + + if (!userDevice) { + // Device doesn't exist, invalidate the token + await prisma.user.update({ + where: { id: token?.id as string }, + data: { deviceIsValid: false }, + }); + token.deviceIsValid = false; + return token; + } + + // Update lastActive field + await prisma.userDevice.update({ + where: { + userId: token?.id, + deviceId: token.deviceId, + }, + data: { + lastActive: new Date(), + }, + }); + } catch (error) { + console.error("Error checking or updating user device:", error); + } } return token; }, @@ -443,13 +510,18 @@ export const authOptions: NextAuthOptions = { }); // Number(user.id.trim()) checks if the user session has the old int as the User id - if (!user || !user.isActive || Number.isInteger(Number(token.id))) { + if ( + !user || + !user.isActive || + !user.deviceIsValid || + Number.isInteger(Number(token.id)) + ) { // If the user does not exist, set user to null return { ...session, user: null }; } // update users lastseen in the database - await prisma.user.update({ + const updatedUser = await prisma.user.update({ where: { id: user.id, }, @@ -457,8 +529,14 @@ export const authOptions: NextAuthOptions = { lastseen: new Date(), }, }); - session.user = { ...token } as IUser; - return session; + + return { + ...session, + user: { + ...updatedUser, + userAgent: token.userAgent, + }, + }; }, redirect({ url, baseUrl }) { // Allows relative callback URLs From 89f4097e5bdf9be89dfe1ec7c34568c997101b75 Mon Sep 17 00:00:00 2001 From: Bernt Christian Egeland Date: Thu, 22 Aug 2024 20:59:27 +0000 Subject: [PATCH 08/24] deleteUserDevice --- src/server/api/routers/authRouter.ts | 17 ++++++++++++++++- src/utils/devices.ts | 20 ++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 src/utils/devices.ts diff --git a/src/server/api/routers/authRouter.ts b/src/server/api/routers/authRouter.ts index 46709eea..ad622d60 100644 --- a/src/server/api/routers/authRouter.ts +++ b/src/server/api/routers/authRouter.ts @@ -24,7 +24,7 @@ import { generateInstanceSecret, } from "~/utils/encryption"; import { isRunningInDocker } from "~/utils/docker"; -import { Invitation, User, UserOptions } from "@prisma/client"; +import { Invitation, User, UserDevice, UserOptions } from "@prisma/client"; import { validateOrganizationToken } from "../services/organizationAuthService"; import rateLimit from "~/utils/rateLimit"; import { ErrorCode } from "~/utils/errorCode"; @@ -367,6 +367,7 @@ export const authRouter = createTRPCRouter({ include: { options: true, memberOfOrgs: true, + UserDevice: true, }, })) as User & { options?: UserOptions & { @@ -381,6 +382,7 @@ export const authRouter = createTRPCRouter({ description: string | null; isActive: boolean; }[]; + UserDevice?: UserDevice[]; }; user.options.localControllerUrlPlaceholder = isRunningInDocker() ? "http://zerotier:9993" @@ -889,4 +891,17 @@ export const authRouter = createTRPCRouter({ }, }); }), + deleteUserDevice: protectedProcedure + .input( + z.object({ + deviceId: z.string(), + }), + ) + .mutation(async ({ ctx, input }) => { + return await ctx.prisma.userDevice.delete({ + where: { + deviceId: input.deviceId, + }, + }); + }), }); diff --git a/src/utils/devices.ts b/src/utils/devices.ts new file mode 100644 index 00000000..64c20864 --- /dev/null +++ b/src/utils/devices.ts @@ -0,0 +1,20 @@ +import UAParser from "ua-parser-js"; + +export function generateDeviceId(userAgent: string, userId: string): string { + const ua = new UAParser(userAgent); + const deviceType = ua.getDevice().type || "desktop"; + const browser = ua.getBrowser().name || "Unknown"; + const os = ua.getOS().name || "Unknown"; + const version = ua.getOS().version || "Unknown"; + + const deviceInfo = `${userId}-${deviceType}-${browser}-${os}-${version}`; + + let hash = 0; + for (let i = 0; i < deviceInfo.length; i++) { + const char = deviceInfo.charCodeAt(i); + hash = (hash << 5) - hash + char; + hash = hash & hash; + } + + return Math.abs(hash).toString(16); +} From 8ca88c0fca4ffa3ec812f7e58d82f9a70b29ffa4 Mon Sep 17 00:00:00 2001 From: Bernt Christian Egeland Date: Thu, 22 Aug 2024 21:02:43 +0000 Subject: [PATCH 09/24] description --- src/components/auth/userDevices.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/auth/userDevices.tsx b/src/components/auth/userDevices.tsx index 00a09ce3..90c34a39 100644 --- a/src/components/auth/userDevices.tsx +++ b/src/components/auth/userDevices.tsx @@ -59,7 +59,7 @@ const ListUserDevices: React.FC<{ devices: UserDevice[] }> = ({ devices }) => { return (
-

Enheter du er logget inn på

+

Connected Devices

{devices && devices.length > 0 ? ( devices.map((device) => ( @@ -82,7 +82,7 @@ const ListUserDevices: React.FC<{ devices: UserDevice[] }> = ({ devices }) => { {new Date(device.lastActive).toLocaleString("no-NO")}

{isCurrentDevice(device) && ( -

Aktiv nå

+

Active Now

)}
From 2077e67bbb5e47c371bff0094908ac79d85b23c5 Mon Sep 17 00:00:00 2001 From: Bernt Christian Egeland Date: Thu, 22 Aug 2024 21:07:19 +0000 Subject: [PATCH 10/24] added userAgent to test suite --- src/__tests__/components/loginForm.test.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/__tests__/components/loginForm.test.tsx b/src/__tests__/components/loginForm.test.tsx index 8b501d86..b9837615 100644 --- a/src/__tests__/components/loginForm.test.tsx +++ b/src/__tests__/components/loginForm.test.tsx @@ -67,6 +67,8 @@ describe("CredentialsForm", () => { redirect: false, email: "test@example.com", password: "testpassword", + userAgent: + "Mozilla/5.0 (linux) AppleWebKit/537.36 (KHTML, like Gecko) jsdom/20.0.3", totpCode: "", }); }); From 62b1c1345bc84f6ea60a413579bfe5f1a99682a0 Mon Sep 17 00:00:00 2001 From: Bernt Christian Egeland Date: Thu, 22 Aug 2024 21:14:14 +0000 Subject: [PATCH 11/24] loading --- src/components/auth/userDevices.tsx | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/components/auth/userDevices.tsx b/src/components/auth/userDevices.tsx index 90c34a39..6418edd4 100644 --- a/src/components/auth/userDevices.tsx +++ b/src/components/auth/userDevices.tsx @@ -23,12 +23,13 @@ const ListUserDevices: React.FC<{ devices: UserDevice[] }> = ({ devices }) => { const { data: session, status } = useSession(); const { refetch } = api.auth.me.useQuery(); - const { mutate: deleteUserDevice } = api.auth.deleteUserDevice.useMutation({ - onSuccess: () => { - // Refresh the devices list - refetch(); - }, - }); + const { mutate: deleteUserDevice, isLoading: deleteLoading } = + api.auth.deleteUserDevice.useMutation({ + onSuccess: () => { + // Refresh the devices list + refetch(); + }, + }); const [currentDeviceInfo, setCurrentDeviceInfo] = useState<{ browser: string; @@ -59,7 +60,10 @@ const ListUserDevices: React.FC<{ devices: UserDevice[] }> = ({ devices }) => { return (
-

Connected Devices

+
+

Connected Devices

+ {/* */} +
{devices && devices.length > 0 ? ( devices.map((device) => ( @@ -87,6 +91,7 @@ const ListUserDevices: React.FC<{ devices: UserDevice[] }> = ({ devices }) => {
*/} -
+
{devices && devices.length > 0 ? ( devices.map((device) => (
{ if (userError) { toast.error(userError.message); } + return (
{ + const deviceTypes = ["desktop", "mobile", "tablet"]; + const browsers = ["Chrome", "Firefox", "Safari", "Edge"]; + const operatingSystems = ["Windows", "MacOS", "Linux", "iOS", "Android"]; + + const createdAt = faker.date.past(); + const lastActive = faker.date.between({ from: createdAt, to: new Date() }); + + return { + id: faker.string.uuid(), + userId: faker.string.uuid(), + deviceType: faker.helpers.arrayElement(deviceTypes), + ipAddress: faker.internet.ip(), + location: faker.datatype.boolean() ? faker.location.city() : null, + deviceId: faker.string.alphanumeric(8), + browser: faker.helpers.arrayElement(browsers), + os: faker.helpers.arrayElement(operatingSystems), + lastActive: lastActive.toISOString(), + isActive: faker.datatype.boolean(), + createdAt: createdAt.toISOString(), + }; +}; + +export const generateFakeUserDevices = (count = 5) => { + return Array(count) + .fill(null) + .map(() => generateFakeDevice()); +}; From 0a783912493245dce4ce8c32556f24536be97b3c Mon Sep 17 00:00:00 2001 From: Bernt Christian Egeland Date: Sat, 24 Aug 2024 09:32:47 +0000 Subject: [PATCH 17/24] tests --- src/__tests__/pages/auth/signin.test.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/__tests__/pages/auth/signin.test.tsx b/src/__tests__/pages/auth/signin.test.tsx index 3088b568..e7602b64 100644 --- a/src/__tests__/pages/auth/signin.test.tsx +++ b/src/__tests__/pages/auth/signin.test.tsx @@ -193,7 +193,13 @@ describe("LoginPage", () => { const oauthButton = screen.getByRole("button", { name: /Sign in with OAuth/i }); await userEvent.click(oauthButton); - expect(signIn).toHaveBeenCalledWith("oauth", { redirect: false }); + expect(signIn).toHaveBeenCalledWith( + "oauth", + { redirect: false }, + expect.objectContaining({ + userAgent: expect.any(String), + }), + ); }); it("Enter 2FA code", async () => { From a241ce51db73eff776e45a016047dfe93abe6778 Mon Sep 17 00:00:00 2001 From: Bernt Christian Egeland Date: Sat, 24 Aug 2024 09:38:36 +0000 Subject: [PATCH 18/24] renamed authoptions --- src/pages/api/auth/two-factor/totp/disable.ts | 4 ++-- src/pages/api/auth/two-factor/totp/enable.ts | 4 ++-- src/pages/api/auth/two-factor/totp/setup.ts | 4 ++-- src/pages/api/auth/user/invalidateUser.ts | 4 ++-- src/pages/api/mkworld/config.ts | 4 ++-- src/pages/api/websocket/index.ts | 4 ++-- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/pages/api/auth/two-factor/totp/disable.ts b/src/pages/api/auth/two-factor/totp/disable.ts index 37da26ee..9f090415 100644 --- a/src/pages/api/auth/two-factor/totp/disable.ts +++ b/src/pages/api/auth/two-factor/totp/disable.ts @@ -1,7 +1,7 @@ import type { NextApiRequest, NextApiResponse } from "next"; import { getServerSession } from "next-auth"; import { authenticator } from "otplib"; -import { authOptions } from "~/server/auth"; +import { getAuthOptions } from "~/server/auth"; import { prisma } from "~/server/db"; import { decrypt, @@ -15,7 +15,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) return res.status(405).json({ message: "Method not allowed" }); } - const session = await getServerSession(req, res, authOptions); + const session = await getServerSession(req, res, getAuthOptions(req)); if (!session) { return res.status(401).json({ message: "Not authenticated" }); } diff --git a/src/pages/api/auth/two-factor/totp/enable.ts b/src/pages/api/auth/two-factor/totp/enable.ts index b7a4697a..0832c958 100644 --- a/src/pages/api/auth/two-factor/totp/enable.ts +++ b/src/pages/api/auth/two-factor/totp/enable.ts @@ -1,7 +1,7 @@ import type { NextApiRequest, NextApiResponse } from "next"; import { authenticator } from "otplib"; import { getServerSession } from "next-auth"; -import { authOptions } from "~/server/auth"; +import { getAuthOptions } from "~/server/auth"; import { ErrorCode } from "~/utils/errorCode"; import { prisma } from "~/server/db"; import { @@ -26,7 +26,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) return res.status(405).json({ message: "Method not allowed" }); } - const session = await getServerSession(req, res, authOptions); + const session = await getServerSession(req, res, getAuthOptions(req)); if (!session) { return res.status(401).json({ message: "Not authenticated" }); } diff --git a/src/pages/api/auth/two-factor/totp/setup.ts b/src/pages/api/auth/two-factor/totp/setup.ts index 57b6639b..386c96e0 100644 --- a/src/pages/api/auth/two-factor/totp/setup.ts +++ b/src/pages/api/auth/two-factor/totp/setup.ts @@ -5,7 +5,7 @@ import { prisma } from "~/server/db"; import { compare } from "bcryptjs"; import { ErrorCode } from "~/utils/errorCode"; import { getServerSession } from "next-auth/next"; -import { authOptions } from "~/server/auth"; +import { getAuthOptions } from "~/server/auth"; import { encrypt, generateInstanceSecret, @@ -27,7 +27,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) return res.status(405).json({ message: "Method not allowed" }); } - const session = await getServerSession(req, res, authOptions); + const session = await getServerSession(req, res, getAuthOptions(req)); if (!session) { return res.status(401).json({ error: ErrorCode.InternalServerError }); } diff --git a/src/pages/api/auth/user/invalidateUser.ts b/src/pages/api/auth/user/invalidateUser.ts index 7024542f..72fed0b7 100644 --- a/src/pages/api/auth/user/invalidateUser.ts +++ b/src/pages/api/auth/user/invalidateUser.ts @@ -1,7 +1,7 @@ import { NextApiRequest, NextApiResponse } from "next"; import { getServerSession } from "next-auth/next"; import { NextResponse } from "next/server"; -import { authOptions } from "~/server/auth"; +import { getAuthOptions } from "~/server/auth"; import { prisma } from "~/server/db"; export default async function handler( @@ -13,7 +13,7 @@ export default async function handler( } try { - const session = await getServerSession(request, response, authOptions); + const session = await getServerSession(request, response, getAuthOptions(request)); if (!session) { return response.status(401).json({ message: "Not authenticated" }); } diff --git a/src/pages/api/mkworld/config.ts b/src/pages/api/mkworld/config.ts index 669220f3..2d2c267f 100644 --- a/src/pages/api/mkworld/config.ts +++ b/src/pages/api/mkworld/config.ts @@ -11,8 +11,8 @@ import { execSync } from "child_process"; import { updateLocalConf } from "~/utils/planet"; import { ZT_FOLDER } from "~/utils/ztApi"; import { getServerSession } from "next-auth/next"; -import { authOptions } from "~/server/auth"; import { WorldConfig } from "~/types/worldConfig"; +import { getAuthOptions } from "~/server/auth"; export const config = { api: { @@ -21,7 +21,7 @@ export const config = { }; export default async (req: NextApiRequest, res: NextApiResponse) => { - const session = await getServerSession(req, res, authOptions); + const session = await getServerSession(req, res, getAuthOptions(req)); if (!session) { res.status(401).json({ message: "Authorization Error" }); return; diff --git a/src/pages/api/websocket/index.ts b/src/pages/api/websocket/index.ts index 8ddd4fcb..dc57665b 100644 --- a/src/pages/api/websocket/index.ts +++ b/src/pages/api/websocket/index.ts @@ -1,7 +1,7 @@ import type { NextApiRequest, NextApiResponse } from "next"; import { getServerSession } from "next-auth/next"; import { Server } from "socket.io"; -import { authOptions } from "~/server/auth"; +import { getAuthOptions } from "~/server/auth"; interface SocketIoExtension { socket: { @@ -13,7 +13,7 @@ interface SocketIoExtension { export type NextApiResponseWithSocketIo = NextApiResponse & SocketIoExtension; const SocketHandler = async (req: NextApiRequest, res: NextApiResponseWithSocketIo) => { - const session = await getServerSession(req, res, authOptions); + const session = await getServerSession(req, res, getAuthOptions(req)); if (!session) { res.status(401).json({ message: "Authorization Error" }); return; From 8b21e36a0137ce0908a285df9a750b3a6c143316 Mon Sep 17 00:00:00 2001 From: Bernt Christian Egeland Date: Sat, 24 Aug 2024 12:03:19 +0000 Subject: [PATCH 19/24] translations --- src/components/auth/userDevices.tsx | 61 ++++++++++++++--------------- src/locales/en/common.json | 8 ++++ src/locales/es/common.json | 8 ++++ src/locales/fr/common.json | 8 ++++ src/locales/no/common.json | 8 ++++ src/locales/pl/common.json | 8 ++++ src/locales/zh-tw/common.json | 8 ++++ src/locales/zh/common.json | 8 ++++ 8 files changed, 86 insertions(+), 31 deletions(-) diff --git a/src/components/auth/userDevices.tsx b/src/components/auth/userDevices.tsx index bbb0dbbb..a4251511 100644 --- a/src/components/auth/userDevices.tsx +++ b/src/components/auth/userDevices.tsx @@ -1,5 +1,4 @@ import { useEffect, useState } from "react"; -import { useSession } from "next-auth/react"; import UAParser from "ua-parser-js"; import type { UserDevice } from "@prisma/client"; import cn from "classnames"; @@ -7,29 +6,35 @@ import Smartphone from "~/icons/smartphone"; import Monitor from "~/icons/monitor"; import Tablet from "~/icons/tablet"; import { api } from "~/utils/api"; +import { useTranslations } from "next-intl"; const formatLastActive = (date) => { return new Date(date).toLocaleString("no-NO"); }; -const DeviceInfo = ({ device, isCurrentDevice }) => ( -
-

- {`${device.os} ${device.browser}`} -

-

- {formatLastActive(device.lastActive)} - {device.ipAddress && ` - ${device.ipAddress}`} -

- {isCurrentDevice && ( -

Active Now

- )} -
-); +const DeviceInfo = ({ device, isCurrentDevice }) => { + const t = useTranslations(); + return ( +
+

+ {`${device.os} ${device.browser}`} +

+

+ {formatLastActive(device.lastActive)} + {device.ipAddress && ` - ${device.ipAddress}`} +

+ {isCurrentDevice && ( +

+ {t("userSettings.account.userDevices.activeNow")} +

+ )} +
+ ); +}; const DeviceIcon = ({ deviceType }: { deviceType: string }) => { switch (deviceType.toLowerCase()) { @@ -43,7 +48,7 @@ const DeviceIcon = ({ deviceType }: { deviceType: string }) => { }; const ListUserDevices: React.FC<{ devices: UserDevice[] }> = ({ devices }) => { - const { data: session, status } = useSession(); + const t = useTranslations(); const { refetch } = api.auth.me.useQuery(); const { mutate: deleteUserDevice, isLoading: deleteLoading } = @@ -67,14 +72,6 @@ const ListUserDevices: React.FC<{ devices: UserDevice[] }> = ({ devices }) => { }); }, []); - if (status === "loading") { - return
Loading...
; - } - - if (status === "unauthenticated" || !session) { - return
Access Denied
; - } - const isCurrentDevice = (device: UserDevice) => { return ( device.browser === currentDeviceInfo?.browser && device.os === currentDeviceInfo?.os @@ -90,7 +87,9 @@ const ListUserDevices: React.FC<{ devices: UserDevice[] }> = ({ devices }) => { return (
-

Connected Devices

+

+ {t("userSettings.account.userDevices.connectedDevices")} +

{/* */}
@@ -112,12 +111,12 @@ const ListUserDevices: React.FC<{ devices: UserDevice[] }> = ({ devices }) => { className="btn btn-sm btn-primary" onClick={() => deleteUserDevice({ deviceId: device.deviceId })} > - Logg ut + {t("userSettings.account.userDevices.logout")}
)) ) : ( -

Ingen enheter funnet.

+

{t("userSettings.account.userDevices.noDevicesFound")}

)}
diff --git a/src/locales/en/common.json b/src/locales/en/common.json index ce0fb650..5c8fe167 100644 --- a/src/locales/en/common.json +++ b/src/locales/en/common.json @@ -719,6 +719,14 @@ } } }, + "userDevices": { + "connectedDevices": "Connected Devices", + "logoutAll": "Logout All", + "accessDenied": "Access Denied", + "noDevicesFound": "No devices found.", + "activeNow": "Active Now", + "logout": "Logout" + }, "restapi": { "sectionTitle": "API Access Tokens", "description": "This allows you to generate an API token that you can use to authenticate and gain access to our application's API services. With a valid token, you can make requests to the API and interact with the application's features programmatically.", diff --git a/src/locales/es/common.json b/src/locales/es/common.json index 851f4273..11b4719f 100644 --- a/src/locales/es/common.json +++ b/src/locales/es/common.json @@ -719,6 +719,14 @@ } } }, + "userDevices": { + "connectedDevices": "Dispositivos Conectados", + "logoutAll": "Cerrar Sesión en Todos", + "accessDenied": "Acceso Denegado", + "noDevicesFound": "No se encontraron dispositivos.", + "activeNow": "Activo Ahora", + "logout": "Cerrar Sesión" + }, "restapi": { "sectionTitle": "Tokens de Acceso a la API", "description": "Los tokens de acceso a la API se utilizan para acceder a la API pública de ZTNET.", diff --git a/src/locales/fr/common.json b/src/locales/fr/common.json index 09d633b1..fc7ea859 100644 --- a/src/locales/fr/common.json +++ b/src/locales/fr/common.json @@ -719,6 +719,14 @@ } } }, + "userDevices": { + "connectedDevices": "Appareils Connectés", + "logoutAll": "Déconnecter Tout", + "accessDenied": "Accès Refusé", + "noDevicesFound": "Aucun appareil trouvé.", + "activeNow": "Actif Maintenant", + "logout": "Déconnexion" + }, "restapi": { "sectionTitle": "Token d'accès à l'API", "description": "Cela vous permet de générer un token API que vous pouvez utiliser pour vous authentifier et accéder aux services API de notre application. Avec un token valide, vous pouvez adresser des requêtes à l'API et interagir avec les fonctionnalités de l'application par programmation.", diff --git a/src/locales/no/common.json b/src/locales/no/common.json index 7755398b..bdd7759f 100644 --- a/src/locales/no/common.json +++ b/src/locales/no/common.json @@ -719,6 +719,14 @@ } } }, + "userDevices": { + "connectedDevices": "Tilkoblede Enheter", + "logoutAll": "Logg ut Alle", + "accessDenied": "Tilgang Nektet", + "noDevicesFound": "Ingen enheter funnet.", + "activeNow": "Aktiv Nå", + "logout": "Logg ut" + }, "restapi": { "sectionTitle": "API-tilgangstokens", "description": "API-tilgangstokens brukes for å få tilgang til ZTNETs offentlige API.", diff --git a/src/locales/pl/common.json b/src/locales/pl/common.json index 29efa9ca..95a436df 100644 --- a/src/locales/pl/common.json +++ b/src/locales/pl/common.json @@ -719,6 +719,14 @@ } } }, + "userDevices": { + "connectedDevices": "Podłączone Urządzenia", + "logoutAll": "Wyloguj Wszystkie", + "accessDenied": "Odmowa Dostępu", + "noDevicesFound": "Nie znaleziono urządzeń.", + "activeNow": "Aktywny Teraz", + "logout": "Wyloguj" + }, "restapi": { "sectionTitle": "Tokeny dostępowe API", "description": "Dzięki temu możesz wygenerować token API, którego możesz użyć do uwierzytelnienia i uzyskania dostępu do usług API naszej aplikacji. Mając ważny token, możesz wysyłać żądania do API i programowo wchodzić w interakcję z funkcjami aplikacji.", diff --git a/src/locales/zh-tw/common.json b/src/locales/zh-tw/common.json index 5579aa4e..ddd62800 100644 --- a/src/locales/zh-tw/common.json +++ b/src/locales/zh-tw/common.json @@ -719,6 +719,14 @@ } } }, + "userDevices": { + "connectedDevices": "已連接裝置", + "logoutAll": "全部登出", + "accessDenied": "拒絕存取", + "noDevicesFound": "未找到裝置。", + "activeNow": "目前使用中", + "logout": "登出" + }, "restapi": { "sectionTitle": "API存取Token", "description": "API存取Token用於訪問ZTNET公共API。", diff --git a/src/locales/zh/common.json b/src/locales/zh/common.json index 7f4547b9..921516ce 100644 --- a/src/locales/zh/common.json +++ b/src/locales/zh/common.json @@ -719,6 +719,14 @@ } } }, + "userDevices": { + "connectedDevices": "已连接设备", + "logoutAll": "全部登出", + "accessDenied": "访问被拒绝", + "noDevicesFound": "未找到设备。", + "activeNow": "当前活跃", + "logout": "登出" + }, "restapi": { "sectionTitle": "API访问令牌", "description": "API访问令牌用于访问ZTNET公共API。", From b854cca01bffb223128133c4ac27acf2e0e3041b Mon Sep 17 00:00:00 2001 From: Bernt Christian Egeland Date: Sat, 24 Aug 2024 12:17:51 +0000 Subject: [PATCH 20/24] token --- src/server/callbacks/jwt.ts | 28 ++++++++++------------------ 1 file changed, 10 insertions(+), 18 deletions(-) diff --git a/src/server/callbacks/jwt.ts b/src/server/callbacks/jwt.ts index 2cc00c35..f221090f 100644 --- a/src/server/callbacks/jwt.ts +++ b/src/server/callbacks/jwt.ts @@ -59,25 +59,17 @@ export function jwtCallback( return token; } - if (account?.provider === "oauth") { - const userAgent = req.headers["user-agent"]; - const deviceId = generateDeviceId(userAgent as string, user.id); - token.accessToken = account.accessToken; + if (user) { + const { id, name, email, role } = user; + Object.assign(token, { id, name, email, role }); - // Update token with user information - token.id = user.id; - token.name = user.name; - token.email = user.email; - token.role = user.role; - token.deviceId = deviceId; - } - - if (account?.provider === "credentials" && user?.id) { - token.id = user.id; - token.name = user.name; - token.email = user.email; - token.role = user.role; - token.deviceId = user.deviceId; + if (account?.provider === "oauth") { + const userAgent = req.headers["user-agent"]; + token.deviceId = generateDeviceId(userAgent as string, id); + token.accessToken = account.accessToken; + } else if (account?.provider === "credentials") { + token.deviceId = user.deviceId; + } } // Check if the device still exists and is valid From db078524014ed74bed4c47e82abd9aa0ac995970 Mon Sep 17 00:00:00 2001 From: Bernt Christian Egeland Date: Sat, 24 Aug 2024 12:26:14 +0000 Subject: [PATCH 21/24] logout --- src/components/auth/userDevices.tsx | 2 +- src/server/api/routers/authRouter.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/components/auth/userDevices.tsx b/src/components/auth/userDevices.tsx index a4251511..777706eb 100644 --- a/src/components/auth/userDevices.tsx +++ b/src/components/auth/userDevices.tsx @@ -107,7 +107,7 @@ const ListUserDevices: React.FC<{ devices: UserDevice[] }> = ({ devices }) => {
diff --git a/src/server/auth.ts b/src/server/auth.ts index 47f60750..e8042098 100644 --- a/src/server/auth.ts +++ b/src/server/auth.ts @@ -125,18 +125,17 @@ export const getAuthOptions = ( token: process.env.OAUTH_ACCESS_TOKEN_URL, userinfo: process.env.OAUTH_USER_INFO, idToken: true, - profile(profile, tokens) { + profile(profile) { return Promise.resolve({ id: profile.sub || profile.id.toString(), name: profile.name || profile.login || profile.username, email: profile.email, // image: profile.picture || profile.avatar_url || profile.image_url, lastLogin: new Date().toISOString(), - userAgent: tokens.userAgent, role: "USER", }); }, - } as const, // Add 'as const' to make the provider type narrow to the exact expected values + } as const, ] : []), CredentialsProvider({ diff --git a/src/server/callbacks/signin.ts b/src/server/callbacks/signin.ts index 01c4aefe..79d53228 100644 --- a/src/server/callbacks/signin.ts +++ b/src/server/callbacks/signin.ts @@ -153,6 +153,7 @@ export function signInCallback( const userAgent = account.provider === "credentials" ? user?.userAgent : req.headers["user-agent"]; + if (userAgent) { const deviceInfo = createDeviceInfo( userAgent as string, @@ -170,150 +171,3 @@ export function signInCallback( } }; } - -// import { ErrorCode } from "~/utils/errorCode"; -// import { prisma } from "../db"; -// import { generateDeviceId } from "~/utils/devices"; -// import UAParser from "ua-parser-js"; -// import { isRunningInDocker } from "~/utils/docker"; -// import { IncomingMessage } from "http"; - -// export function signInCallback( -// req: IncomingMessage & { cookies: Partial<{ [key: string]: string }> }, -// ) { -// return async function signIn({ user, account }) { -// // Check if the user already exists -// const existingUser = await prisma.user.findUnique({ -// where: { -// email: user.email, -// }, -// }); - -// // check if the user is allowed to sign up. -// if (!existingUser) { -// const siteSettings = await prisma.globalOptions.findFirst(); - -// if (!siteSettings?.enableRegistration) { -// // route to error page -// return `/auth/login?error=${ErrorCode.RegistrationDisabled}`; -// } -// } - -// if (account.provider === "credentials") { -// if (existingUser) { -// // User exists, update last login or other fields as necessary -// await prisma.user.update({ -// where: { -// id: existingUser.id, -// }, -// data: { -// lastLogin: new Date().toISOString(), -// }, -// }); - -// // Update device information -// if (user?.userAgent) { -// const deviceId = generateDeviceId(user.userAgent as string, existingUser.id); -// const ua = new UAParser(user.userAgent as string); - -// const deviceInfo = { -// deviceId, -// userId: existingUser.id as string, -// deviceType: ua.getDevice().type || "desktop", -// browser: ua.getBrowser().name || "Unknown", -// os: ua.getOS().name || "Unknown", -// lastActive: new Date(), -// }; - -// await prisma.userDevice.upsert({ -// where: { deviceId }, -// update: { -// lastActive: deviceInfo.lastActive, -// isActive: true, -// }, -// create: deviceInfo, -// }); - -// user.deviceId = deviceId; -// } -// } - -// return true; -// } -// if (account.provider === "oauth") { -// const userAgent = req.headers["user-agent"]; -// // Check if the user already exists -// const existingUser = await prisma.user.findUnique({ -// where: { -// email: user.email, -// }, -// }); -// // Update device information -// const deviceId = generateDeviceId(userAgent as string, existingUser.id); -// const ua = new UAParser(user.userAgent as string); - -// const deviceInfo = { -// deviceId, -// userId: existingUser.id as string, -// deviceType: ua.getDevice().type || "desktop", -// browser: ua.getBrowser().name || "Unknown", -// os: ua.getOS().name || "Unknown", -// lastActive: new Date(), -// }; - -// await prisma.userDevice.upsert({ -// where: { deviceId }, -// update: { -// lastActive: deviceInfo.lastActive, -// isActive: true, -// }, -// create: deviceInfo, -// }); - -// if (existingUser) { -// // User exists, update last login or other fields as necessary -// await prisma.user.update({ -// where: { -// id: existingUser.id, -// }, -// data: { -// lastLogin: new Date().toISOString(), -// }, -// }); -// } else { -// // User does not exist, create new user -// const userCount = await prisma.user.count(); -// const defaultUserGroup = await prisma.userGroup.findFirst({ -// where: { -// isDefault: true, -// }, -// }); - -// await prisma.user.create({ -// data: { -// name: user.name, -// email: user.email, -// lastLogin: new Date().toISOString(), -// role: userCount === 0 ? "ADMIN" : "USER", -// image: user.image, -// userGroupId: defaultUserGroup?.id, -// options: { -// create: { -// localControllerUrl: isRunningInDocker() -// ? "http://zerotier:9993" -// : "http://127.0.0.1:9993", -// }, -// }, -// }, -// select: { -// id: true, -// name: true, -// email: true, -// role: true, -// }, -// }); -// } -// return true; -// } -// }; -// } diff --git a/src/utils/devices.ts b/src/utils/devices.ts index 64c20864..abdfe652 100644 --- a/src/utils/devices.ts +++ b/src/utils/devices.ts @@ -4,10 +4,12 @@ export function generateDeviceId(userAgent: string, userId: string): string { const ua = new UAParser(userAgent); const deviceType = ua.getDevice().type || "desktop"; const browser = ua.getBrowser().name || "Unknown"; + const browserVersion = ua.getBrowser().version || "Unknown"; const os = ua.getOS().name || "Unknown"; + const osVersion = ua.getOS().version || "Unknown"; const version = ua.getOS().version || "Unknown"; - const deviceInfo = `${userId}-${deviceType}-${browser}-${os}-${version}`; + const deviceInfo = `${userId}-${deviceType}-${browser}-${browserVersion}-${os}-${osVersion}-${version}`; let hash = 0; for (let i = 0; i < deviceInfo.length; i++) { From 149ea3b572ff3d176d39f7bafe7516c318090d0b Mon Sep 17 00:00:00 2001 From: Bernt Christian Egeland Date: Mon, 26 Aug 2024 07:53:15 +0000 Subject: [PATCH 23/24] tests --- src/__tests__/pages/auth/signin.test.tsx | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/__tests__/pages/auth/signin.test.tsx b/src/__tests__/pages/auth/signin.test.tsx index e7602b64..3088b568 100644 --- a/src/__tests__/pages/auth/signin.test.tsx +++ b/src/__tests__/pages/auth/signin.test.tsx @@ -193,13 +193,7 @@ describe("LoginPage", () => { const oauthButton = screen.getByRole("button", { name: /Sign in with OAuth/i }); await userEvent.click(oauthButton); - expect(signIn).toHaveBeenCalledWith( - "oauth", - { redirect: false }, - expect.objectContaining({ - userAgent: expect.any(String), - }), - ); + expect(signIn).toHaveBeenCalledWith("oauth", { redirect: false }); }); it("Enter 2FA code", async () => { From 40a67597dc0f60e19b0ea5034aea07633b8f3b9a Mon Sep 17 00:00:00 2001 From: Bernt Christian Egeland Date: Mon, 26 Aug 2024 10:11:27 +0000 Subject: [PATCH 24/24] refactor parseUa --- .../migration.sql | 2 + prisma/schema.prisma | 26 ++++++----- src/components/auth/userDevices.tsx | 20 ++------- src/server/callbacks/jwt.ts | 6 ++- src/server/callbacks/signin.ts | 12 +++-- src/utils/devices.ts | 45 +++++++++++++------ 6 files changed, 59 insertions(+), 52 deletions(-) rename prisma/migrations/{20240824083718_user_device => 20240826093637_user_device}/migration.sql (92%) diff --git a/prisma/migrations/20240824083718_user_device/migration.sql b/prisma/migrations/20240826093637_user_device/migration.sql similarity index 92% rename from prisma/migrations/20240824083718_user_device/migration.sql rename to prisma/migrations/20240826093637_user_device/migration.sql index 8002887b..01ec09ad 100644 --- a/prisma/migrations/20240824083718_user_device/migration.sql +++ b/prisma/migrations/20240826093637_user_device/migration.sql @@ -7,7 +7,9 @@ CREATE TABLE "UserDevice" ( "location" TEXT, "deviceId" TEXT NOT NULL, "browser" TEXT NOT NULL, + "browserVersion" TEXT NOT NULL, "os" TEXT NOT NULL, + "osVersion" TEXT NOT NULL, "lastActive" TIMESTAMP(3) NOT NULL, "isActive" BOOLEAN NOT NULL DEFAULT true, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 496947bb..a330b6ae 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -275,18 +275,20 @@ model User { } model UserDevice { - id String @id @default(cuid()) - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - deviceType String - ipAddress String? - location String? - deviceId String @unique - browser String - os String - lastActive DateTime - isActive Boolean @default(true) - createdAt DateTime @default(now()) + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + deviceType String + ipAddress String? + location String? + deviceId String @unique + browser String + browserVersion String + os String + osVersion String + lastActive DateTime + isActive Boolean @default(true) + createdAt DateTime @default(now()) @@index([userId]) @@index([deviceId]) diff --git a/src/components/auth/userDevices.tsx b/src/components/auth/userDevices.tsx index ee572fb6..788e5f72 100644 --- a/src/components/auth/userDevices.tsx +++ b/src/components/auth/userDevices.tsx @@ -1,5 +1,3 @@ -import { useEffect, useState } from "react"; -import UAParser from "ua-parser-js"; import type { UserDevice } from "@prisma/client"; import cn from "classnames"; import Smartphone from "~/icons/smartphone"; @@ -8,6 +6,7 @@ import Tablet from "~/icons/tablet"; import { api } from "~/utils/api"; import { useTranslations } from "next-intl"; import { signOut } from "next-auth/react"; +import { generateDeviceId, parseUA } from "~/utils/devices"; const formatLastActive = (date) => { return new Date(date).toLocaleString("no-NO"); @@ -50,7 +49,7 @@ const DeviceIcon = ({ deviceType }: { deviceType: string }) => { const ListUserDevices: React.FC<{ devices: UserDevice[] }> = ({ devices }) => { const t = useTranslations(); - const { refetch } = api.auth.me.useQuery(); + const { data: me, refetch } = api.auth.me.useQuery(); const { mutate: deleteUserDevice, isLoading: deleteLoading } = api.auth.deleteUserDevice.useMutation({ @@ -60,22 +59,9 @@ const ListUserDevices: React.FC<{ devices: UserDevice[] }> = ({ devices }) => { }, }); - const [currentDeviceInfo, setCurrentDeviceInfo] = useState<{ - browser: string; - os: string; - } | null>(null); - - useEffect(() => { - const ua = new UAParser(navigator.userAgent); - setCurrentDeviceInfo({ - browser: ua.getBrowser().name || "Unknown", - os: ua.getOS().name || "Unknown", - }); - }, []); - const isCurrentDevice = (device: UserDevice) => { return ( - device.browser === currentDeviceInfo?.browser && device.os === currentDeviceInfo?.os + device?.deviceId === generateDeviceId(parseUA(navigator.userAgent), me.id)?.deviceId ); }; diff --git a/src/server/callbacks/jwt.ts b/src/server/callbacks/jwt.ts index f221090f..54e8f030 100644 --- a/src/server/callbacks/jwt.ts +++ b/src/server/callbacks/jwt.ts @@ -1,6 +1,6 @@ import { IncomingMessage } from "http"; import { prisma } from "../db"; -import { generateDeviceId } from "~/utils/devices"; +import { generateDeviceId, parseUA } from "~/utils/devices"; export function jwtCallback( req: IncomingMessage & { cookies: Partial<{ [key: string]: string }> }, @@ -65,7 +65,9 @@ export function jwtCallback( if (account?.provider === "oauth") { const userAgent = req.headers["user-agent"]; - token.deviceId = generateDeviceId(userAgent as string, id); + const { deviceId } = generateDeviceId(parseUA(userAgent), id); + token.deviceId = deviceId; + token.accessToken = account.accessToken; } else if (account?.provider === "credentials") { token.deviceId = user.deviceId; diff --git a/src/server/callbacks/signin.ts b/src/server/callbacks/signin.ts index 79d53228..97b3063a 100644 --- a/src/server/callbacks/signin.ts +++ b/src/server/callbacks/signin.ts @@ -1,7 +1,6 @@ import { ErrorCode } from "~/utils/errorCode"; import { prisma } from "../db"; -import { generateDeviceId } from "~/utils/devices"; -import UAParser from "ua-parser-js"; +import { generateDeviceId, parseUA } from "~/utils/devices"; import { isRunningInDocker } from "~/utils/docker"; import { IncomingMessage } from "http"; import { User } from "@prisma/client"; @@ -12,7 +11,9 @@ interface DeviceInfo { userId: string; deviceType: string; browser: string; + browserVersion: string; os: string; + osVersion: string; lastActive: Date; } @@ -78,16 +79,13 @@ function createDeviceInfo( userId: string, ipAddress: string, ): DeviceInfo { - const deviceId = generateDeviceId(userAgent, userId); - const ua = new UAParser(userAgent); + const { deviceId, parsedUA } = generateDeviceId(parseUA(userAgent), userId); return { + ...parsedUA, deviceId, ipAddress, userId, - deviceType: ua.getDevice().type || "desktop", - browser: ua.getBrowser().name || "Unknown", - os: ua.getOS().name || "Unknown", lastActive: new Date(), }; } diff --git a/src/utils/devices.ts b/src/utils/devices.ts index abdfe652..72100234 100644 --- a/src/utils/devices.ts +++ b/src/utils/devices.ts @@ -1,22 +1,39 @@ import UAParser from "ua-parser-js"; +export interface ParsedUA { + deviceType: string; + browser: string; + browserVersion: string; + os: string; + osVersion: string; +} -export function generateDeviceId(userAgent: string, userId: string): string { - const ua = new UAParser(userAgent); - const deviceType = ua.getDevice().type || "desktop"; - const browser = ua.getBrowser().name || "Unknown"; - const browserVersion = ua.getBrowser().version || "Unknown"; - const os = ua.getOS().name || "Unknown"; - const osVersion = ua.getOS().version || "Unknown"; - const version = ua.getOS().version || "Unknown"; - - const deviceInfo = `${userId}-${deviceType}-${browser}-${browserVersion}-${os}-${osVersion}-${version}`; - +interface ReturnDeviceId { + deviceId: string; + parsedUA: ParsedUA; +} +export function generateDeviceId(parsedUA: ParsedUA, userId: string): ReturnDeviceId { + const deviceInfoString = `${userId}-${parsedUA.deviceType}-${parsedUA.browser}-${parsedUA.browserVersion}-${parsedUA.os}-${parsedUA.osVersion}`; let hash = 0; - for (let i = 0; i < deviceInfo.length; i++) { - const char = deviceInfo.charCodeAt(i); + for (let i = 0; i < deviceInfoString.length; i++) { + const char = deviceInfoString.charCodeAt(i); hash = (hash << 5) - hash + char; hash = hash & hash; } - return Math.abs(hash).toString(16); + return { + deviceId: Math.abs(hash).toString(16), + parsedUA, + }; +} + +export function parseUA(userAgent: string): ParsedUA { + const ua = new UAParser(userAgent); + + return { + deviceType: ua.getDevice().type || "desktop", + browser: ua.getBrowser().name || "Unknown", + browserVersion: ua.getBrowser().version || "Unknown", + os: ua.getOS().name || "Unknown", + osVersion: ua.getOS().version || "Unknown", + }; }