feat(api): add auth middleware to protect game endpoints
- Add requireAuth middleware using Better Auth session validation - Apply to all game routes (start, answer) - Unauthenticated requests return 401
This commit is contained in:
parent
91a3112d8b
commit
a3685a9e68
13 changed files with 196 additions and 24 deletions
|
|
@ -13,9 +13,11 @@
|
|||
"@glossa/db": "workspace:*",
|
||||
"@glossa/shared": "workspace:*",
|
||||
"better-auth": "^1.6.2",
|
||||
"cors": "^2.8.6",
|
||||
"express": "^5.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/supertest": "^7.2.0",
|
||||
"supertest": "^7.2.2",
|
||||
|
|
|
|||
|
|
@ -4,10 +4,12 @@ import { toNodeHandler } from "better-auth/node";
|
|||
import { auth } from "./lib/auth.js";
|
||||
import { apiRouter } from "./routes/apiRouter.js";
|
||||
import { errorHandler } from "./middleware/errorHandler.js";
|
||||
import cors from "cors";
|
||||
|
||||
export function createApp() {
|
||||
const app: Express = express();
|
||||
|
||||
app.use(cors({ origin: "http://localhost:5173", credentials: true }));
|
||||
app.all("/api/auth/*splat", toNodeHandler(auth));
|
||||
app.use(express.json());
|
||||
app.use("/api/v1", apiRouter);
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import { betterAuth } from "better-auth";
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import { db } from "@glossa/db";
|
||||
import * as schema from "@glossa/db/schema";
|
||||
|
||||
export const auth = betterAuth({
|
||||
database: drizzleAdapter(db, { provider: "pg" }),
|
||||
database: drizzleAdapter(db, { provider: "pg", schema }),
|
||||
trustedOrigins: ["http://localhost:5173"],
|
||||
socialProviders: {
|
||||
google: {
|
||||
clientId: process.env["GOOGLE_CLIENT_ID"] as string,
|
||||
|
|
|
|||
20
apps/api/src/middleware/authMiddleware.ts
Normal file
20
apps/api/src/middleware/authMiddleware.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import type { Request, Response, NextFunction } from "express";
|
||||
import { fromNodeHeaders } from "better-auth/node";
|
||||
import { auth } from "../lib/auth.js";
|
||||
|
||||
export const requireAuth = async (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) => {
|
||||
const session = await auth.api.getSession({
|
||||
headers: fromNodeHeaders(req.headers),
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
res.status(401).json({ success: false, error: "Unauthorized" });
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
|
|
@ -1,8 +1,10 @@
|
|||
import express from "express";
|
||||
import type { Router } from "express";
|
||||
import { createGame, submitAnswer } from "../controllers/gameController.js";
|
||||
import { requireAuth } from "../middleware/authMiddleware.js";
|
||||
|
||||
export const gameRouter: Router = express.Router();
|
||||
|
||||
gameRouter.use(requireAuth);
|
||||
gameRouter.post("/start", createGame);
|
||||
gameRouter.post("/answer", submitAnswer);
|
||||
|
|
|
|||
7
apps/web/src/lib/auth-client.ts
Normal file
7
apps/web/src/lib/auth-client.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { createAuthClient } from "better-auth/react";
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: "http://localhost:3000",
|
||||
});
|
||||
|
||||
export const { signIn, signOut, useSession } = authClient;
|
||||
|
|
@ -10,6 +10,7 @@
|
|||
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as PlayRouteImport } from './routes/play'
|
||||
import { Route as LoginRouteImport } from './routes/login'
|
||||
import { Route as AboutRouteImport } from './routes/about'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
|
||||
|
|
@ -18,6 +19,11 @@ const PlayRoute = PlayRouteImport.update({
|
|||
path: '/play',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const LoginRoute = LoginRouteImport.update({
|
||||
id: '/login',
|
||||
path: '/login',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AboutRoute = AboutRouteImport.update({
|
||||
id: '/about',
|
||||
path: '/about',
|
||||
|
|
@ -32,30 +38,34 @@ const IndexRoute = IndexRouteImport.update({
|
|||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/about': typeof AboutRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/play': typeof PlayRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/about': typeof AboutRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/play': typeof PlayRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/about': typeof AboutRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/play': typeof PlayRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths: '/' | '/about' | '/play'
|
||||
fullPaths: '/' | '/about' | '/login' | '/play'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to: '/' | '/about' | '/play'
|
||||
id: '__root__' | '/' | '/about' | '/play'
|
||||
to: '/' | '/about' | '/login' | '/play'
|
||||
id: '__root__' | '/' | '/about' | '/login' | '/play'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
AboutRoute: typeof AboutRoute
|
||||
LoginRoute: typeof LoginRoute
|
||||
PlayRoute: typeof PlayRoute
|
||||
}
|
||||
|
||||
|
|
@ -68,6 +78,13 @@ declare module '@tanstack/react-router' {
|
|||
preLoaderRoute: typeof PlayRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/login': {
|
||||
id: '/login'
|
||||
path: '/login'
|
||||
fullPath: '/login'
|
||||
preLoaderRoute: typeof LoginRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/about': {
|
||||
id: '/about'
|
||||
path: '/about'
|
||||
|
|
@ -88,6 +105,7 @@ declare module '@tanstack/react-router' {
|
|||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
AboutRoute: AboutRoute,
|
||||
LoginRoute: LoginRoute,
|
||||
PlayRoute: PlayRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
|
|
|
|||
|
|
@ -1,20 +1,51 @@
|
|||
import { createRootRoute, Link, Outlet } from "@tanstack/react-router";
|
||||
import {
|
||||
createRootRoute,
|
||||
Link,
|
||||
Outlet,
|
||||
useNavigate,
|
||||
} from "@tanstack/react-router";
|
||||
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
|
||||
import { useSession, signOut } from "../lib/auth-client";
|
||||
|
||||
const RootLayout = () => (
|
||||
const RootLayout = () => {
|
||||
const { data: session } = useSession();
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="p-2 flex gap-2">
|
||||
<div className="p-2 flex gap-2 items-center">
|
||||
<Link to="/" className="[&.active]:font-bold">
|
||||
Home
|
||||
</Link>{" "}
|
||||
</Link>
|
||||
<Link to="/about" className="[&.active]:font-bold">
|
||||
About
|
||||
</Link>
|
||||
<div className="ml-auto">
|
||||
{session ? (
|
||||
<button
|
||||
className="text-sm text-gray-600 hover:text-gray-900"
|
||||
onClick={async () => {
|
||||
await signOut();
|
||||
navigate({ to: "/" });
|
||||
}}
|
||||
>
|
||||
Sign out ({session.user.name})
|
||||
</button>
|
||||
) : (
|
||||
<Link
|
||||
to="/login"
|
||||
className="text-sm text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<hr />
|
||||
<Outlet />
|
||||
<TanStackRouterDevtools />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const Route = createRootRoute({ component: RootLayout });
|
||||
|
|
|
|||
44
apps/web/src/routes/login.tsx
Normal file
44
apps/web/src/routes/login.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { signIn, useSession } from "../lib/auth-client";
|
||||
|
||||
const LoginPage = () => {
|
||||
const { data: session, isPending } = useSession();
|
||||
const navigate = useNavigate();
|
||||
|
||||
if (isPending) return <div className="p-4">Loading...</div>;
|
||||
|
||||
if (session) {
|
||||
navigate({ to: "/" });
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-4 p-8">
|
||||
<h1 className="text-2xl font-bold">Sign in to Glossa</h1>
|
||||
<button
|
||||
className="w-64 rounded bg-gray-800 px-4 py-2 text-white hover:bg-gray-700"
|
||||
onClick={() =>
|
||||
signIn.social({
|
||||
provider: "github",
|
||||
callbackURL: "http://localhost:5173",
|
||||
})
|
||||
}
|
||||
>
|
||||
Continue with GitHub
|
||||
</button>
|
||||
<button
|
||||
className="w-64 rounded bg-blue-600 px-4 py-2 text-white hover:bg-blue-500"
|
||||
onClick={() =>
|
||||
signIn.social({
|
||||
provider: "google",
|
||||
callbackURL: "http://localhost:5173",
|
||||
})
|
||||
}
|
||||
>
|
||||
Continue with Google
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Route = createFileRoute("/login")({ component: LoginPage });
|
||||
|
|
@ -1,9 +1,10 @@
|
|||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
import { useState, useCallback } from "react";
|
||||
import type { GameSession, GameRequest, AnswerResult } from "@glossa/shared";
|
||||
import { QuestionCard } from "../components/game/QuestionCard";
|
||||
import { ScoreScreen } from "../components/game/ScoreScreen";
|
||||
import { GameSetup } from "../components/game/GameSetup";
|
||||
import { authClient } from "../lib/auth-client";
|
||||
|
||||
function Play() {
|
||||
const [gameSession, setGameSession] = useState<GameSession | null>(null);
|
||||
|
|
@ -105,4 +106,12 @@ function Play() {
|
|||
);
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/play")({ component: Play });
|
||||
export const Route = createFileRoute("/play")({
|
||||
component: Play,
|
||||
beforeLoad: async () => {
|
||||
const { data: session } = await authClient.getSession();
|
||||
if (!session) {
|
||||
throw redirect({ to: "/login" });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,6 +2,9 @@
|
|||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"allowImportingTsExtensions": true,
|
||||
"composite": false,
|
||||
"declaration": false,
|
||||
"declarationMap": false,
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"jsx": "react-jsx",
|
||||
"module": "ESNext",
|
||||
|
|
@ -9,7 +12,7 @@
|
|||
"noEmit": true,
|
||||
"target": "ES2023",
|
||||
"types": ["vite/client", "vitest/globals"],
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo"
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
},
|
||||
"include": ["src", "vitest.config.ts"]
|
||||
"include": ["src", "vitest.config.ts"],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,10 @@
|
|||
|
||||
## problems+thoughts
|
||||
|
||||
### try now option
|
||||
|
||||
there should be an option to try the app without an account so users can see what they would get when creating an account
|
||||
|
||||
### resolve deps problem
|
||||
|
||||
pnpm --filter web add better-auth
|
||||
|
|
|
|||
28
pnpm-lock.yaml
generated
28
pnpm-lock.yaml
generated
|
|
@ -56,10 +56,16 @@ importers:
|
|||
better-auth:
|
||||
specifier: ^1.6.2
|
||||
version: 1.6.2(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.28.16)(pg@8.20.0))(pg@8.20.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.1.0(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(jsdom@29.0.1(@noble/hashes@2.2.0))(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)))
|
||||
cors:
|
||||
specifier: ^2.8.6
|
||||
version: 2.8.6
|
||||
express:
|
||||
specifier: ^5.2.1
|
||||
version: 5.2.1
|
||||
devDependencies:
|
||||
'@types/cors':
|
||||
specifier: ^2.8.19
|
||||
version: 2.8.19
|
||||
'@types/express':
|
||||
specifier: ^5.0.6
|
||||
version: 5.0.6
|
||||
|
|
@ -1243,6 +1249,9 @@ packages:
|
|||
'@types/cookiejar@2.1.5':
|
||||
resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==}
|
||||
|
||||
'@types/cors@2.8.19':
|
||||
resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==}
|
||||
|
||||
'@types/deep-eql@4.0.2':
|
||||
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
|
||||
|
||||
|
|
@ -1662,6 +1671,10 @@ packages:
|
|||
cookiejar@2.1.4:
|
||||
resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==}
|
||||
|
||||
cors@2.8.6:
|
||||
resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==}
|
||||
engines: {node: '>= 0.10'}
|
||||
|
||||
crc-32@1.2.2:
|
||||
resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==}
|
||||
engines: {node: '>=0.8'}
|
||||
|
|
@ -2397,6 +2410,10 @@ packages:
|
|||
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
object-assign@4.1.1:
|
||||
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
object-inspect@1.13.4:
|
||||
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
|
@ -3821,6 +3838,10 @@ snapshots:
|
|||
|
||||
'@types/cookiejar@2.1.5': {}
|
||||
|
||||
'@types/cors@2.8.19':
|
||||
dependencies:
|
||||
'@types/node': 24.12.0
|
||||
|
||||
'@types/deep-eql@4.0.2': {}
|
||||
|
||||
'@types/esrecurse@4.3.1': {}
|
||||
|
|
@ -4306,6 +4327,11 @@ snapshots:
|
|||
|
||||
cookiejar@2.1.4: {}
|
||||
|
||||
cors@2.8.6:
|
||||
dependencies:
|
||||
object-assign: 4.1.1
|
||||
vary: 1.1.2
|
||||
|
||||
crc-32@1.2.2: {}
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
|
|
@ -4985,6 +5011,8 @@ snapshots:
|
|||
|
||||
normalize-path@3.0.0: {}
|
||||
|
||||
object-assign@4.1.1: {}
|
||||
|
||||
object-inspect@1.13.4: {}
|
||||
|
||||
obug@2.1.1: {}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue