diff --git a/.env.example b/.env.example index 0ffe7de64..66d7ef41d 100644 --- a/.env.example +++ b/.env.example @@ -14,3 +14,4 @@ BOT_TOKEN = "123" GUILD_ID = "123" LOCAL_CMS_PROVIDER = true CACHE_EXPIRE_S = 10 + diff --git a/package.json b/package.json index 7b7a199ec..06325a1f7 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "dayjs": "^1.11.10", "discord-oauth2": "^2.11.0", "discord.js": "^14.14.1", + "jose": "^5.2.2", "jsonwebtoken": "^9.0.2", "lucide-react": "^0.321.0", "next": "14.0.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 66e4db65c..d4870d0de 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,6 +59,9 @@ dependencies: discord.js: specifier: ^14.14.1 version: 14.14.1 + jose: + specifier: ^5.2.2 + version: 5.2.2 jsonwebtoken: specifier: ^9.0.2 version: 9.0.2 @@ -189,7 +192,7 @@ packages: '@panva/hkdf': 1.1.1 '@types/cookie': 0.6.0 cookie: 0.6.0 - jose: 5.2.0 + jose: 5.2.2 oauth4webapi: 2.8.1 preact: 10.11.3 preact-render-to-string: 5.2.3(preact@10.11.3) @@ -2851,8 +2854,8 @@ packages: resolution: {integrity: sha512-W+oqK4H+r5sITxfxpSU+MMdr/YSWGvgZMQDIsNoBDGGy4i7GBPTtvFKibQzW06n3U3TqHjhvBJsirShsEJ6eeQ==} dev: false - /jose@5.2.0: - resolution: {integrity: sha512-oW3PCnvyrcm1HMvGTzqjxxfnEs9EoFOFWi2HsEGhlFVOXxTE3K9GKWVMFoFw06yPUqwpvEWic1BmtUZBI/tIjw==} + /jose@5.2.2: + resolution: {integrity: sha512-/WByRr4jDcsKlvMd1dRJnPfS1GVO3WuKyaurJ/vvXcOaUQO8rnNObCQMlv/5uCceVQIq5Q4WLF44ohsdiTohdg==} dev: false /js-cookie@2.2.1: diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b60dbd284..a71046d63 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -117,6 +117,7 @@ model User { id String @id @default(cuid()) name String? email String? @unique + token String? sessions Session[] purchases UserPurchases[] videoProgress VideoProgress[] diff --git a/src/actions/user/index.ts b/src/actions/user/index.ts new file mode 100644 index 000000000..d85f351e7 --- /dev/null +++ b/src/actions/user/index.ts @@ -0,0 +1,27 @@ +'use server'; +import db from '@/db'; + +export const logoutUser = async (email: string, adminPassword: string) => { + if (adminPassword !== process.env.ADMIN_SECRET) { + return { error: 'Unauthorized' }; + } + + const user = await db.user.findFirst({ + where: { + email, + }, + }); + if (!user) { + return { message: 'User not found' }; + } + await db.user.update({ + where: { + id: user.id, + }, + data: { + token: '', + }, + }); + + return { message: 'User logged out' }; +}; diff --git a/src/app/admin/user/LogoutUser.tsx b/src/app/admin/user/LogoutUser.tsx new file mode 100644 index 000000000..9ff93c25f --- /dev/null +++ b/src/app/admin/user/LogoutUser.tsx @@ -0,0 +1,63 @@ +'use client'; +import { logoutUser } from '@/actions/user'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@radix-ui/react-dropdown-menu'; +import React from 'react'; +import { toast } from 'sonner'; + +const LogoutUserComp = () => { + const formRef = React.useRef(null); + + const handlLogout = async (e: React.FormEvent) => { + e.preventDefault(); + const formData = new FormData(e.target as HTMLFormElement); + + const email = formData.get('email') as string; + const adminPassword = formData.get('adminPassword') as string; + const res = await logoutUser(email, adminPassword); + toast.info(res.message); + }; + return ( +
+
+
+
Logout the user
+
+ Enter the information below to logout the user +
+
+ +
+ + +
+
+ + +
+
+ +
+
+
+ ); +}; + +export default LogoutUserComp; diff --git a/src/app/admin/user/page.tsx b/src/app/admin/user/page.tsx new file mode 100644 index 000000000..e7d534001 --- /dev/null +++ b/src/app/admin/user/page.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import LogoutUserComp from './LogoutUser'; + +const UserAdminPage = () => { + return ( +
+ +
+ ); +}; + +export default UserAdminPage; diff --git a/src/app/api/user/route.ts b/src/app/api/user/route.ts new file mode 100644 index 000000000..22e8cc269 --- /dev/null +++ b/src/app/api/user/route.ts @@ -0,0 +1,15 @@ +import { type NextRequest, NextResponse } from 'next/server'; +import db from '@/db'; + +export async function GET(req: NextRequest) { + const url = new URL(req.url); + const token = url.searchParams.get('token'); + const user = await db.user.findFirst({ + where: { + token, + }, + }); + return NextResponse.json({ + user, + }); +} diff --git a/src/app/invalidsession/page.tsx b/src/app/invalidsession/page.tsx new file mode 100644 index 000000000..4b1ffc37f --- /dev/null +++ b/src/app/invalidsession/page.tsx @@ -0,0 +1,28 @@ +'use client'; + +import { signOut } from 'next-auth/react'; +import React, { useEffect } from 'react'; +import { Toaster } from '@/components/ui/sonner'; +import { toast } from 'sonner'; + +const page = () => { + useEffect(() => { + signOut({ + callbackUrl: '/signin', + }); + toast('Too many devices connected. Logging out!', { + action: { + label: 'Close', + onClick: () => console.log('Closed Toast'), + }, + }); + }, []); + + return ( +
+ +
+ ); +}; + +export default page; diff --git a/src/lib/auth.ts b/src/lib/auth.ts index ee9f7f01e..f9fdb0da0 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,6 +1,20 @@ import db from '@/db'; import CredentialsProvider from 'next-auth/providers/credentials'; +import { SignJWT, importJWK } from 'jose'; +const generateJWT = async (payload: any) => { + const secret = process.env.JWT_SECRET || 'secret'; + + const jwk = await importJWK({ k: secret, alg: 'HS256', kty: 'oct' }); + + const jwt = await new SignJWT(payload) + .setProtectedHeader({ alg: 'HS256' }) + .setIssuedAt() + .setExpirationTime('365d') + .sign(jwk); + + return jwt; +}; async function validateUser( email: string, password: string, @@ -71,6 +85,9 @@ export const authOptions = { credentials.password, ); if (user.data) { + const jwt = await generateJWT({ + id: user.data.userid, + }); try { await db.user.upsert({ where: { @@ -80,11 +97,13 @@ export const authOptions = { id: user.data.userid, name: user.data.name, email: credentials.username, + token: jwt, }, update: { id: user.data.userid, name: user.data.name, email: credentials.username, + token: jwt, }, }); } catch (e) { @@ -95,6 +114,7 @@ export const authOptions = { id: user.data.userid, name: user.data.name, email: credentials.username, + token: jwt, }; } // Return null if user data could not be retrieved @@ -111,12 +131,15 @@ export const authOptions = { session: async ({ session, token }: any) => { if (session?.user) { session.user.id = token.uid; + session.user.jwtToken = token.jwtToken; } + return session; }, jwt: async ({ user, token }: any) => { if (user) { token.uid = user.id; + token.jwtToken = user.token; } return token; }, diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 000000000..605cbcea6 --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,21 @@ +import { withAuth } from 'next-auth/middleware'; +import { NextResponse } from 'next/server'; + +export const config = { + matcher: ['/courses/:path*'], +}; + +export default withAuth(async (req) => { + const token = req.nextauth.token; + if (!token) { + return NextResponse.redirect(new URL('/invalidsession', req.url)); + } + const user = await fetch( + `${process.env.NEXT_PUBLIC_BASE_URL_LOCAL}/api/user?token=${token.jwtToken}`, + ); + + const json = await user.json(); + if (!json.user) { + return NextResponse.redirect(new URL('/invalidsession', req.url)); + } +});