@heymarco/next-auth
Version:
A complete authentication solution for web applications.
784 lines (783 loc) • 36.3 kB
JavaScript
// cryptos:
import { default as bcrypt, } from 'bcrypt';
import { customAlphabet, } from 'nanoid/async';
export const PrismaAdapterWithCredentials = (prisma, options) => {
// options:
options ??= {};
const { modelUser = 'user', modelRole = 'role', modelAccount = 'account', modelSession = 'session', modelCredentials = 'credentials', modelPasswordResetToken = 'passwordResetToken', modelEmailConfirmationToken = 'emailConfirmationToken', } = options;
const { modelUserRefRoleId = `${modelRole}Id`, modelAccountRefUserId = `${modelUser}Id`, modelSessionRefUserId = `${modelUser}Id`, modelCredentialsRefUserId = `${modelUser}Id`, modelPasswordResetTokenRefUserId = `${modelUser}Id`, modelEmailConfirmationTokenRefUserId = `${modelUser}Id`, } = options;
return {
// CRUD users:
createUser: async (userData) => {
const { name, ...restUserData } = userData;
if (!name)
throw Error('`name` is required.');
return prisma[modelUser].create({
data: {
...restUserData,
name,
},
});
},
getUser: async (userId) => {
return prisma[modelUser].findUnique({
where: {
id: userId,
},
});
},
getUserByEmail: async (userEmail) => {
return prisma[modelUser].findUnique({
where: {
email: userEmail,
},
});
},
getUserByAccount: async (userAccount) => {
const { provider, providerAccountId, } = userAccount;
return prisma.$transaction(async (prismaTransaction) => {
const account = await prismaTransaction[modelAccount].findFirst({
where: {
provider,
providerAccountId,
},
select: {
[modelAccountRefUserId]: true,
},
});
if (!account)
return null;
return prismaTransaction[modelUser].findUnique({
where: {
id: account[modelAccountRefUserId],
},
});
});
},
updateUser: async (userData) => {
const { id, name, ...restUserData } = userData;
if ((name !== undefined) && !name)
throw Error('`name` is required.');
return prisma[modelUser].update({
where: {
id,
},
data: {
...restUserData,
name,
},
});
},
deleteUser: async (userId) => {
return prisma[modelUser].delete({
where: {
id: userId,
},
});
},
// CRUD sessions:
createSession: async (sessionData) => {
const { userId: userId, ...restSessionData } = sessionData;
return prisma[modelSession].create({
data: {
...restSessionData,
[modelSessionRefUserId]: userId,
},
});
},
getSessionAndUser: async (sessionToken) => {
return prisma.$transaction(async (prismaTransaction) => {
const session = await prismaTransaction[modelSession].findUnique({
where: {
sessionToken,
},
});
if (!session)
return null;
const user = await prismaTransaction[modelUser].findUnique({
where: {
id: session[modelSessionRefUserId],
},
});
if (!user)
return null;
return {
session,
user,
};
});
},
updateSession: async (sessionData) => {
const { userId: userId, ...restSessionData } = sessionData;
return prisma[modelSession].update({
where: {
sessionToken: restSessionData.sessionToken,
},
data: {
...restSessionData,
[modelSessionRefUserId]: userId,
},
});
},
deleteSession: async (sessionToken) => {
return prisma[modelSession].delete({
where: {
sessionToken,
},
});
},
// CRUD accounts:
linkAccount: async (accountData) => {
const { userId: userId, ...restAccountData } = accountData;
const account = await prisma[modelAccount].create({
data: {
...restAccountData,
[modelAccountRefUserId]: userId,
},
});
return account;
},
unlinkAccount: async (userAccount) => {
const { provider, providerAccountId, } = userAccount;
const deletedAccount = await prisma.$transaction(async (prismaTransaction) => {
const account = await prismaTransaction[modelAccount].findFirst({
where: {
provider,
providerAccountId,
},
select: {
id: true,
},
});
if (!account)
return undefined;
return await prismaTransaction[modelAccount].delete({
where: {
id: account?.id,
},
});
});
return deletedAccount;
},
// token verifications:
createVerificationToken: undefined,
useVerificationToken: undefined,
// --------------------------------------------------------------------------------------
// sign in:
credentialsSignIn: async (credentials, options) => {
// options:
const { now = new Date(), requireEmailVerified = true, failureMaxAttempts = null, failureLockDuration = 0.25, } = options ?? {};
// credentials:
const { username: usernameOrEmailRaw, password, } = credentials;
// normalizations:
const usernameOrEmail = usernameOrEmailRaw.toLowerCase();
// a database transaction for preventing multiple bulk login for bypassing failureMaxAttempts (forced to be a sequential operation):
// an atomic transaction of [`find user's credentials by username (or email)`, `update the failureAttempts & lockedAt`]:
return prisma.$transaction(async (prismaTransaction) => {
// find user data + credentials by given username (or email):
const userWithCredentials = (usernameOrEmail.includes('@') // if username contains '@' => treat as email, otherwise regular username
? await (async () => {
// first: find the user:
const user = await prismaTransaction[modelUser].findUnique({
where: {
email: usernameOrEmail,
},
});
if (!user)
return null;
// then: find the related credentials:
const credentials = await prismaTransaction[modelCredentials].findUnique({
where: {
[modelCredentialsRefUserId]: user.id,
},
select: {
id: true, // required: for further updating failure_counter and/or lockedAt
failureAttempts: true, // required: for inspecting the failureMaxAttempts constraint
lockedAt: true, // required: for inspecting the failureLockDuration constraint
password: true, // required: for password hash comparison
},
});
// then: combine them:
return {
user,
credentials,
};
})()
: await (async () => {
// first: find the credentials:
const credentials = await prismaTransaction[modelCredentials].findUnique({
where: {
username: usernameOrEmail,
},
select: {
id: true, // required: for further updating failure_counter and/or lockedAt
failureAttempts: true, // required: for inspecting the failureMaxAttempts constraint
lockedAt: true, // required: for inspecting the failureLockDuration constraint
password: true, // required: for password hash comparison
[modelCredentialsRefUserId]: true, // required: for finding the related user
},
});
if (!credentials)
return null;
// then: find the user:
const user = await prismaTransaction[modelUser].findUnique({
where: {
id: credentials[modelCredentialsRefUserId],
},
});
// then: combine them:
return {
user,
credentials,
};
})());
if (!userWithCredentials)
return null; // no user found with given username (or email) => return null (not found)
// exclude credentials property to increase security strength:
const { user: user, credentials: expectedCredentials, } = userWithCredentials;
// check if user's email was verified:
if (requireEmailVerified) {
if (user.emailVerified === null)
return false;
} // if
// verify whether the credentials does exist:
if (!expectedCredentials)
return null; // no credential was configured on the user's account => unable to compare => return null (assumes as password do not match)
// verify whether the credentials is not locked out:
{
const lockedAt = expectedCredentials.lockedAt ?? null;
if (lockedAt !== null) {
const lockedUntil = new Date(/* since: */ lockedAt.valueOf() + /* duration: */ (failureLockDuration * 60 * 60 * 1000 /* convert hours to milliseconds */));
if (lockedUntil > now) {
// still in locked period => return the released_out date:
return lockedUntil;
}
else {
// the locked period expired => unlock & reset the failure_counter:
await prismaTransaction[modelCredentials].update({
where: {
id: expectedCredentials.id,
},
data: {
failureAttempts: null, // reset the failure_counter
lockedAt: null, // reset the lock_date constraint
},
select: {
id: true,
},
});
expectedCredentials.failureAttempts = null; // reset this variable too
expectedCredentials.lockedAt = null; // reset this variable too
} // if
} // if
}
// perform password hash comparison:
{
const isSuccess = !!password && !!expectedCredentials.password && await bcrypt.compare(password, expectedCredentials.password);
if (isSuccess) { // signIn attemp succeeded:
if (expectedCredentials.failureAttempts !== null) { // there are some failure attempts => reset
// reset the failure_counter:
await prismaTransaction[modelCredentials].update({
where: {
id: expectedCredentials.id,
},
data: {
failureAttempts: null, // reset the failure_counter
},
select: {
id: true,
},
});
} // if
}
else { // signIn attemp failed:
if (failureMaxAttempts !== null) { // there are a limit of failure signIn attempts
// increase the failure_counter and/or lockedAt:
const currentFailureAttempts = (expectedCredentials.failureAttempts ?? 0) + 1;
const isLocked = (currentFailureAttempts >= failureMaxAttempts);
await prismaTransaction[modelCredentials].update({
where: {
id: expectedCredentials.id,
},
data: {
failureAttempts: currentFailureAttempts,
lockedAt: (!isLocked // if under limit
? undefined // do not lock now, the user still have a/some chance(s) to retry
: now // lock now, too many retries
),
},
select: {
id: true,
},
});
if (isLocked)
return new Date(/* since: */ now.valueOf() + /* duration: */ (failureLockDuration * 60 * 60 * 1000 /* convert hours to milliseconds */)); // the credentials has been locked
} // if
} // if
if (!isSuccess)
return null; // password hash comparison do not match => return null (password do not match)
}
// the verification passed => authorized => return An `AdapterUser` object:
return user;
});
},
// password resets:
createPasswordResetToken: async (usernameOrEmail, options) => {
// conditions:
const hasPasswordResetToken = !!modelPasswordResetToken && (modelPasswordResetToken in prisma);
if (!hasPasswordResetToken)
return null;
// options:
const { now = new Date(), resetThrottle, resetMaxAge = 24, } = options ?? {};
// normalizations:
usernameOrEmail = usernameOrEmail.toLowerCase();
// generate the passwordResetToken data:
const passwordResetToken = await customAlphabet('0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ', 16)();
const passwordResetMaxAge = resetMaxAge * 60 * 60 * 1000 /* convert hours to milliseconds */;
const passwordResetExpiry = new Date(now.valueOf() + passwordResetMaxAge);
// an atomic transaction of [`find user by username (or email)`, `find passwordResetToken by user id`, `create/update the new passwordResetToken`]:
const user = await prisma.$transaction(async (prismaTransaction) => {
// find user id by given username (or email):
const userId = (usernameOrEmail.includes('@') // if username contains '@' => treat as email, otherwise regular username
? (await prismaTransaction[modelUser].findUnique({
where: {
email: usernameOrEmail,
},
select: {
id: true, // required: for id key
},
}))?.id
: (await prismaTransaction[modelCredentials].findUnique({
where: {
username: usernameOrEmail,
},
select: {
[modelCredentialsRefUserId]: true, // required: for id key
},
}))?.[modelCredentialsRefUserId]);
if (userId === undefined)
return null;
// limits the rate of passwordResetToken request:
if (resetThrottle !== undefined) { // there are a limit of passwordResetToken request
// find the last request date (if found) of passwordResetToken by user id:
const { updatedAt: lastRequestDate } = await prismaTransaction[modelPasswordResetToken].findUnique({
where: {
[modelPasswordResetTokenRefUserId]: userId,
},
select: {
updatedAt: true,
},
}) ?? {};
// calculate how often the last request of passwordResetToken:
if (!!lastRequestDate) {
const minInterval = resetThrottle * 60 * 60 * 1000 /* convert hours to milliseconds */;
if ((now.valueOf() - lastRequestDate.valueOf()) < minInterval) { // the request interval is shorter than minInterval => reject the request
// the reset request is too frequent => reject:
return new Date(lastRequestDate.valueOf() + minInterval);
} // if
} // if
} // if
// create/update the passwordResetToken record and get the related user name & email:
const relatedPasswordResetToken = await prismaTransaction[modelPasswordResetToken].upsert({
where: {
[modelPasswordResetTokenRefUserId]: userId,
},
create: {
[modelPasswordResetTokenRefUserId]: userId,
expiresAt: passwordResetExpiry,
token: passwordResetToken,
},
update: {
expiresAt: passwordResetExpiry,
token: passwordResetToken,
},
select: {
[modelPasswordResetTokenRefUserId]: true,
},
});
if (!relatedPasswordResetToken)
return null;
return prismaTransaction[modelUser].findUnique({
where: {
id: relatedPasswordResetToken[modelPasswordResetTokenRefUserId],
},
});
});
if (!user || (user instanceof Date))
return user;
return {
passwordResetToken,
user,
};
},
validatePasswordResetToken: async (passwordResetToken, options) => {
// conditions:
const hasPasswordResetToken = !!modelPasswordResetToken && (modelPasswordResetToken in prisma);
if (!hasPasswordResetToken)
return null;
// options:
const { now = new Date(), } = options ?? {};
return prisma.$transaction(async (prismaTransaction) => {
const relatedPasswordResetToken = await prismaTransaction[modelPasswordResetToken].findUnique({
where: {
token: passwordResetToken,
expiresAt: {
gt: now, // not expired yet (expires in the future)
},
},
select: {
[modelPasswordResetTokenRefUserId]: true,
},
});
if (!relatedPasswordResetToken)
return null;
const [user, credentials] = await Promise.all([
prismaTransaction[modelUser].findUnique({
where: {
id: relatedPasswordResetToken[modelPasswordResetTokenRefUserId],
},
select: {
email: true,
},
}),
prismaTransaction[modelCredentials].findUnique({
where: {
[modelCredentialsRefUserId]: relatedPasswordResetToken[modelPasswordResetTokenRefUserId],
},
select: {
username: true,
},
}),
]);
if (!user)
return null;
if (!credentials)
return null;
return {
email: user.email,
username: credentials.username || null,
};
});
},
usePasswordResetToken: async (passwordResetToken, password, options) => {
// conditions:
const hasPasswordResetToken = !!modelPasswordResetToken && (modelPasswordResetToken in prisma);
if (!hasPasswordResetToken)
return false;
// options:
const { now = new Date(), } = options ?? {};
// generate the hashed password:
const hashedPassword = await bcrypt.hash(password, 10);
// an atomic transaction of [`find user id by passwordResetToken`, `delete current passwordResetToken record`, `create/update user's credentials`]:
return prisma.$transaction(async (prismaTransaction) => {
// find the existance of passwordResetToken record by given passwordResetToken:
const relatedPasswordResetToken = await prismaTransaction[modelPasswordResetToken].findUnique({
where: {
token: passwordResetToken,
expiresAt: {
gt: now, // not expired yet (expires in the future)
},
},
select: {
id: true,
[modelPasswordResetTokenRefUserId]: true,
},
});
if (!relatedPasswordResetToken) { // there is no passwordResetToken record with related passwordResetToken
// report the error:
return false;
} // if
await Promise.all([
// delete the current passwordResetToken record so it cannot be re-use again:
await prismaTransaction[modelPasswordResetToken].deleteMany({
where: {
id: relatedPasswordResetToken.id,
},
}),
// create/update user's credentials:
prismaTransaction[modelCredentials].upsert({
where: {
[modelCredentialsRefUserId]: relatedPasswordResetToken[modelPasswordResetTokenRefUserId],
},
create: {
[modelCredentialsRefUserId]: relatedPasswordResetToken[modelPasswordResetTokenRefUserId],
password: hashedPassword,
},
update: {
password: hashedPassword,
},
select: {
id: true,
},
}),
// resetting password is also intrinsically verifies the email:
await prismaTransaction[modelUser].updateMany({
where: {
id: relatedPasswordResetToken[modelPasswordResetTokenRefUserId], // unique, guarantees only update one or zero
},
data: {
emailVerified: now,
},
}),
]);
// report the success:
return true;
});
},
// registrations:
checkUsernameAvailability: async (username) => {
// normalizations:
username = username.toLowerCase();
// database query:
return !(await prisma[modelCredentials].findUnique({
where: {
username: username,
},
select: {
id: true,
},
}));
},
checkEmailAvailability: async (email) => {
// normalizations:
email = email.toLowerCase();
// database query:
return !(await prisma[modelUser].findUnique({
where: {
email: email,
},
select: {
id: true,
},
}));
},
registerUser: async (name, email, username, password, options) => {
// options:
const { requireEmailVerified = false, } = options ?? {};
// normalizations:
email = email.toLowerCase();
username = username.toLowerCase();
// generate the hashed password:
const hashedPassword = await bcrypt.hash(password, 10);
// generate the emailConfirmationToken data:
const hasEmailConfirmationToken = !!modelEmailConfirmationToken && (modelEmailConfirmationToken in prisma);
const emailConfirmationToken = (requireEmailVerified && hasEmailConfirmationToken) ? await customAlphabet('0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ', 16)() : null;
// an atomic transaction of [`create/update User`, `create/update Credentials`, `create/update EmailConfirmationToken`]:
return prisma.$transaction(async (prismaTransaction) => {
// create/update User:
const userData = {
name: name,
email: email,
emailVerified: null, // reset
};
const { id: userId } = await prismaTransaction[modelUser].upsert({
where: {
email: email,
emailVerified: null,
},
create: userData,
update: userData,
select: {
id: true,
},
});
// create/update Credentials:
const credentialsData = {
failureAttempts: null, // reset
lockedAt: null, // reset
username: username,
password: hashedPassword,
};
await prismaTransaction[modelCredentials].upsert({
where: {
[modelCredentialsRefUserId]: userId,
},
create: {
[modelCredentialsRefUserId]: userId,
...credentialsData,
},
update: {
...credentialsData,
},
select: {
id: true,
},
});
// create/update EmailConfirmationToken:
if (hasEmailConfirmationToken && emailConfirmationToken) {
await prismaTransaction[modelEmailConfirmationToken].upsert({
where: {
[modelEmailConfirmationTokenRefUserId]: userId,
},
create: {
[modelEmailConfirmationTokenRefUserId]: userId,
token: emailConfirmationToken,
},
update: {
token: emailConfirmationToken,
},
select: {
id: true,
},
});
} // if
return {
userId,
emailConfirmationToken,
};
});
},
// email verifications:
markEmailAsVerified: async (userId, options) => {
// options:
const { now = new Date(), } = options ?? {};
await prisma[modelUser].update({
where: {
id: userId,
},
data: {
emailVerified: now,
},
select: {
id: true,
},
});
},
useEmailConfirmationToken: async (emailConfirmationToken, options) => {
// conditions:
const hasEmailConfirmationToken = !!modelEmailConfirmationToken && (modelEmailConfirmationToken in prisma);
if (!hasEmailConfirmationToken)
return false;
// options:
const { now = new Date(), } = options ?? {};
// an atomic transaction of [`find user id by emailConfirmationToken`, `delete current emailConfirmationToken record`, `update user's emailVerified field`]:
return prisma.$transaction(async (prismaTransaction) => {
// find the existance of emailConfirmationToken record by given emailConfirmationToken:
const relatedEmailConfirmationToken = await prismaTransaction[modelEmailConfirmationToken].findUnique({
where: {
token: emailConfirmationToken,
},
select: {
id: true,
[modelEmailConfirmationTokenRefUserId]: true,
},
});
if (!relatedEmailConfirmationToken) { // there is no emailConfirmationToken record with related emailConfirmationToken
// report the error:
return false;
} // if
await Promise.all([
// delete the current emailConfirmationToken record so it cannot be re-use again:
prismaTransaction[modelEmailConfirmationToken].deleteMany({
where: {
[modelEmailConfirmationTokenRefUserId]: relatedEmailConfirmationToken.id,
},
}),
// update user's emailVerified field (if not already verified):
prismaTransaction[modelUser].updateMany({
where: {
id: relatedEmailConfirmationToken[modelEmailConfirmationTokenRefUserId], // unique, guarantees only update one or zero
},
data: {
emailVerified: now,
},
}),
]);
// report the success:
return true;
});
},
// user credentials:
getCredentialsByUserId: async (userId) => {
// database query:
return prisma[modelCredentials].findUnique({
where: {
[modelCredentialsRefUserId]: userId,
},
select: {
username: true, // only username is shown for security purpose
},
});
},
getCredentialsByUserEmail: async (userEmail) => {
// normalizations:
userEmail = userEmail.toLowerCase();
// database query:
return prisma.$transaction(async (prismaTransaction) => {
const relatedUser = await prismaTransaction[modelUser].findUnique({
where: {
email: userEmail,
},
select: {
id: true,
},
});
if (!relatedUser)
return null;
return prismaTransaction[modelCredentials].findUnique({
where: {
[modelCredentialsRefUserId]: relatedUser.id,
},
select: {
username: true, // only username is shown for security purpose
},
});
});
},
// user roles:
getRoleByUserId: async (userId) => {
// conditions:
const hasRole = !!modelRole && (modelRole in prisma);
if (!hasRole)
return null;
// database query:
return prisma.$transaction(async (prismaTransaction) => {
const relatedUser = await prismaTransaction[modelUser].findUnique({
where: {
id: userId,
},
select: {
[modelUserRefRoleId]: true,
},
});
if (!relatedUser)
return null;
if (relatedUser[modelUserRefRoleId] === null)
return null;
return prismaTransaction[modelRole].findUnique({
where: {
id: relatedUser[modelUserRefRoleId],
},
});
});
},
getRoleByUserEmail: async (userEmail) => {
// conditions:
const hasRole = !!modelRole && (modelRole in prisma);
if (!hasRole)
return null;
// normalizations:
userEmail = userEmail.toLowerCase();
// database query:
return prisma.$transaction(async (prismaTransaction) => {
const relatedUser = await prismaTransaction[modelUser].findUnique({
where: {
email: userEmail,
},
select: {
[modelUserRefRoleId]: true,
},
});
if (!relatedUser)
return null;
if (relatedUser[modelUserRefRoleId] === null)
return null;
return prismaTransaction[modelRole].findUnique({
where: {
id: relatedUser[modelUserRefRoleId],
},
});
});
},
};
};