Checkpoint: Ajout d'une page de connexion locale permettant aux utilisateurs de se connecter avec leur identifiant et mot de passe (username/password) au lieu de l'OAuth Manus. Implémentation complète avec route d'authentification backend, hashage bcrypt, session JWT, et reconnaissance automatique des sessions locales vs OAuth. Tests unitaires et manuels réussis avec l'utilisateur adminServFormation.

This commit is contained in:
Manus Sandbox
2025-11-23 04:59:54 -05:00
parent b2e7505afb
commit fe8b8e90c4
11 changed files with 488 additions and 7 deletions

View File

@@ -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");
});
});

View File

@@ -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 ?? "",

View File

@@ -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<boolean> {
@@ -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",

108
server/_core/localAuth.ts Normal file
View File

@@ -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;

View File

@@ -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<string, unknown>;
// 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({