Search code examples
node.jstypescriptsecurityprismafastify

How to securly verify user by passwordless magic link?


Problem:

I am in the process of creating a straightforward passwordless authentication system for users to perform basic CRUD operations.

Approach

The overall login process is as follows:

  1. The user visits the magic link page
  2. If the email is valid, a verification link is sent to the user's mailbox.
  3. The user verifies the link.
  4. If the link has not expired and is valid, it is stored as a cookie.
  5. With the information from the cookie, I can make Guest HOC for components only visible for guests like login and User HOC for components like dashboard.

I would like to know if this approach to verifying users without passwords is secure enough.

Stack i use

I use fastify with prisma,typescript and zod for schema validation

My code so far

export const app = fastify()

declare module 'fastify' {
   interface FastifyRequest {
      user: User
   }
}

app.register(fastifyCookie, {
   secret: process.env.COOKIE_SECRET,
   hook: 'onRequest',
   parseOptions: {},
})

app.setErrorHandler(function (error, _request, reply) {
   if (error instanceof NotFoundError) {
      reply.status(404).send(error.message)
   }

   if (error instanceof InvalidInputError) {
      reply.status(422).send(error.issues)
   }

   if (error instanceof TokenExpiredError) {
      reply.status(403).send({ error: 'Token expired' })
   }

   if (error instanceof JsonWebTokenError) {
      reply.status(404).send({ error: 'Invalid token' })

      // Token required for the request is invalid
   }

   if (process.env.NODE_ENV == 'development') {
      console.log(error.message)
   }

   reply.status(500).send('Internal server error')
})


const authTokenSchema = z.object({ email: z.string().email() })

const auth = async (request: FastifyRequest, reply: FastifyReply) => {
   try {
      const { authToken } = request.cookies

      if (!authToken) {
         throw new NotFoundError('Token')
      }

      const decodedToken = verify(authToken, process.env.JWT_SECRET)

      const { email } = authTokenSchema.parse(decodedToken)

      const user = await prisma.user.findFirst({
         where: {
            email,
            authToken,
         },
      })

      if (!user) {
         throw new NotFoundError('User')
      }

      request.user = user
   } catch (error) {
      reply.status(500).send({ error })
   }
}

app.post('/api/login', async (req, reply) => {
   const { email } = req.body as {
      email: string
   }

   try {
      const authToken = sign({ email }, process.env.JWT_SECRET, { expiresIn: '30m' })

      const isEmailFound = await prisma.user.findFirst({ where: { email } })

      if (isEmailFound) {
         await prisma.user.update({
            where: { email },
            data: { authToken },
         })
      } else {
         await prisma.user.create({
            data: {
               email,
               authToken,
            },
         })
      }

      await emailService.sendMagicLink(email, authToken)
      reply.status(200).send({
         success: true,
         email,
      })
   } catch (error) {
      reply.status(500).send({ error })
   }
})



app.get('/api/verify', async (req, reply) => {
   const { authToken } = req.query as { authToken: string }

   // TODO: parse schemą tokena 
   const data = verify(authToken, process.env.JWT_SECRET) as { email: string }
   console.log(authToken)
   const { email } = authTokenSchema.parse(data)

   reply
      .setCookie('authToken', authToken, {
         httpOnly: true,
         secure: process.env.NODE_ENV === 'production', // true on production
      })
      .status(200)
      .send({
         success: true,
         email,
      })
})

app.get('/auth', { preHandler: [auth] }, async (_req, reply) => {
   try {
      reply.status(200).send({ success: true })
   } catch (error) {
      reply.status(403).send({ error })
   }
})

const port = parseInt(process.env.PORT)

app.listen({ port }, (error, address) => {
   if (error) logger.error(error)

   logger.info(`Server listening on on ${address}`)
})

If someone is kind enught a have some questions

  1. How can I ensure the security of the authentication process in my passwordless approach when verifying users without passwords?
  2. Are there any potential vulnerabilities or security risks in the code implementation of my passwordless authentication system using Fastify, Prisma, and TypeScript?
  3. What are the best practices for handling and storing authentication tokens in a passwordless authentication system?
  4. Are there any improvements or modifications I should consider to enhance the security of my passwordless authentication approach using cookies and magic links?

Thanks


Solution

  • I would like to know if this approach to verifying users without passwords is secure enough.

    Secure enough for what? This isn't a yes/no answer. Everything has tradeoffs.

    In this case, it's only secure as the e-mail, and anyone who the user inadvertently sends it to. You have to trust all possible recipients of that message, the user, the user's browser and its extensions, etc. If you're fine with that, then it's secure enough. If not, well, then, not.

    1. How can I ensure the security of the authentication process in my passwordless approach when verifying users without passwords?

    Testing, auditing, third-party review, etc.

    Have you also considered checking out the common attacks for JWT on OWASP?

    1. Are there any potential vulnerabilities or security risks in the code implementation of my passwordless authentication system using Fastify, Prisma, and TypeScript?

    The answer to this question is always yes... there are always potential vulnerabilities or security risks. It's up to you to understand them and decide what risks you will take for the convenience of your users.

    As far as specific issues go... you haven't shown us code in main functions like verify() so who knows. I am curious though... it appears that you're not just allowing token re-use but requiring it for your system to work?

    1. What are the best practices for handling and storing authentication tokens in a passwordless authentication system?

    I'm not sure why you're storing the tokens to begin with. If you include the relevant information, like a session ID, and sign it, then the server can statelessly determine if the session ID was properly created. You can also encrypt the token to hide data within it that the user's browser tracks for you, but only the API server can decrypt. Either method doesn't require storing the token.