Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Injected auth models #1583

Merged
merged 66 commits into from
Jan 3, 2024
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
66 commits
Select commit Hold shift + click to select a range
87a24b1
WIP: Auth entity
infomiho Nov 15, 2023
7749dec
Merge branch 'main' into auth-model-experiment
infomiho Nov 15, 2023
a7e3c14
Username and password working
infomiho Nov 15, 2023
bf77d11
Add support for email auth
infomiho Nov 15, 2023
238c3a8
Add support for Social auth
infomiho Nov 15, 2023
627ecf9
Add tasks to User
infomiho Nov 16, 2023
4259689
Update authentication providers and entities
infomiho Nov 24, 2023
20d4fd4
Email authentication. Test adding signup fields
infomiho Nov 24, 2023
33eb3aa
Migration with seed scripts
infomiho Nov 24, 2023
993e39d
Migrate Todo app
infomiho Nov 24, 2023
cf38191
Clenaup
infomiho Nov 27, 2023
9dfbd6c
Remove example app
infomiho Nov 27, 2023
4d0e3e4
Cleanup
infomiho Nov 27, 2023
a768c36
Updates tests
infomiho Nov 27, 2023
83ad555
Cleanup
infomiho Nov 27, 2023
8693c69
Fixes tests
infomiho Nov 28, 2023
43fb9f7
Merge branch 'main' into auth-model-experiment
infomiho Dec 12, 2023
e6f6a17
Use JSON based auth model
infomiho Dec 12, 2023
2714731
Refactor provider data serialization
infomiho Dec 12, 2023
872bca1
Updates typing of provider data
infomiho Dec 13, 2023
fd87def
Remove Prisma middleware. Fixes types
infomiho Dec 13, 2023
3ae0468
Cleanup
infomiho Dec 13, 2023
b5e0c05
Fixes double password hashing issue
infomiho Dec 13, 2023
315962f
Fixes headless test
infomiho Dec 13, 2023
95bda0a
Update e2e tests
infomiho Dec 13, 2023
efc2291
Cleanup
infomiho Dec 13, 2023
7fa0fe1
Merge branch 'main' into auth-model-experiment
infomiho Dec 14, 2023
21b0e77
Updates utils.ts. Updates websocket example app
infomiho Dec 14, 2023
f94f7fd
Update e2e tests
infomiho Dec 14, 2023
0a1d965
Update examples apps. Update server utils.ts
infomiho Dec 14, 2023
52f0881
PR comments
infomiho Dec 18, 2023
f982e6d
Updates e2e tests
infomiho Dec 18, 2023
48484c1
Add user ID helpers
infomiho Dec 18, 2023
6ba6a21
Fixes e2e tests
infomiho Dec 18, 2023
cf19e38
Improve naming and types
infomiho Dec 19, 2023
c5aee00
Updates e2e tests
infomiho Dec 19, 2023
a3cb241
Update examples/waspello/src/client/Navbar.jsx
infomiho Dec 19, 2023
b0b1a8b
PR comments
infomiho Dec 21, 2023
ce1c89b
PR comments
infomiho Dec 21, 2023
c3e46dc
Minor fixes. Rename local provider to username
infomiho Dec 22, 2023
c58ed9c
Updates e2e tests
infomiho Dec 22, 2023
2429673
Fixes frontend unit tests
infomiho Dec 22, 2023
d71d0f7
PR comments
infomiho Dec 22, 2023
8d27abb
Update e2e tests
infomiho Dec 22, 2023
d9690bb
Improve username handling in examples. Add getFirstProviderUserId
infomiho Dec 22, 2023
226e38b
Update e2e tests
infomiho Dec 22, 2023
9303dbc
Update seed script path
infomiho Dec 22, 2023
7ea4792
Add comment above PossibleProviderData
infomiho Dec 22, 2023
887ce20
Use UTC date for auth related timings
infomiho Dec 22, 2023
215bb86
Updates e2e tests
infomiho Dec 22, 2023
227a2f8
unverfiied email signup flow. foregin constraint error
infomiho Dec 23, 2023
1c53057
e2e tests
infomiho Dec 23, 2023
5daf665
Use token in email verification and password reset
infomiho Dec 23, 2023
4d1a863
e2e tests
infomiho Dec 23, 2023
dba9d28
Fix user creation and deletion errors
infomiho Dec 27, 2023
c313736
e2e tests
infomiho Dec 27, 2023
acf5d1f
PR comments
infomiho Dec 29, 2023
49966c0
PR comments
infomiho Jan 2, 2024
7c1b66e
Update e2e tests
infomiho Jan 2, 2024
04a9ee1
Extract the oauth handler into a separate function
infomiho Jan 2, 2024
d84dcc9
e2e tests
infomiho Jan 2, 2024
d4339a4
Update waspc/data/Generator/templates/server/src/auth/providers/email…
infomiho Jan 3, 2024
5077add
Apply suggestions from code review
infomiho Jan 3, 2024
9e4d7bb
Merge branch 'main' into auth-model-experiment
infomiho Jan 3, 2024
993eeac
PR comments
infomiho Jan 3, 2024
9b1185d
e2e tests
infomiho Jan 3, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ export type {
{=# entities =}
{= name =},
{=/ entities =}
{=# isAuthEnabled =}
Martinsos marked this conversation as resolved.
Show resolved Hide resolved
type {= authEntityName =},
Martinsos marked this conversation as resolved.
Show resolved Hide resolved
{=/ isAuthEnabled =}
} from '@prisma/client'

export type Entity =
Expand Down
6 changes: 4 additions & 2 deletions waspc/data/Generator/templates/server/src/_types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { type Request, type Response } from 'express'
import { type ParamsDictionary as ExpressParams, type Query as ExpressQuery } from 'express-serve-static-core'
import prisma from "../dbClient.js"
{=# isAuthEnabled =}
import { type {= userEntityName =} } from "../entities"
import { type {= userEntityName =}, type {= authEntityName =} } from "../entities"
{=/ isAuthEnabled =}
import { type _Entity } from "./taggedEntities"
import { type Payload } from "./serialization";
Expand Down Expand Up @@ -83,5 +83,7 @@ type ContextWithUser<Entities extends _Entity[]> = Expand<Context<Entities> & {
// password field from the object there, we must do the same here). Ideally,
// these two things would live in the same place:
// https://github.com/wasp-lang/wasp/issues/965
export type SanitizedUser = Omit<{= userEntityName =}, 'password'>
export type SanitizedUser = {= userEntityName =} & {
{= authFieldOnUserEntityName =}: Omit<{= authEntityName =}, 'password'> | null
}
{=/ isAuthEnabled =}
8 changes: 1 addition & 7 deletions waspc/data/Generator/templates/server/src/auth/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1 @@
{{={= =}=}}
{=# isEmailAuthEnabled =}
export { defineAdditionalSignupFields } from './providers/email/types.js';
{=/ isEmailAuthEnabled =}
{=# isLocalAuthEnabled =}
export { defineAdditionalSignupFields } from './providers/local/types.js';
{=/ isLocalAuthEnabled =}
export { defineAdditionalSignupFields } from './providers/types.js';
Martinsos marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import type { OAuthConfig } from "../oauth/types.js";
const _waspGetUserFieldsFn = {= userFieldsFn.importIdentifier =}
{=/ userFieldsFn.isDefined =}
{=^ userFieldsFn.isDefined =}
import { getUserFieldsFn as _waspGetUserFieldsFn } from '../oauth/defaults.js'
const _waspGetUserFieldsFn = undefined
Martinsos marked this conversation as resolved.
Show resolved Hide resolved
{=/ userFieldsFn.isDefined =}
{=# configFn.isDefined =}
{=& configFn.importStatement =}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Request, Response } from 'express';
import { verifyPassword, throwInvalidCredentialsError } from "../../../core/auth.js";
import { findUserBy, createAuthToken } from "../../utils.js";
import { findAuthWithUserBy, createAuthToken } from "../../utils.js";
import { ensureValidEmail, ensurePasswordIsPresent } from "../../validation.js";

export function getLoginRoute({
Expand All @@ -12,25 +12,25 @@ export function getLoginRoute({
req: Request<{ email: string; password: string; }>,
res: Response,
): Promise<Response<{ token: string } | undefined>> {
const userFields = req.body || {}
ensureValidArgs(userFields)
const fields = req.body || {}
ensureValidArgs(fields)

userFields.email = userFields.email.toLowerCase()
fields.email = fields.email.toLowerCase()

const user = await findUserBy({ email: userFields.email })
if (!user) {
const auth = await findAuthWithUserBy({ email: fields.email })
if (!auth) {
throwInvalidCredentialsError()
}
if (!user.isEmailVerified && !allowUnverifiedLogin) {
if (!auth.isEmailVerified && !allowUnverifiedLogin) {
throwInvalidCredentialsError()
}
try {
await verifyPassword(user.password, userFields.password);
await verifyPassword(auth.password, fields.password);
} catch(e) {
throwInvalidCredentialsError()
}

const token = await createAuthToken(user)
const token = await createAuthToken(auth)

return res.json({ token })
};
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Request, Response } from 'express';
import {
findUserBy,
findAuthWithUserBy,
doFakeWork,
} from "../../utils.js";
import {
Expand Down Expand Up @@ -30,25 +30,25 @@ export function getRequestPasswordResetRoute({

args.email = args.email.toLowerCase();

const user = await findUserBy({ email: args.email });
const auth = await findAuthWithUserBy({ email: args.email });

// User not found or not verified - don't leak information
infomiho marked this conversation as resolved.
Show resolved Hide resolved
if (!user || !user.isEmailVerified) {
if (!auth || !auth.isEmailVerified) {
await doFakeWork();
Martinsos marked this conversation as resolved.
Show resolved Hide resolved
return res.json({ success: true });
}

if (!isEmailResendAllowed(user, 'passwordResetSentAt')) {
if (!isEmailResendAllowed(auth, 'passwordResetSentAt')) {
return res.status(400).json({ success: false, message: "Please wait a minute before trying again." });
}

const passwordResetLink = await createPasswordResetLink(user, clientRoute);
const passwordResetLink = await createPasswordResetLink(auth, clientRoute);
try {
await sendPasswordResetEmail(
user.email,
auth.email,
{
from: fromField,
to: user.email,
to: auth.email,
...getPasswordResetEmailContent({ passwordResetLink }),
}
);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Request, Response } from 'express';
import { findUserBy, verifyToken } from "../../utils.js";
import { updateUserPassword } from "./utils.js";
import { findAuthWithUserBy, verifyToken } from "../../utils.js";
import { updateAuthPassword } from "./utils.js";
import { ensureTokenIsPresent, ensurePasswordIsPresent, ensureValidPassword } from "../../validation.js";
import { tokenVerificationErrors } from "./types.js";

Expand All @@ -13,12 +13,12 @@ export async function resetPassword(

const { token, password } = args;
try {
const { id: userId } = await verifyToken(token);
const user = await findUserBy({ id: userId });
if (!user) {
const { id: authId } = await verifyToken(token);
Martinsos marked this conversation as resolved.
Show resolved Hide resolved
const auth = await findAuthWithUserBy({ id: authId });
if (!auth) {
return res.status(400).json({ success: false, message: 'Invalid token' });
}
await updateUserPassword(userId, password);
await updateAuthPassword(authId, password);
} catch (e) {
const reason = e.name === tokenVerificationErrors.TokenExpiredError
? 'expired'
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { Request, Response } from 'express';
import { EmailFromField } from "../../../email/core/types.js";
import {
createUser,
findUserBy,
deleteUser,
createAuthWithUser,
findAuthWithUserBy,
deleteAuth,
doFakeWork,
} from "../../utils.js";
import {
Expand All @@ -28,38 +28,40 @@ export function getSignupRoute({
req: Request<{ email: string; password: string; }>,
res: Response,
): Promise<Response<{ success: true } | { success: false; message: string }>> {
const userFields = req.body;
ensureValidArgs(userFields);
const fields = req.body;
ensureValidArgs(fields);

userFields.email = userFields.email.toLowerCase();
fields.email = fields.email.toLowerCase();

const existingUser = await findUserBy({ email: userFields.email });
const existingAuth = await findAuthWithUserBy({ email: fields.email });
// User already exists and is verified - don't leak information
if (existingUser && existingUser.isEmailVerified) {
if (existingAuth && existingAuth.isEmailVerified) {
await doFakeWork();
return res.json({ success: true });
} else if (existingUser && !existingUser.isEmailVerified) {
if (!isEmailResendAllowed(existingUser, 'emailVerificationSentAt')) {
} else if (existingAuth && !existingAuth.isEmailVerified) {
if (!isEmailResendAllowed(existingAuth, 'emailVerificationSentAt')) {
return res.status(400).json({ success: false, message: "Please wait a minute before trying again." });
}
await deleteUser(existingUser);
await deleteAuth(existingAuth);
}

const additionalFields = await validateAndGetAdditionalFields(userFields);
const additionalFields = await validateAndGetAdditionalFields(fields);

const user = await createUser({
...additionalFields,
email: userFields.email,
password: userFields.password,
});
const auth = await createAuthWithUser(
{
email: fields.email,
password: fields.password,
},
additionalFields,
);

const verificationLink = await createEmailVerificationLink(user, clientRoute);
const verificationLink = await createEmailVerificationLink(auth, clientRoute);
try {
await sendEmailVerificationEmail(
userFields.email,
fields.email,
{
from: fromField,
to: userFields.email,
to: fields.email,
...getVerificationEmailContent({ verificationLink }),
}
);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { createDefineAdditionalSignupFieldsFn } from '../types.js'

export type GetVerificationEmailContentFn = (params: { verificationLink: string }) => EmailContent;

export type GetPasswordResetEmailContentFn = (params: { passwordResetLink: string }) => EmailContent;
Expand All @@ -13,5 +11,3 @@ type EmailContent = {
export const tokenVerificationErrors = {
TokenExpiredError: 'TokenExpiredError',
};

export const defineAdditionalSignupFields = createDefineAdditionalSignupFieldsFn<"email" | "password">()
Original file line number Diff line number Diff line change
Expand Up @@ -5,74 +5,79 @@ import { Email } from '../../../email/core/types.js';
import { rethrowPossiblePrismaError } from '../../utils.js'
import prisma from '../../../dbClient.js'
import waspServerConfig from '../../../config.js';
import { type {= userEntityUpper =} } from '../../../entities/index.js'
import { type {= userEntityUpper =}, type {= authEntityUpper =} } from '../../../entities/index.js'

type {= authEntityUpper =}Id = {= authEntityUpper =}['id']
type {= userEntityUpper =}Id = {= userEntityUpper =}['id']

export async function updateUserEmailVerification(userId: {= userEntityUpper =}Id): Promise<void> {
type AuthWithId = {
Martinsos marked this conversation as resolved.
Show resolved Hide resolved
id: {= authEntityUpper =}Id,
Martinsos marked this conversation as resolved.
Show resolved Hide resolved
}

export async function updateAuthEmailVerification(authId: {= authEntityUpper =}Id) {
try {
await prisma.{= userEntityLower =}.update({
where: { id: userId },
await prisma.{= authEntityLower =}.update({
where: { id: authId },
data: { isEmailVerified: true },
})
} catch (e) {
rethrowPossiblePrismaError(e);
}
}

export async function updateUserPassword(userId: {= userEntityUpper =}Id, password: string): Promise<void> {
export async function updateAuthPassword(authId: {= authEntityUpper =}Id, password: string) {
try {
await prisma.{= userEntityLower =}.update({
where: { id: userId },
await prisma.{= authEntityLower =}.update({
where: { id: authId },
data: { password },
})
} catch (e) {
rethrowPossiblePrismaError(e);
}
}

export async function createEmailVerificationLink(user: {= userEntityUpper =}, clientRoute: string): Promise<string> {
const token = await createEmailVerificationToken(user);
export async function createEmailVerificationLink(auth: AuthWithId, clientRoute: string) {
Martinsos marked this conversation as resolved.
Show resolved Hide resolved
const token = await createEmailVerificationToken(auth);
return `${waspServerConfig.frontendUrl}${clientRoute}?token=${token}`;
}

export async function createPasswordResetLink(user: {= userEntityUpper =}, clientRoute: string): Promise<string> {
const token = await createPasswordResetToken(user);
export async function createPasswordResetLink(auth: AuthWithId, clientRoute: string) {
const token = await createPasswordResetToken(auth);
return `${waspServerConfig.frontendUrl}${clientRoute}?token=${token}`;
}

async function createEmailVerificationToken(user: {= userEntityUpper =}): Promise<string> {
return sign(user.id, { expiresIn: '30m' });
async function createEmailVerificationToken(auth: AuthWithId): Promise<string> {
return sign(auth.id, { expiresIn: '30m' });
}

async function createPasswordResetToken(user: {= userEntityUpper =}): Promise<string> {
return sign(user.id, { expiresIn: '30m' });
async function createPasswordResetToken(auth: AuthWithId): Promise<string> {
return sign(auth.id, { expiresIn: '30m' });
}

export async function sendPasswordResetEmail(
email: string,
content: Email,
): Promise<void> {
) {
return sendEmailAndLogTimestamp(email, content, 'passwordResetSentAt');
}

export async function sendEmailVerificationEmail(
email: string,
content: Email,
): Promise<void> {
) {
return sendEmailAndLogTimestamp(email, content, 'emailVerificationSentAt');
}

async function sendEmailAndLogTimestamp(
email: string,
content: Email,
field: 'emailVerificationSentAt' | 'passwordResetSentAt',
): Promise<void> {
) {
Martinsos marked this conversation as resolved.
Show resolved Hide resolved
// Set the timestamp first, and then send the email
// so the user can't send multiple requests while
// the email is being sent.
try {
await prisma.{= userEntityLower =}.update({
await prisma.{= authEntityLower =}.update({
where: { email },
data: { [field]: new Date() },
})
Expand All @@ -85,11 +90,11 @@ async function sendEmailAndLogTimestamp(
}

export function isEmailResendAllowed(
user: {= userEntityUpper =},
auth: {= authEntityUpper =},
field: 'emailVerificationSentAt' | 'passwordResetSentAt',
resendInterval: number = 1000 * 60,
): boolean {
const sentAt = user[field];
const sentAt = auth[field];
if (!sentAt) {
return true;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Request, Response } from 'express';
import { updateUserEmailVerification } from './utils.js';
import { updateAuthEmailVerification } from './utils.js';
import { verifyToken } from '../../utils.js';
import { tokenVerificationErrors } from './types.js';

Expand All @@ -9,8 +9,8 @@ export async function verifyEmail(
): Promise<Response<{ success: true } | { success: false, message: string }>> {
try {
const { token } = req.body;
const { id: userId } = await verifyToken(token);
await updateUserEmailVerification(userId);
const { id: authId } = await verifyToken(token);
await updateAuthEmailVerification(authId);
} catch (e) {
const reason = e.name === tokenVerificationErrors.TokenExpiredError
? 'expired'
Expand Down
Loading
Loading