Introduce auth flow

This commit is contained in:
Nathan 2025-06-19 15:15:59 +02:00
parent 211e6e26f5
commit 73db24d3c0
11 changed files with 244 additions and 38 deletions

View file

@ -23,4 +23,6 @@ MAILGUN_DOMAIN=
TURNSTILE_SITE_KEY= TURNSTILE_SITE_KEY=
TURNSTILE_SECRET= TURNSTILE_SECRET=
PUBLIC_AUTH_URL= PUBLIC_AUTH_URL=
SESSION_DRIVER=cookie

View file

@ -25,7 +25,12 @@ export default defineConfig({
| will be scanned automatically from the "./commands" directory. | 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/transmit/transmit_provider'),
() => import('@adonisjs/core/providers/vinejs_provider'), () => import('@adonisjs/core/providers/vinejs_provider'),
() => import('adonis-captcha-guard/providers/captcha_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, forceExit: false,
}, },
metaFiles: [{ metaFiles: [
pattern: 'resources/views/**/*.edge', {
reloadServer: false, pattern: 'resources/views/**/*.edge',
}] reloadServer: false,
},
],
}) })

View file

@ -5,7 +5,9 @@ import { AuthService } from '#services/auth_service'
import { inject } from '@adonisjs/core' import { inject } from '@adonisjs/core'
import app from '@adonisjs/core/services/app' import app from '@adonisjs/core/services/app'
import env from '#start/env' 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() @inject()
export default class AuthController { export default class AuthController {
constructor(private authService: AuthService) {} 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 // Generate token
const expiresIn = '15 minutes' const expiresIn = '15 minutes'
const { email } = await request.validateUsing(requestLoginValidator) const payload = await this.authService.generateToken(email, user.id, expiresIn)
const payload = await this.authService.generateToken(email, expiresIn)
// Send email // Send email
await mail.send((message) => { await mail
message .send((message) => {
.from(env.get('MAIL_FROM')!) message
.to(email) .from(env.get('MAIL_FROM')!)
.subject(payload.emailTitle) .to(email)
.htmlView('mails/auth', payload) .subject(payload.emailTitle)
.textView('mails/auth-fallback', payload) .htmlView('mails/auth', payload)
}).then(console.log).catch(console.error) .textView('mails/auth-fallback', payload)
})
// TODO: Handle email sending errors
.then(console.log)
.catch(console.error)
return { return {
success: true, success: true,
@ -47,19 +62,33 @@ export default class AuthController {
} }
// POST /auth/verify // POST /auth/verify
async verifyCode({ request }: HttpContext) { async verifyCode({ request, response, auth }: HttpContext) {
// Validate code // Validate code
const { code } = await request.validateUsing(verifyCodeValidator) const { code } = await request.validateUsing(verifyCodeValidator)
const email = await this.authService.validateCode(code) const { success, userId, email } = await this.authService.validateCode(code)
if (!email) { if (!success || !userId || isNaN(userId)) {
return { return response.badRequest({
success: false, success: false,
message: 'Le code est invalide ou a expiré', message: 'Code de vérification invalide',
} })
} }
// TOOD: Login // Find user by id
// Find user by email (string similary) 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) { magicLink({}: HttpContext) {

View file

@ -2,10 +2,9 @@ import User from '#models/user'
import { createUserValidator } from '#validators/user' import { createUserValidator } from '#validators/user'
import type { HttpContext } from '@adonisjs/core/http' import type { HttpContext } from '@adonisjs/core/http'
import { cuid } from '@adonisjs/core/helpers' import { cuid } from '@adonisjs/core/helpers'
import drive from '@adonisjs/drive/services/main'
export default class UserController { export default class UserController {
// GET /user/@me // GET /users/@me
async me({ auth }: HttpContext) { async me({ auth }: HttpContext) {
return { return {
success: true, success: true,
@ -13,14 +12,13 @@ export default class UserController {
} }
} }
// POST /user // POST /users
async create({ request }: HttpContext) { async create({ request }: HttpContext) {
const payload = await request.validateUsing(createUserValidator) const payload = await request.validateUsing(createUserValidator)
// Save avatar // Save avatar
const avatar = `avatars/${cuid()}.${payload.avatar.extname}` const avatar = `avatars/${cuid()}.${payload.avatar.extname}`
await payload.avatar.moveToDisk(avatar) await payload.avatar.moveToDisk(avatar)
// const avatar = await drive.use().getSignedUrl(key) // const avatar = await drive.use().getSignedUrl(key)
console.log(avatar)
return User.create({ return User.create({
...payload, ...payload,
avatar, avatar,

View file

@ -1,11 +1,16 @@
import encryption from '@adonisjs/core/services/encryption' import encryption from '@adonisjs/core/services/encryption'
import cache from '@adonisjs/cache/services/main' import cache from '@adonisjs/cache/services/main'
import env from '#start/env' 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 { export class AuthService {
async generateToken(email: string, expiresIn: string) { async generateToken(email: string, userId: number, expiresIn: string) {
// Generate magic link token // 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 const magicLink = env.get('PUBLIC_AUTH_URL') + '/success?signature=' + token
// Generate code // Generate code
@ -14,7 +19,7 @@ export class AuthService {
.padStart(6, '0') .padStart(6, '0')
await cache.set({ await cache.set({
key: 'auth:otp:' + formattedOTP, key: 'auth:otp:' + formattedOTP,
value: email, value: identifier,
ttl: expiresIn, 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) { async validateCode(code: string) {
// Validate code // Validate code
const key = 'auth:otp:' + code const key = 'auth:otp:' + code
const email = await cache.get({ key }) const id = await cache.get({ key })
if (!email) return false if (!id) return { success: false, userId: null, email: null }
// Delete code from cache // Delete code from cache
await cache.delete({ key }) await cache.delete({ key })
return email return {
success: true,
...this.parseIdentifier(id)
}
}
parseNameFromEmail(email: string): Promise<string> {
// 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}"`
)
} }
} }

48
config/session.ts Normal file
View file

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

53
package-lock.json generated
View file

@ -18,9 +18,11 @@
"@adonisjs/lucid": "^21.6.1", "@adonisjs/lucid": "^21.6.1",
"@adonisjs/mail": "^9.2.2", "@adonisjs/mail": "^9.2.2",
"@adonisjs/redis": "^9.2.0", "@adonisjs/redis": "^9.2.0",
"@adonisjs/session": "^7.5.1",
"@adonisjs/transmit": "^2.0.2", "@adonisjs/transmit": "^2.0.2",
"@vinejs/vine": "^3.0.1", "@vinejs/vine": "^3.0.1",
"adonis-captcha-guard": "^1.0.1", "adonis-captcha-guard": "^1.0.1",
"cmpstr": "^3.0.1",
"edge.js": "^6.2.1", "edge.js": "^6.2.1",
"luxon": "^3.6.1", "luxon": "^3.6.1",
"pg": "^8.16.0", "pg": "^8.16.0",
@ -698,6 +700,48 @@
"node": ">=18.16.0" "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": { "node_modules/@adonisjs/transmit": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/@adonisjs/transmit/-/transmit-2.0.2.tgz", "resolved": "https://registry.npmjs.org/@adonisjs/transmit/-/transmit-2.0.2.tgz",
@ -3019,6 +3063,15 @@
"node": ">=0.10.0" "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": { "node_modules/code-block-writer": {
"version": "13.0.3", "version": "13.0.3",
"resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz", "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz",

View file

@ -59,9 +59,11 @@
"@adonisjs/lucid": "^21.6.1", "@adonisjs/lucid": "^21.6.1",
"@adonisjs/mail": "^9.2.2", "@adonisjs/mail": "^9.2.2",
"@adonisjs/redis": "^9.2.0", "@adonisjs/redis": "^9.2.0",
"@adonisjs/session": "^7.5.1",
"@adonisjs/transmit": "^2.0.2", "@adonisjs/transmit": "^2.0.2",
"@vinejs/vine": "^3.0.1", "@vinejs/vine": "^3.0.1",
"adonis-captcha-guard": "^1.0.1", "adonis-captcha-guard": "^1.0.1",
"cmpstr": "^3.0.1",
"edge.js": "^6.2.1", "edge.js": "^6.2.1",
"luxon": "^3.6.1", "luxon": "^3.6.1",
"pg": "^8.16.0", "pg": "^8.16.0",

View file

@ -63,4 +63,6 @@ export default await Env.create(new URL('../', import.meta.url), {
TURNSTILE_SITE_KEY: Env.schema.string.optional(), TURNSTILE_SITE_KEY: Env.schema.string.optional(),
TURNSTILE_SECRET: Env.schema.string.optional(), TURNSTILE_SECRET: Env.schema.string.optional(),
SESSION_DRIVER: Env.schema.enum(['cookie', 'memory'] as const),
}) })

View file

@ -28,7 +28,7 @@ server.use([() => import('#middleware/container_bindings_middleware'), () => imp
* The router middleware stack runs middleware on all the HTTP * The router middleware stack runs middleware on all the HTTP
* requests with a registered route. * 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 * Named middleware collection must be explicitly assigned to

View file

@ -28,6 +28,6 @@ router.group(() => {
const UserController = () => import('#controllers/user_controller') const UserController = () => import('#controllers/user_controller')
if (app.inDev) { 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())