diff --git a/client/src/App.tsx b/client/src/App.tsx index d0cedc7..2dcde33 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -18,11 +18,13 @@ import AdminEmailConfig from "./pages/AdminEmailConfig"; import AdminCalendrier from "./pages/AdminCalendrier"; import AdminRappels from "./pages/AdminRappels"; import Inscription from "./pages/Inscription"; +import Login from "./pages/Login"; function Router() { return ( + diff --git a/client/src/pages/Home.tsx b/client/src/pages/Home.tsx index 557f4f8..63b9b58 100644 --- a/client/src/pages/Home.tsx +++ b/client/src/pages/Home.tsx @@ -3,7 +3,7 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { APP_LOGO, APP_TITLE, getLoginUrl } from "@/const"; import { GraduationCap, Calendar, Users, CheckCircle, Mail, Download } from "lucide-react"; -import { useLocation } from "wouter"; +import { useLocation, Link } from "wouter"; import { useEffect } from "react"; export default function Home() { @@ -71,9 +71,16 @@ export default function Home() {

{APP_TITLE}

{!isAuthenticated && ( - +
+ + + + +
)} diff --git a/client/src/pages/Login.tsx b/client/src/pages/Login.tsx new file mode 100644 index 0000000..e2aab44 --- /dev/null +++ b/client/src/pages/Login.tsx @@ -0,0 +1,115 @@ +import { useState } from "react"; +import { useLocation } from "wouter"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { toast } from "sonner"; +import { APP_LOGO, APP_TITLE } from "@/const"; +import { Loader2 } from "lucide-react"; + +export default function Login() { + const [, setLocation] = useLocation(); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!username || !password) { + toast.error("Veuillez remplir tous les champs"); + return; + } + + setLoading(true); + + try { + const response = await fetch("/api/auth/local/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ username, password }), + }); + + const data = await response.json(); + + if (response.ok && data.success) { + toast.success("Connexion réussie !"); + // Rediriger vers le tableau de bord + setLocation("/admin"); + } else { + toast.error(data.message || "Identifiant ou mot de passe incorrect"); + } + } catch (error) { + console.error("Erreur lors de la connexion:", error); + toast.error("Erreur lors de la connexion"); + } finally { + setLoading(false); + } + }; + + return ( +
+ + +
+ Logo +
+
+ {APP_TITLE} + + Connectez-vous avec votre identifiant et mot de passe + +
+
+ +
+
+ + setUsername(e.target.value)} + disabled={loading} + required + /> +
+ +
+ + setPassword(e.target.value)} + disabled={loading} + required + /> +
+ + +
+ +
+

Vous n'avez pas de compte ?

+

Contactez l'administrateur

+
+
+
+
+ ); +} diff --git a/package.json b/package.json index 8ecc102..0018517 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "framer-motion": "^12.23.22", "input-otp": "^1.4.2", "jose": "6.1.0", + "jsonwebtoken": "^9.0.2", "jspdf": "^3.0.3", "jspdf-autotable": "^5.0.2", "lucide-react": "^0.453.0", @@ -89,6 +90,7 @@ "@types/bcryptjs": "^3.0.0", "@types/express": "4.17.21", "@types/google.maps": "^3.58.1", + "@types/jsonwebtoken": "^9.0.10", "@types/node": "^24.7.0", "@types/react": "^19.1.16", "@types/react-dom": "^19.1.9", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d4139b0..fe8692d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -154,6 +154,9 @@ importers: jose: specifier: 6.1.0 version: 6.1.0 + jsonwebtoken: + specifier: ^9.0.2 + version: 9.0.2 jspdf: specifier: ^3.0.3 version: 3.0.3 @@ -239,6 +242,9 @@ importers: '@types/google.maps': specifier: ^3.58.1 version: 3.58.1 + '@types/jsonwebtoken': + specifier: ^9.0.10 + version: 9.0.10 '@types/node': specifier: ^24.7.0 version: 24.7.0 @@ -2293,6 +2299,9 @@ packages: '@types/http-errors@2.0.5': resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + '@types/jsonwebtoken@9.0.10': + resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} + '@types/katex@0.16.7': resolution: {integrity: sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ==} @@ -2469,6 +2478,9 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -2958,6 +2970,9 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} @@ -3346,6 +3361,10 @@ packages: engines: {node: '>=6'} hasBin: true + jsonwebtoken@9.0.2: + resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==} + engines: {node: '>=12', npm: '>=6'} + jspdf-autotable@5.0.2: resolution: {integrity: sha512-YNKeB7qmx3pxOLcNeoqAv3qTS7KuvVwkFe5AduCawpop3NOkBUtqDToxNc225MlNecxT4kP2Zy3z/y/yvGdXUQ==} peerDependencies: @@ -3354,6 +3373,12 @@ packages: jspdf@3.0.3: resolution: {integrity: sha512-eURjAyz5iX1H8BOYAfzvdPfIKK53V7mCpBTe7Kb16PaM8JSXEcUQNBQaiWMI8wY5RvNOPj4GccMjTlfwRBd+oQ==} + jwa@1.4.2: + resolution: {integrity: sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==} + + jws@3.2.2: + resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==} + katex@0.16.25: resolution: {integrity: sha512-woHRUZ/iF23GBP1dkDQMh1QBad9dmr8/PAwNA54VrSOVYgI12MAcE14TqnDdQOdzyEonGzMepYnqBMYdsoAr8Q==} hasBin: true @@ -3445,6 +3470,27 @@ packages: lodash-es@4.17.21: resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==} + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} @@ -4045,6 +4091,11 @@ packages: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true + semver@7.7.3: + resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} + engines: {node: '>=10'} + hasBin: true + send@0.19.0: resolution: {integrity: sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==} engines: {node: '>= 0.8.0'} @@ -6819,6 +6870,11 @@ snapshots: '@types/http-errors@2.0.5': {} + '@types/jsonwebtoken@9.0.10': + dependencies: + '@types/ms': 2.1.0 + '@types/node': 24.7.0 + '@types/katex@0.16.7': {} '@types/mdast@4.0.4': @@ -7020,6 +7076,8 @@ snapshots: node-releases: 2.0.23 update-browserslist-db: 1.1.3(browserslist@4.26.3) + buffer-equal-constant-time@1.0.1: {} + buffer-from@1.1.2: {} bytes@3.1.2: {} @@ -7423,6 +7481,10 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + ee-first@1.1.1: {} electron-to-chromium@1.5.230: {} @@ -7931,6 +7993,19 @@ snapshots: json5@2.2.3: {} + jsonwebtoken@9.0.2: + dependencies: + jws: 3.2.2 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.7.3 + jspdf-autotable@5.0.2(jspdf@3.0.3): dependencies: jspdf: 3.0.3 @@ -7946,6 +8021,17 @@ snapshots: dompurify: 3.3.0 html2canvas: 1.4.1 + jwa@1.4.2: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@3.2.2: + dependencies: + jwa: 1.4.2 + safe-buffer: 5.2.1 + katex@0.16.25: dependencies: commander: 8.3.0 @@ -8019,6 +8105,20 @@ snapshots: lodash-es@4.17.21: {} + lodash.includes@4.3.0: {} + + lodash.isboolean@3.0.3: {} + + lodash.isinteger@4.0.4: {} + + lodash.isnumber@3.0.3: {} + + lodash.isplainobject@4.0.6: {} + + lodash.isstring@4.0.1: {} + + lodash.once@4.1.1: {} + lodash@4.17.21: {} long@5.3.2: {} @@ -8902,6 +9002,8 @@ snapshots: semver@6.3.1: {} + semver@7.7.3: {} + send@0.19.0: dependencies: debug: 2.6.9 diff --git a/server/__tests__/localAuth.test.ts b/server/__tests__/localAuth.test.ts new file mode 100644 index 0000000..4385110 --- /dev/null +++ b/server/__tests__/localAuth.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import { getDb } from "../db"; +import { users } from "../../drizzle/schema"; +import { eq } from "drizzle-orm"; + +describe("Local Authentication", () => { + beforeAll(async () => { + // Vérifier que la base de données est accessible + const db = await getDb(); + expect(db).toBeDefined(); + }); + + it("should have an admin user with username adminServFormation", async () => { + const db = await getDb(); + if (!db) { + throw new Error("Database not available"); + } + + const result = await db + .select() + .from(users) + .where(eq(users.username, "adminServFormation")) + .limit(1); + + expect(result.length).toBe(1); + expect(result[0].username).toBe("adminServFormation"); + expect(result[0].role).toBe("admin"); + expect(result[0].password).toBeDefined(); + expect(result[0].password).not.toBeNull(); + }); + + it("should authenticate with correct credentials", async () => { + const response = await fetch("http://localhost:3000/api/auth/local/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + username: "adminServFormation", + password: "Itinova69!", + }), + }); + + expect(response.status).toBe(200); + const data = await response.json(); + expect(data.success).toBe(true); + expect(data.user).toBeDefined(); + expect(data.user.username).toBe("adminServFormation"); + expect(data.user.role).toBe("admin"); + expect(data.user.password).toBeUndefined(); // Le mot de passe ne doit pas être retourné + }); + + it("should reject authentication with incorrect password", async () => { + const response = await fetch("http://localhost:3000/api/auth/local/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + username: "adminServFormation", + password: "wrongpassword", + }), + }); + + expect(response.status).toBe(401); + const data = await response.json(); + expect(data.success).toBe(false); + expect(data.message).toContain("incorrect"); + }); + + it("should reject authentication with non-existent username", async () => { + const response = await fetch("http://localhost:3000/api/auth/local/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + username: "nonexistentuser", + password: "anypassword", + }), + }); + + expect(response.status).toBe(401); + const data = await response.json(); + expect(data.success).toBe(false); + expect(data.message).toContain("incorrect"); + }); + + it("should reject authentication with missing credentials", async () => { + const response = await fetch("http://localhost:3000/api/auth/local/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + username: "adminServFormation", + // password manquant + }), + }); + + expect(response.status).toBe(400); + const data = await response.json(); + expect(data.success).toBe(false); + expect(data.message).toContain("requis"); + }); +}); diff --git a/server/_core/env.ts b/server/_core/env.ts index 2792b99..305082c 100644 --- a/server/_core/env.ts +++ b/server/_core/env.ts @@ -1,6 +1,7 @@ export const ENV = { appId: process.env.VITE_APP_ID ?? "", cookieSecret: process.env.JWT_SECRET ?? "", + jwtSecret: process.env.JWT_SECRET ?? "", databaseUrl: process.env.DATABASE_URL ?? "", oAuthServerUrl: process.env.OAUTH_SERVER_URL ?? "", ownerOpenId: process.env.OWNER_OPEN_ID ?? "", diff --git a/server/_core/index.ts b/server/_core/index.ts index f472331..ccf53e7 100644 --- a/server/_core/index.ts +++ b/server/_core/index.ts @@ -6,6 +6,7 @@ import { createExpressMiddleware } from "@trpc/server/adapters/express"; import { registerOAuthRoutes } from "./oauth"; import { appRouter } from "../routers"; import { createContext } from "./context"; +import localAuthRouter from "./localAuth"; import { serveStatic, setupVite } from "./vite"; function isPortAvailable(port: number): Promise { @@ -35,6 +36,8 @@ async function startServer() { app.use(express.urlencoded({ limit: "50mb", extended: true })); // OAuth callback under /api/oauth/callback registerOAuthRoutes(app); + // Local authentication under /api/auth/local + app.use("/api/auth/local", localAuthRouter); // tRPC API app.use( "/api/trpc", diff --git a/server/_core/localAuth.ts b/server/_core/localAuth.ts new file mode 100644 index 0000000..9caf5c2 --- /dev/null +++ b/server/_core/localAuth.ts @@ -0,0 +1,108 @@ +import { Router } from "express"; +import bcrypt from "bcryptjs"; +import jwt from "jsonwebtoken"; +import { getDb } from "../db"; +import { users } from "../../drizzle/schema"; +import { eq } from "drizzle-orm"; +import { ENV } from "./env"; +import { COOKIE_NAME } from "@shared/const"; +import { getSessionCookieOptions } from "./cookies"; + +const router = Router(); + +/** + * Route d'authentification locale avec username/password + * POST /api/auth/local/login + * Body: { username: string, password: string } + */ +router.post("/login", async (req, res) => { + try { + const { username, password } = req.body; + + // Validation des champs + if (!username || !password) { + return res.status(400).json({ + success: false, + message: "Identifiant et mot de passe requis", + }); + } + + // Récupérer l'utilisateur par username + const db = await getDb(); + if (!db) { + return res.status(500).json({ + success: false, + message: "Erreur de connexion à la base de données", + }); + } + + const result = await db + .select() + .from(users) + .where(eq(users.username, username)) + .limit(1); + + if (result.length === 0) { + return res.status(401).json({ + success: false, + message: "Identifiant ou mot de passe incorrect", + }); + } + + const user = result[0]; + + // Vérifier que l'utilisateur a un mot de passe défini + if (!user.password) { + return res.status(401).json({ + success: false, + message: "Cet utilisateur n'a pas de mot de passe défini", + }); + } + + // Comparer le mot de passe + const isPasswordValid = await bcrypt.compare(password, user.password); + + if (!isPasswordValid) { + return res.status(401).json({ + success: false, + message: "Identifiant ou mot de passe incorrect", + }); + } + + // Mettre à jour la date de dernière connexion + await db + .update(users) + .set({ lastSignedIn: new Date() }) + .where(eq(users.id, user.id)); + + // Créer le token JWT + const token = jwt.sign( + { + openId: user.openId, + name: user.name, + email: user.email, + }, + ENV.jwtSecret, + { expiresIn: "7d", algorithm: "HS256" } + ); + + // Définir le cookie de session + const cookieOptions = getSessionCookieOptions(req); + res.cookie(COOKIE_NAME, token, cookieOptions); + + // Retourner l'utilisateur (sans le mot de passe) + const { password: _, ...userWithoutPassword } = user; + return res.json({ + success: true, + user: userWithoutPassword, + }); + } catch (error) { + console.error("[LocalAuth] Error during login:", error); + return res.status(500).json({ + success: false, + message: "Erreur lors de la connexion", + }); + } +}); + +export default router; diff --git a/server/_core/sdk.ts b/server/_core/sdk.ts index 230e762..956239b 100644 --- a/server/_core/sdk.ts +++ b/server/_core/sdk.ts @@ -260,7 +260,33 @@ class SDKServer { // Regular authentication flow const cookies = this.parseCookies(req.headers.cookie); const sessionCookie = cookies.get(COOKIE_NAME); - const session = await this.verifySession(sessionCookie); + + // Try to verify as local JWT first + let session: { openId: string; appId?: string; name: string } | null = null; + let isLocalAuth = false; + + if (sessionCookie) { + try { + const secretKey = this.getSessionSecret(); + const { payload } = await jwtVerify(sessionCookie, secretKey, { + algorithms: ["HS256"], + }); + const { openId, name, appId } = payload as Record; + + // Check if it's a local JWT (has openId but may not have appId) + if (isNonEmptyString(openId) && isNonEmptyString(name)) { + session = { openId, name, appId: appId as string | undefined }; + isLocalAuth = !appId; // Local auth doesn't have appId + } + } catch (error) { + console.warn("[Auth] JWT verification failed:", error); + } + } + + // If local JWT verification failed, try OAuth session + if (!session) { + session = await this.verifySession(sessionCookie); + } if (!session) { throw ForbiddenError("Invalid session cookie"); @@ -270,8 +296,8 @@ class SDKServer { const signedInAt = new Date(); let user = await db.getUserByOpenId(sessionUserId); - // If user not in DB, sync from OAuth server automatically - if (!user) { + // If user not in DB, sync from OAuth server automatically (only for OAuth sessions) + if (!user && !isLocalAuth) { try { const userInfo = await this.getUserInfoWithJwt(sessionCookie ?? ""); await db.upsertUser({ diff --git a/todo.md b/todo.md index 3574223..3646900 100644 --- a/todo.md +++ b/todo.md @@ -404,3 +404,12 @@ - [x] Modifier l'interface pour rendre le champ email modifiable pour l'utilisateur adminServFormation - [x] Tester la modification + +## Authentification locale avec username/password + +- [x] Créer la route d'authentification locale dans le backend (POST /api/auth/local/login) +- [x] Créer la page de connexion locale avec formulaire username/password +- [x] Ajouter la route /login dans App.tsx +- [x] Créer un lien vers la page de login depuis la page d'accueil +- [x] Écrire des tests pour l'authentification locale +- [ ] Tester la connexion avec adminServFormation/Itinova69!