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_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.
|
*/
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,
},
],
})

View file

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

View file

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

View file

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

View file

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

View file

@ -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),
})

View file

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

View file

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