✨ Introduce auth flow
This commit is contained in:
parent
211e6e26f5
commit
73db24d3c0
11 changed files with 244 additions and 38 deletions
|
|
@ -23,4 +23,6 @@ MAILGUN_DOMAIN=
|
|||
TURNSTILE_SITE_KEY=
|
||||
TURNSTILE_SECRET=
|
||||
|
||||
PUBLIC_AUTH_URL=
|
||||
PUBLIC_AUTH_URL=
|
||||
|
||||
SESSION_DRIVER=cookie
|
||||
20
adonisrc.ts
20
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,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
48
config/session.ts
Normal 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
53
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue