From 73db24d3c08e734ba5977a5ba2ef0b6622f47d77 Mon Sep 17 00:00:00 2001 From: Nathan Date: Thu, 19 Jun 2025 15:15:59 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Introduce=20auth=20flow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 4 +- adonisrc.ts | 20 +++++--- app/controllers/auth_controller.ts | 65 ++++++++++++++++++------- app/controllers/user_controller.ts | 6 +-- app/services/auth_service.ts | 76 +++++++++++++++++++++++++++--- config/session.ts | 48 +++++++++++++++++++ package-lock.json | 53 +++++++++++++++++++++ package.json | 2 + start/env.ts | 2 + start/kernel.ts | 2 +- start/routes.ts | 4 +- 11 files changed, 244 insertions(+), 38 deletions(-) create mode 100644 config/session.ts diff --git a/.env.example b/.env.example index 6ad640b..ec61e82 100644 --- a/.env.example +++ b/.env.example @@ -23,4 +23,6 @@ MAILGUN_DOMAIN= TURNSTILE_SITE_KEY= TURNSTILE_SECRET= -PUBLIC_AUTH_URL= \ No newline at end of file +PUBLIC_AUTH_URL= + +SESSION_DRIVER=cookie \ No newline at end of file diff --git a/adonisrc.ts b/adonisrc.ts index 3ad708c..af79a5e 100644 --- a/adonisrc.ts +++ b/adonisrc.ts @@ -25,7 +25,12 @@ export default defineConfig({ | will be scanned automatically from the "./commands" directory. | */ - commands: [() => import('@adonisjs/core/commands'), () => import('@adonisjs/lucid/commands'), () => import('@adonisjs/cache/commands'), () => import('@adonisjs/mail/commands')], + commands: [ + () => import('@adonisjs/core/commands'), + () => import('@adonisjs/lucid/commands'), + () => import('@adonisjs/cache/commands'), + () => import('@adonisjs/mail/commands'), + ], /* |-------------------------------------------------------------------------- @@ -54,7 +59,8 @@ export default defineConfig({ () => import('@adonisjs/transmit/transmit_provider'), () => import('@adonisjs/core/providers/vinejs_provider'), () => import('adonis-captcha-guard/providers/captcha_provider'), - () => import('@adonisjs/core/providers/edge_provider') + () => import('@adonisjs/core/providers/edge_provider'), + () => import('@adonisjs/session/session_provider'), ], /* @@ -91,8 +97,10 @@ export default defineConfig({ ], forceExit: false, }, - metaFiles: [{ - pattern: 'resources/views/**/*.edge', - reloadServer: false, - }] + metaFiles: [ + { + pattern: 'resources/views/**/*.edge', + reloadServer: false, + }, + ], }) diff --git a/app/controllers/auth_controller.ts b/app/controllers/auth_controller.ts index 2f566b5..be22204 100644 --- a/app/controllers/auth_controller.ts +++ b/app/controllers/auth_controller.ts @@ -5,7 +5,9 @@ import { AuthService } from '#services/auth_service' import { inject } from '@adonisjs/core' import app from '@adonisjs/core/services/app' import env from '#start/env' +import User from '#models/user' +// TODO: When login, set user.email to the email used to request login (lowercase) @inject() export default class AuthController { constructor(private authService: AuthService) {} @@ -23,20 +25,33 @@ export default class AuthController { } } + // Find user by email + const { email } = await request.validateUsing(requestLoginValidator) + const user = await this.authService.findUser(email) + if (!user) { + return response.notFound({ + success: false, + message: 'Utilisateur non trouvé', + }) + } + // Generate token const expiresIn = '15 minutes' - const { email } = await request.validateUsing(requestLoginValidator) - const payload = await this.authService.generateToken(email, expiresIn) + const payload = await this.authService.generateToken(email, user.id, expiresIn) // Send email - await mail.send((message) => { - message - .from(env.get('MAIL_FROM')!) - .to(email) - .subject(payload.emailTitle) - .htmlView('mails/auth', payload) - .textView('mails/auth-fallback', payload) - }).then(console.log).catch(console.error) + await mail + .send((message) => { + message + .from(env.get('MAIL_FROM')!) + .to(email) + .subject(payload.emailTitle) + .htmlView('mails/auth', payload) + .textView('mails/auth-fallback', payload) + }) + // TODO: Handle email sending errors + .then(console.log) + .catch(console.error) return { success: true, @@ -47,19 +62,33 @@ export default class AuthController { } // POST /auth/verify - async verifyCode({ request }: HttpContext) { + async verifyCode({ request, response, auth }: HttpContext) { // Validate code const { code } = await request.validateUsing(verifyCodeValidator) - const email = await this.authService.validateCode(code) - if (!email) { - return { + const { success, userId, email } = await this.authService.validateCode(code) + if (!success || !userId || isNaN(userId)) { + return response.badRequest({ success: false, - message: 'Le code est invalide ou a expiré', - } + message: 'Code de vérification invalide', + }) } - // TOOD: Login - // Find user by email (string similary) + // Find user by id + const user = await User.findBy('id', userId) + if (!user) { + return response.notFound({ + success: false, + message: 'Utilisateur non trouvé', + }) + } + + // Set user email to the email used to request login (lowercase) + user.email = email.toLowerCase() + await user.save() + + // Perform login + await auth.use('web').login(user, true) // true for remember me + return user } magicLink({}: HttpContext) { diff --git a/app/controllers/user_controller.ts b/app/controllers/user_controller.ts index 934b2ed..fa6f448 100644 --- a/app/controllers/user_controller.ts +++ b/app/controllers/user_controller.ts @@ -2,10 +2,9 @@ import User from '#models/user' import { createUserValidator } from '#validators/user' import type { HttpContext } from '@adonisjs/core/http' import { cuid } from '@adonisjs/core/helpers' -import drive from '@adonisjs/drive/services/main' export default class UserController { - // GET /user/@me + // GET /users/@me async me({ auth }: HttpContext) { return { success: true, @@ -13,14 +12,13 @@ export default class UserController { } } - // POST /user + // POST /users async create({ request }: HttpContext) { const payload = await request.validateUsing(createUserValidator) // Save avatar const avatar = `avatars/${cuid()}.${payload.avatar.extname}` await payload.avatar.moveToDisk(avatar) // const avatar = await drive.use().getSignedUrl(key) - console.log(avatar) return User.create({ ...payload, avatar, diff --git a/app/services/auth_service.ts b/app/services/auth_service.ts index 7a7c9fd..da544b4 100644 --- a/app/services/auth_service.ts +++ b/app/services/auth_service.ts @@ -1,11 +1,16 @@ import encryption from '@adonisjs/core/services/encryption' import cache from '@adonisjs/cache/services/main' import env from '#start/env' +import User from '#models/user' +import { CmpStrAsync } from 'cmpstr' + +const cmp = CmpStrAsync.create().setMetric('levenshtein').setFlags('i') export class AuthService { - async generateToken(email: string, expiresIn: string) { + async generateToken(email: string, userId: number, expiresIn: string) { // Generate magic link token - const token = encryption.encrypt(email, expiresIn) + const identifier = `${email}:${userId}` + const token = encryption.encrypt(identifier, expiresIn) const magicLink = env.get('PUBLIC_AUTH_URL') + '/success?signature=' + token // Generate code @@ -14,7 +19,7 @@ export class AuthService { .padStart(6, '0') await cache.set({ key: 'auth:otp:' + formattedOTP, - value: email, + value: identifier, ttl: expiresIn, }) @@ -29,15 +34,74 @@ export class AuthService { } } + parseIdentifier(id: string) { + const [email, userId] = id.split(':') + return { + email: email.toLowerCase(), + userId: parseInt(userId, 10), + } + } + async validateCode(code: string) { // Validate code const key = 'auth:otp:' + code - const email = await cache.get({ key }) - if (!email) return false + const id = await cache.get({ key }) + if (!id) return { success: false, userId: null, email: null } // Delete code from cache await cache.delete({ key }) - return email + return { + success: true, + ...this.parseIdentifier(id) + } + } + + parseNameFromEmail(email: string): Promise { + // Parse name from email + return new Promise((resolve, reject) => { + try { + const [firstName, lastName] = email.split('@')[0].split('.') + resolve(`${firstName} ${lastName}`.toLowerCase()) + } catch (error) { + reject(new Error('Invalid email format')) + } + }) + } + + findUserByEmail(email: string) { + return User.query().where('email', email).first() + } + + async findUser(email: string) { + // Try to find user by email + let user: User | null | undefined = await this.findUserByEmail(email) + if (user) return user + + // If not found, try to parse name from email and find user by name + const name = await this.parseNameFromEmail(email) + const users = await User.query() + // Select all users with no email + .whereNull('email') + const names = users.map((user) => `${user.firstName} ${user.lastName}`.toLowerCase()) + + // Search for similar names + const data = await cmp.searchAsync(name, names) + if (data.length === 0) { + console.warn(`No user found for email "${email}" or name "${name}"`) + return null + } + const source = data[0] + const { match: similarity } = await cmp.testAsync(source, name) + + console.log(similarity) + // If similarity is high enough, return the user + if (similarity > 0.8) { + user = users.find((u) => `${u.firstName} ${u.lastName}`.toLowerCase() === source) + return user + } + console.warn( + `No user found for email "${email}" or name "${name}". Similarity: ${similarity} with source "${source}"` + ) } } diff --git a/config/session.ts b/config/session.ts new file mode 100644 index 0000000..b6b2c52 --- /dev/null +++ b/config/session.ts @@ -0,0 +1,48 @@ +import env from '#start/env' +import app from '@adonisjs/core/services/app' +import { defineConfig, stores } from '@adonisjs/session' + +const sessionConfig = defineConfig({ + enabled: true, + cookieName: 'adonis-session', + + /** + * When set to true, the session id cookie will be deleted + * once the user closes the browser. + */ + clearWithBrowser: false, + + /** + * Define how long to keep the session data alive without + * any activity. + */ + age: '2h', + + /** + * Configuration for session cookie and the + * cookie store + */ + cookie: { + path: '/', + httpOnly: false, + secure: app.inProduction, + sameSite: 'lax', + }, + + /** + * The store to use. Make sure to validate the environment + * variable in order to infer the store name without any + * errors. + */ + store: env.get('SESSION_DRIVER'), + + /** + * List of configured stores. Refer documentation to see + * list of available stores and their config. + */ + stores: { + cookie: stores.cookie(), + } +}) + +export default sessionConfig \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 33ae3ef..29cb447 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,9 +18,11 @@ "@adonisjs/lucid": "^21.6.1", "@adonisjs/mail": "^9.2.2", "@adonisjs/redis": "^9.2.0", + "@adonisjs/session": "^7.5.1", "@adonisjs/transmit": "^2.0.2", "@vinejs/vine": "^3.0.1", "adonis-captcha-guard": "^1.0.1", + "cmpstr": "^3.0.1", "edge.js": "^6.2.1", "luxon": "^3.6.1", "pg": "^8.16.0", @@ -698,6 +700,48 @@ "node": ">=18.16.0" } }, + "node_modules/@adonisjs/session": { + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/@adonisjs/session/-/session-7.5.1.tgz", + "integrity": "sha512-b1E0W/1nnJfAq3Gv8yPywgsxJ7uzzOBJxxulonXI4t1eSdvJzZGNrFScfVLOcjTwlxwrEFA847tULIQxgR4Spw==", + "license": "MIT", + "dependencies": { + "@poppinss/macroable": "^1.0.4", + "@poppinss/utils": "^6.9.2" + }, + "engines": { + "node": ">=18.16.0" + }, + "peerDependencies": { + "@adonisjs/core": "^6.6.0", + "@adonisjs/redis": "^8.0.1 || ^9.0.0", + "@aws-sdk/client-dynamodb": "^3.658.0", + "@aws-sdk/util-dynamodb": "^3.658.0", + "@japa/api-client": "^2.0.3 || ^3.0.0", + "@japa/browser-client": "^2.0.3", + "edge.js": "^6.0.2" + }, + "peerDependenciesMeta": { + "@adonisjs/redis": { + "optional": true + }, + "@aws-sdk/client-dynamodb": { + "optional": true + }, + "@aws-sdk/util-dynamodb": { + "optional": true + }, + "@japa/api-client": { + "optional": true + }, + "@japa/browser-client": { + "optional": true + }, + "edge.js": { + "optional": true + } + } + }, "node_modules/@adonisjs/transmit": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/@adonisjs/transmit/-/transmit-2.0.2.tgz", @@ -3019,6 +3063,15 @@ "node": ">=0.10.0" } }, + "node_modules/cmpstr": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/cmpstr/-/cmpstr-3.0.1.tgz", + "integrity": "sha512-0gj7U07fS8XaAx4IY854d6xpttqxVKUtaeI/giOguqDrXD9OQaBlW321NwhoE+WCF7rv+Pj55YL5kEW3jVuxEA==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/code-block-writer": { "version": "13.0.3", "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz", diff --git a/package.json b/package.json index 0d574a8..d10b030 100644 --- a/package.json +++ b/package.json @@ -59,9 +59,11 @@ "@adonisjs/lucid": "^21.6.1", "@adonisjs/mail": "^9.2.2", "@adonisjs/redis": "^9.2.0", + "@adonisjs/session": "^7.5.1", "@adonisjs/transmit": "^2.0.2", "@vinejs/vine": "^3.0.1", "adonis-captcha-guard": "^1.0.1", + "cmpstr": "^3.0.1", "edge.js": "^6.2.1", "luxon": "^3.6.1", "pg": "^8.16.0", diff --git a/start/env.ts b/start/env.ts index bfecf1d..4419d4a 100644 --- a/start/env.ts +++ b/start/env.ts @@ -63,4 +63,6 @@ export default await Env.create(new URL('../', import.meta.url), { TURNSTILE_SITE_KEY: Env.schema.string.optional(), TURNSTILE_SECRET: Env.schema.string.optional(), + + SESSION_DRIVER: Env.schema.enum(['cookie', 'memory'] as const), }) diff --git a/start/kernel.ts b/start/kernel.ts index e948ccb..b9c82ca 100644 --- a/start/kernel.ts +++ b/start/kernel.ts @@ -28,7 +28,7 @@ server.use([() => import('#middleware/container_bindings_middleware'), () => imp * The router middleware stack runs middleware on all the HTTP * requests with a registered route. */ -router.use([() => import('@adonisjs/core/bodyparser_middleware'), () => import('@adonisjs/auth/initialize_auth_middleware')]) +router.use([() => import('@adonisjs/core/bodyparser_middleware'), () => import('@adonisjs/auth/initialize_auth_middleware'), () => import('@adonisjs/session/session_middleware')]) /** * Named middleware collection must be explicitly assigned to diff --git a/start/routes.ts b/start/routes.ts index 3f983fc..47a2131 100644 --- a/start/routes.ts +++ b/start/routes.ts @@ -28,6 +28,6 @@ router.group(() => { const UserController = () => import('#controllers/user_controller') if (app.inDev) { - router.post('user', [UserController, 'create']) + router.post('users', [UserController, 'create']) } -router.get('/user/@me', [UserController, 'me']).use(middleware.auth()) +router.get('/users/@me', [UserController, 'me']).use(middleware.auth())