diff --git a/package.json b/package.json index 31bdc5ddf..e79cc4416 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "studio": "prisma studio" }, "dependencies": { + "@fingerprintjs/fingerprintjs": "^4.5.1", "@hookform/resolvers": "^3.6.0", "@icons-pack/react-simple-icons": "^9.4.0", "@prisma/client": "^5.18.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d65c62289..1f308b529 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@fingerprintjs/fingerprintjs': + specifier: ^4.5.1 + version: 4.5.1 '@hookform/resolvers': specifier: ^3.6.0 version: 3.9.0(react-hook-form@7.52.2(react@18.3.1)) @@ -438,6 +441,9 @@ packages: resolution: {integrity: sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@fingerprintjs/fingerprintjs@4.5.1': + resolution: {integrity: sha512-hKJaRoLHNeUUPhb+Md3pTlY/Js2YR4aXjroaDHpxrjoM8kGnEFyZVZxXo6l3gRyKnQN52Uoqsycd3M73eCdMzw==} + '@fisch0920/medium-zoom@1.0.7': resolution: {integrity: sha512-hPUrgVM/QvsZdZzDTPyL1C1mOtEw03RqTLmK7ZlJ8S/64u4O4O5BvPvjB/9kyLtE6iVaS9UDRAMSwmM9uh2JIw==} @@ -4573,6 +4579,10 @@ snapshots: '@eslint/js@8.57.0': {} + '@fingerprintjs/fingerprintjs@4.5.1': + dependencies: + tslib: 2.6.3 + '@fisch0920/medium-zoom@1.0.7': {} '@floating-ui/core@1.6.7': diff --git a/prisma/migrations/20241209080021_add_device_fingerprint_column_in_user_table/migration.sql b/prisma/migrations/20241209080021_add_device_fingerprint_column_in_user_table/migration.sql new file mode 100644 index 000000000..ca5bf7400 --- /dev/null +++ b/prisma/migrations/20241209080021_add_device_fingerprint_column_in_user_table/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "deviceFingerprint" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ca8e4431c..a72d843b5 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -144,6 +144,7 @@ model User { name String? email String? @unique token String? + deviceFingerprint String? sessions Session[] purchases UserPurchases[] videoProgress VideoProgress[] diff --git a/src/actions/deviceFingerprintValidation/index.ts b/src/actions/deviceFingerprintValidation/index.ts new file mode 100644 index 000000000..e94479d6b --- /dev/null +++ b/src/actions/deviceFingerprintValidation/index.ts @@ -0,0 +1,27 @@ +'use server'; +import db from '@/db'; +import { authOptions } from '@/lib/auth'; +import { getServerSession } from 'next-auth'; + +export const validateFingerPrint = async (fingerprint: any) => { + const session = await getServerSession(authOptions); + if (!session || !session.user) throw new Error("User is not logged in"); + + if (!fingerprint) return { error: 'Fingerprint is not provided' }; + + const user = await db.user.findFirst({ + where: { + email: session.user.email, + }, + select: { + deviceFingerprint: true, + token: true + } + }); + + if (fingerprint !== user?.deviceFingerprint) { + throw new Error('Invalid fingerprint - Session expired'); + } + + return { message: 'Fingerprint validated', fingerprint }; +}; \ No newline at end of file diff --git a/src/components/Signin.tsx b/src/components/Signin.tsx index 612e67a56..afbe5e8b0 100644 --- a/src/components/Signin.tsx +++ b/src/components/Signin.tsx @@ -8,6 +8,7 @@ import { useRouter } from 'next/navigation'; import React, { useRef, useState, useEffect } from 'react'; import { toast } from 'sonner'; import { motion } from 'framer-motion'; +import { generateFingerprint } from '@/lib/utils'; const emailDomains = [ 'gmail.com', @@ -123,9 +124,11 @@ const Signin = () => { return; } setCheckingPassword(true); + const fingerprint = await generateFingerprint(); const res = await signIn('credentials', { username: email.current, password: password.current, + deviceFingerprint: fingerprint, redirect: false, }); diff --git a/src/components/VideoPlayer2.tsx b/src/components/VideoPlayer2.tsx index 1561175d7..01899885f 100644 --- a/src/components/VideoPlayer2.tsx +++ b/src/components/VideoPlayer2.tsx @@ -9,14 +9,15 @@ import 'videojs-seek-buttons/dist/videojs-seek-buttons.css'; import 'videojs-mobile-ui'; import 'videojs-sprite-thumbnails'; import 'videojs-seek-buttons'; -import { handleMarkAsCompleted } from '@/lib/utils'; -import { useSearchParams } from 'next/navigation'; +import { generateFingerprint, handleMarkAsCompleted } from '@/lib/utils'; +import { useRouter, useSearchParams } from 'next/navigation'; import './QualitySelectorControllBar'; import { YoutubeRenderer } from './YoutubeRenderer'; import { toast } from 'sonner'; import { createRoot } from 'react-dom/client'; import { PictureInPicture2 } from 'lucide-react'; import { AppxVideoPlayer } from './AppxVideoPlayer'; +import { validateFingerPrint } from '@/actions/deviceFingerprintValidation'; // todo correct types interface VideoPlayerProps { @@ -46,6 +47,7 @@ export const VideoPlayer: FunctionComponent = ({ const videoRef = useRef(null); const playerRef = useRef(null); const [player, setPlayer] = useState(null); + const router = useRouter(); const searchParams = useSearchParams(); const vidUrl = options.sources[0].src; @@ -134,6 +136,35 @@ export const VideoPlayer: FunctionComponent = ({ }; }, [player]); + useEffect(() => { + let timer: any; + const checkFingerprint = async () => { + try { + const fingerprint: string = await generateFingerprint(); + timer = setInterval(async () => { + try { + await validateFingerPrint(fingerprint); + } catch (error) { + console.error('Fingerprint validation failed:', error); + router.push('/invalidsession'); + } + }, 1000 * 60 * 2); + + return () => timer; + } catch (error) { + console.error('Error during fingerprint generation or validation:', error); + } + }; + + checkFingerprint(); + + return () => { + if (timer) { + clearInterval(timer); + } + }; + }, []); + useEffect(() => { const t = searchParams.get('timestamp'); if (contentId && player && !t) { diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 9f52f422a..16948cc4a 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -159,6 +159,7 @@ export const authOptions = { }, data: { token: jwt, + deviceFingerprint: credentials?.deviceFingerprint }, }); diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 8cfa4a966..00eb1e7a6 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -4,6 +4,8 @@ import { type ClassValue, clsx } from 'clsx'; import { twMerge } from 'tailwind-merge'; import Player from 'video.js/dist/types/player'; import { Bookmark } from '@prisma/client'; +import FingerprintJS from '@fingerprintjs/fingerprintjs'; + export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } @@ -426,3 +428,17 @@ export function getFilteredContent( return []; } + +export const generateFingerprint = async (): Promise => { + try { + const fp = await FingerprintJS.load(); + const result = await fp.get(); + const fingerprint: string = result.visitorId; + + return fingerprint; + } catch (error) { + console.error("Error generating fingerprint:", error); + throw new Error("Failed to generate fingerprint"); + } +}; +