redwoodjs / redwood

The App Framework for Startups

Home Page:https://redwoodjs.com

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

[Bug?]: Authentication Error After Upgrading from Version 6.6.4 to 7.4.3

furkanhalkan opened this issue · comments

What's not working?

Hello,

After updating from version 6.6.4 to 7.4.3, I am encountering an error with WebAuthn stating:

Could not start authentication: Cannot retrieve user details without being logged in.

I have not made any changes to the auth-related code, which was working flawlessly in version 6.6.4. However, my project does not function in version 7.4.3, and it does not provide any error messages. Below, I am sharing my schema.prisma and all codes related to auth. Thank you in advance for your support.

schema.prisma:

model User {
  id                  Int              @id @default(autoincrement())
  user_id             String           @unique
  email               String           @unique
  hashedPassword      String
  salt                String
  resetToken          String?
  resetTokenExpiresAt DateTime?
  webAuthnChallenge   String?          @unique
  Clinic_id           String?
  phone_number        String?
  createdAt           DateTime         @default(now())
  updatedAt           DateTime         @updatedAt
  credentials         UserCredential[]
  userdetails         UserDetails[]
}

model UserCredential {
  id         String  @id
  userId     String
  user       User    @relation(fields: [userId], references: [user_id])
  publicKey  Bytes
  transports String?
  counter    BigInt
}

api/src/functions/auth.ts:

import type { APIGatewayProxyEvent, Context } from 'aws-lambda'
import axios from 'axios'
import { v4 as uuidv4 } from 'uuid'

import { DbAuthHandler, DbAuthHandlerOptions } from '@redwoodjs/auth-dbauth-api'

import { db } from 'src/lib/db'

export const handler = async (
  event: APIGatewayProxyEvent,
  context: Context
) => {
  const forgotPasswordOptions: DbAuthHandlerOptions['forgotPassword'] = {

    handler: (user) => {
      return user
    },

    expires: 60 * 60 * 24,

    errors: {
      usernameNotFound: 'Username not found',
      usernameRequired: 'Username is required',
    },
  }

  const loginOptions: DbAuthHandlerOptions['login'] = {
    handler: (user) => {
      return user
    },

    errors: {
      usernameOrPasswordMissing: 'Both username and password are required',
      usernameNotFound: 'Kullanıcı Adı veya Şifre Yanlış',
      incorrectPassword: 'Kullanıcı Adı veya Şifre Yanlış',
    },

    expires: 60 * 60 * 24 * 365 * 10,
  }

  const resetPasswordOptions: DbAuthHandlerOptions['resetPassword'] = {
    handler: (_user) => {
      return true
    },

    allowReusedPassword: true,

    errors: {
      resetTokenExpired: 'resetToken is expired',
      resetTokenInvalid: 'resetToken is invalid',
      resetTokenRequired: 'resetToken is required',
      reusedPassword: 'Must choose a new password',
    },
  }

  const signupOptions: DbAuthHandlerOptions['signup'] = {

    handler: async ({ username, hashedPassword, salt, userAttributes }) => {
      try {
        const userid = uuidv4()
        const clinicID = uuidv4()
        const doctorId = uuidv4()

        if (!userAttributes.googlecapth) {
          throw new Error('ReCAPTCHA response is missing.')
        }

        const response = await axios.post(
          `https://www.google.com/recaptcha/api/siteverify?secret=${process.env.GOOGLE_SECRET_KEY}&response=${userAttributes.googlecapth}`
        )

        if (!response.data.success) {
          throw new Error('ReCAPTCHA verification failed.')
        }

        const transactionResult = await db.$transaction([
          db.user.create({
            data: {
              user_id: userid,
              email: username,
              hashedPassword: hashedPassword,
              salt: salt,
              Clinic_id: clinicID,
              phone_number: userAttributes.phonenumber,
            },
          }),
          db.userDetails.create({
            data: {
              user_id: userid,
              Clinic_id: clinicID,
              name_surname: userAttributes.namesurname,
              Clinic_name: userAttributes.clinicname,
              email: username,
              phone_number: userAttributes.phonenumber,
            },
          }),
          db.clinic.create({
            data: {
              Clinic_id: clinicID,
              Clinic_name: userAttributes.clinicname,
              Kurucu_ID: userid,
            },
          }),
          db.doctor.create({
            data:{
              doctor_id:doctorId,
              clinic_id:clinicID,
              name_surname:userAttributes.namesurname,
              phone_number:userAttributes.phonenumber,
              creater_id:userid
            }
          })
        ])

        return transactionResult
      } catch (error) {
        console.error('Signup error:', error)
        throw error // veya belki de kullanıcıya uygun bir hata mesajı dön.
      }
    },

    passwordValidation: (_password) => {
      return true
    },

    errors: {
      fieldMissing: '${field} is required',
      usernameTaken: 'Username `${username}` already in use',
    },
  }

  const authHandler = new DbAuthHandler(event, context, {
    db: db,

    authModelAccessor: 'user',

    credentialModelAccessor: 'userCredential',

    authFields: {
      id: 'user_id',
      username: 'email',
      hashedPassword: 'hashedPassword',
      salt: 'salt',
      resetToken: 'resetToken',
      resetTokenExpiresAt: 'resetTokenExpiresAt',
      challenge: 'webAuthnChallenge',
    },

    cookie: {
      HttpOnly: true,
      Path: '/',
      SameSite: 'Strict',
      Secure: process.env.NODE_ENV !== 'development' ? true : false,
    },

    forgotPassword: forgotPasswordOptions,
    login: loginOptions,
    resetPassword: resetPasswordOptions,
    signup: signupOptions,

    webAuthn: {
      enabled: true,
      expires: 60 * 60 * 24 * 365 * 10,
      name: 'Cube Dental',
      domain:
        process.env.NODE_ENV === 'development' ? 'localhost' : 'server.com',
      origin:
        process.env.NODE_ENV === 'development'
          ? 'http://localhost:8910'
          : 'https://server.com',
      type: 'platform',
      timeout: 60000,
      credentialFields: {
        id: 'id',
        userId: 'userId',
        publicKey: 'publicKey',
        transports: 'transports',
        counter: 'counter',
      },
    },
  })

  return await authHandler.invoke()
}

api/src/lib/auth.ts:

import type { Decoded } from '@redwoodjs/api'
import { AuthenticationError, ForbiddenError } from '@redwoodjs/graphql-server'

import { db } from './db'

/**
 * The session object sent in as the first argument to getCurrentUser() will
 * have a single key `id` containing the unique ID of the logged in user
 * (whatever field you set as `authFields.id` in your auth function config).
 * You'll need to update the call to `db` below if you use a different model
 * name or unique field name, for example:
 *
 *   return await db.profile.findUnique({ where: { email: session.id } })
 *                   ───┬───                       ──┬──
 *      model accessor ─┘      unique id field name ─┘
 *
 * !! BEWARE !! Anything returned from this function will be available to the
 * client--it becomes the content of `currentUser` on the web side (as well as
 * `context.currentUser` on the api side). You should carefully add additional
 * fields to the `select` object below once you've decided they are safe to be
 * seen if someone were to open the Web Inspector in their browser.
 */
export const getCurrentUser = async (session: Decoded) => {
  if (!session || typeof session.id !== 'string') {
    throw new Error('Invalid session')
  }

  return await db.user.findUnique({
    where: { user_id: session.id },
    select: { user_id: true, Clinic_id:true },
  })
}

/**
 * The user is authenticated if there is a currentUser in the context
 *
 * @returns {boolean} - If the currentUser is authenticated
 */
export const isAuthenticated = (): boolean => {
  console.log(context.currentUser)
  return !!context.currentUser
}

/**
 * When checking role membership, roles can be a single value, a list, or none.
 * You can use Prisma enums too (if you're using them for roles), just import your enum type from `@prisma/client`
 */
type AllowedRoles = string | string[] | undefined

/**
 * Checks if the currentUser is authenticated (and assigned one of the given roles)
 *
 * @param roles: {@link AllowedRoles} - Checks if the currentUser is assigned one of these roles
 *
 * @returns {boolean} - Returns true if the currentUser is logged in and assigned one of the given roles,
 * or when no roles are provided to check against. Otherwise returns false.
 */
export const hasRole = (roles: AllowedRoles): boolean => {
  if (!isAuthenticated()) {
    return false
  }

  const currentUserRoles = context.currentUser?.roles

  if (typeof roles === 'string') {
    if (typeof currentUserRoles === 'string') {
      // roles to check is a string, currentUser.roles is a string
      return currentUserRoles === roles
    } else if (Array.isArray(currentUserRoles)) {
      // roles to check is a string, currentUser.roles is an array
      return currentUserRoles?.some((allowedRole) => roles === allowedRole)
    }
  }

  if (Array.isArray(roles)) {
    if (Array.isArray(currentUserRoles)) {
      // roles to check is an array, currentUser.roles is an array
      return currentUserRoles?.some((allowedRole) =>
        roles.includes(allowedRole)
      )
    } else if (typeof currentUserRoles === 'string') {
      // roles to check is an array, currentUser.roles is a string
      return roles.some((allowedRole) => currentUserRoles === allowedRole)
    }
  }

  // roles not found
  return false
}

/**
 * Use requireAuth in your services to check that a user is logged in,
 * whether or not they are assigned a role, and optionally raise an
 * error if they're not.
 *
 * @param roles: {@link AllowedRoles} - When checking role membership, these roles grant access.
 *
 * @returns - If the currentUser is authenticated (and assigned one of the given roles)
 *
 * @throws {@link AuthenticationError} - If the currentUser is not authenticated
 * @throws {@link ForbiddenError} If the currentUser is not allowed due to role permissions
 *
 * @see https://github.com/redwoodjs/redwood/tree/main/packages/auth for examples
 */
export const requireAuth = ({ roles }: { roles?: AllowedRoles } = {}) => {
  if (!isAuthenticated()) {
    throw new AuthenticationError("You don't have permission to do that.")
  }

  if (roles && !hasRole(roles)) {
    throw new ForbiddenError("You don't have access to do that.")
  }
}

I appreciate any help or guidance on resolving this issue.

How do we reproduce the bug?

No response

What's your environment? (If it applies)

System:
    OS: Windows 11 10.0.22631
  Binaries:
    Node: 20.10.0 - ~\AppData\Local\Temp\xfs-fc6bc9b1\node.CMD
    Yarn: 3.6.3 - ~\AppData\Local\Temp\xfs-fc6bc9b1\yarn.CMD
  Browsers:
    Edge: Chromium (123.0.2420.97)
  npmPackages:
    @redwoodjs/api: 7.4.3 => 7.4.3
    @redwoodjs/auth-dbauth-setup: 7.4.3 => 7.4.3
    @redwoodjs/cli-storybook: 7.4.3 => 7.4.3
    @redwoodjs/core: 7.4.3 => 7.4.3

Are you interested in working on this?

  • I'm interested in working on this

Thanks for opening this issue and providing so much detail!

I'm going to be debugging a potentially related bug to do with our request context which has been reported to be broken between the v6->v7 upgrade. I'll follow up to this issue after checking the context one. I aim to get back to you in a day or two - if that sounds okay?

Thank you for the quick response and for looking into the issue! I am happy to wait for the updates you mentioned. Your assistance is greatly appreciated.

Hi @furkanhalkan I'm having a look at this and helping @Josh-Walker-GM and as I investigated, I did notice a change in the way getCurrentUser is implemented in 6.6.4 and 7.4.3:

Compare

6.6.4

user = await this.dbAccessor.findUnique({

      user = await this.dbAccessor.findUnique({
        where: { [this.options.authFields.id]: this.session?.id },
        select,
      })

7.4.3.

user = await this.dbAccessor.findUnique({

      user = await this.dbAccessor.findUnique({
        where: {
          [this.options.authFields.id]:
            this.session?.[this.options.authFields.id],
        },
        select,
      })

The value for the session is a little different.

Note sure that is your issue, but the error Cannot retrieve user details without being logged in. happens when

  async _getCurrentUser() {
    if (!this.session?.[this.options.authFields.id]) {
      throw new DbAuthError.NotLoggedInError()
    }

Or actually it could be that, too.

Either that or the this.session?.[this.options.authFields.id isn't set properly.

Let me know if that helps.