Skip to content

Commit

Permalink
Loosen remember device restrictions
Browse files Browse the repository at this point in the history
Remove IP address from remember device check, since that often changes for people regularly and causes this to go through the process again.

Lengthen the cookie time and DB time to 90 days and upon successful verification, extend by another 90 days in DB and cookie

#1076
  • Loading branch information
paustint committed Dec 27, 2024
1 parent e4b6b1c commit 3aa6e05
Show file tree
Hide file tree
Showing 5 changed files with 178 additions and 168 deletions.
316 changes: 160 additions & 156 deletions apps/api/src/app/controllers/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,182 +320,186 @@ const signin = createRoute(routeDefinition.signin.validators, async ({ body, par
/**
* FIXME: This should probably be broken up and logic moved to the auth service
*/
const callback = createRoute(routeDefinition.callback.validators, async ({ body, params, query, clearCookie }, req, res, next) => {
let provider: Provider | null = null;
try {
const providers = listProviders();
const callback = createRoute(
routeDefinition.callback.validators,
async ({ body, params, query, setCookie, clearCookie }, req, res, next) => {
let provider: Provider | null = null;
try {
const providers = listProviders();

provider = providers[params.provider];
if (!provider) {
throw new InvalidParameters('Missing provider');
}
provider = providers[params.provider];
if (!provider) {
throw new InvalidParameters('Missing provider');
}

let isNewUser = false;
const {
pkceCodeVerifier,
nonce,
linkIdentity: linkIdentityCookie,
returnUrl,
rememberDevice,
} = getCookieConfig(ENV.USE_SECURE_COOKIES);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const cookies = parseCookie(req.headers.cookie!);
clearOauthCookies(res);

let isNewUser = false;
const {
pkceCodeVerifier,
nonce,
linkIdentity: linkIdentityCookie,
returnUrl,
rememberDevice,
} = getCookieConfig(ENV.USE_SECURE_COOKIES);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const cookies = parseCookie(req.headers.cookie!);
clearOauthCookies(res);
if (provider.type === 'oauth') {
// oauth flow
const { userInfo } = await validateCallback(
provider.provider as OauthProviderType,
new URLSearchParams(query),
cookies[pkceCodeVerifier.name],
cookies[nonce.name]
);

if (!userInfo.email) {
throw new InvalidParameters('Missing email from OAuth provider');
}

if (provider.type === 'oauth') {
// oauth flow
const { userInfo } = await validateCallback(
provider.provider as OauthProviderType,
new URLSearchParams(query),
cookies[pkceCodeVerifier.name],
cookies[nonce.name]
);

if (!userInfo.email) {
throw new InvalidParameters('Missing email from OAuth provider');
}
const providerUser = {
id: userInfo.sub,
email: userInfo.email,
emailVerified: userInfo.email_verified ?? false,
givenName: userInfo.given_name,
familyName: userInfo.family_name,
username: userInfo.preferred_username || (userInfo.username as string | undefined) || userInfo.email,
name:
userInfo.name ??
(userInfo.given_name && userInfo.family_name ? `${userInfo.given_name} ${userInfo.family_name}` : userInfo.email),
picture: (userInfo.picture_thumbnail as string | undefined) || userInfo.picture,
};

// If user has an active session and user is linking an identity to an existing account
// link and redirect to profile page
if (req.session.user && cookies[linkIdentityCookie.name] === 'true') {
await linkIdentityToUser({
userId: req.session.user.id,
provider: provider.provider,
providerUser,
});
createUserActivityFromReq(req, res, {
action: 'LINK_IDENTITY',
method: provider.provider.toUpperCase(),
success: true,
});
redirect(res, cookies[returnUrl.name] || `${ENV.JETSTREAM_CLIENT_URL}/profile`);
return;
}

const providerUser = {
id: userInfo.sub,
email: userInfo.email,
emailVerified: userInfo.email_verified ?? false,
givenName: userInfo.given_name,
familyName: userInfo.family_name,
username: userInfo.preferred_username || (userInfo.username as string | undefined) || userInfo.email,
name:
userInfo.name ??
(userInfo.given_name && userInfo.family_name ? `${userInfo.given_name} ${userInfo.family_name}` : userInfo.email),
picture: (userInfo.picture_thumbnail as string | undefined) || userInfo.picture,
};

// If user has an active session and user is linking an identity to an existing account
// link and redirect to profile page
if (req.session.user && cookies[linkIdentityCookie.name] === 'true') {
await linkIdentityToUser({
userId: req.session.user.id,
const sessionData = await handleSignInOrRegistration({
providerType: provider.type,
provider: provider.provider,
providerUser,
});
createUserActivityFromReq(req, res, {
action: 'LINK_IDENTITY',
method: provider.provider.toUpperCase(),
success: true,
});
redirect(res, cookies[returnUrl.name] || `${ENV.JETSTREAM_CLIENT_URL}/profile`);
return;
}

const sessionData = await handleSignInOrRegistration({
providerType: provider.type,
provider: provider.provider,
providerUser,
});
isNewUser = sessionData.isNewUser;
isNewUser = sessionData.isNewUser;

initSession(req, sessionData);
} else if (provider.type === 'credentials' && req.method === 'POST') {
if (!body || !('action' in body)) {
throw new InvalidAction('Missing action in body');
initSession(req, sessionData);
} else if (provider.type === 'credentials' && req.method === 'POST') {
if (!body || !('action' in body)) {
throw new InvalidAction('Missing action in body');
}
const { action, csrfToken, email, password } = body;
await verifyCSRFFromRequestOrThrow(csrfToken, req.headers.cookie || '');

const sessionData =
action === 'login'
? await handleSignInOrRegistration({
providerType: 'credentials',
action,
email,
password,
})
: await handleSignInOrRegistration({
providerType: 'credentials',
action,
email,
name: body.name,
password,
});

isNewUser = sessionData.isNewUser;

initSession(req, sessionData);
} else {
throw new InvalidProvider(`Provider type ${provider.type} is not supported. Method=${req.method}`);
}
const { action, csrfToken, email, password } = body;
await verifyCSRFFromRequestOrThrow(csrfToken, req.headers.cookie || '');

const sessionData =
action === 'login'
? await handleSignInOrRegistration({
providerType: 'credentials',
action,
email,
password,
})
: await handleSignInOrRegistration({
providerType: 'credentials',
action,
email,
name: body.name,
password,
});

isNewUser = sessionData.isNewUser;

initSession(req, sessionData);
} else {
throw new InvalidProvider(`Provider type ${provider.type} is not supported. Method=${req.method}`);
}

if (!req.session.user) {
throw new AuthError('Session not initialized');
}
if (!req.session.user) {
throw new AuthError('Session not initialized');
}

// check for remembered device - emailVerification cannot be bypassed
if (
cookies[rememberDevice.name] &&
Array.isArray(req.session.pendingVerification) &&
req.session.pendingVerification.length > 0 &&
req.session.pendingVerification.find((item) => item.type !== 'email')
) {
const deviceId = cookies[rememberDevice.name];
const isDeviceRemembered = await hasRememberDeviceRecord({
userId: req.session.user.id,
deviceId,
ipAddress: res.locals.ipAddress || getApiAddressFromReq(req),
userAgent: req.get('User-Agent'),
});
if (isDeviceRemembered) {
req.session.pendingVerification = null;
} else {
// deviceId is not valid, remove cookie
clearCookie(rememberDevice.name, rememberDevice.options);
// check for remembered device - emailVerification cannot be bypassed
if (
cookies[rememberDevice.name] &&
Array.isArray(req.session.pendingVerification) &&
req.session.pendingVerification.length > 0 &&
req.session.pendingVerification.find((item) => item.type !== 'email')
) {
const deviceId = cookies[rememberDevice.name];
const isDeviceRemembered = await hasRememberDeviceRecord({
userId: req.session.user.id,
deviceId,
userAgent: req.get('User-Agent'),
});
if (isDeviceRemembered) {
req.session.pendingVerification = null;
// refresh cookie expiration
setCookie(rememberDevice.name, deviceId, rememberDevice.options);
} else {
// deviceId is not valid, remove cookie
clearCookie(rememberDevice.name, rememberDevice.options);
}
}
}

if (Array.isArray(req.session.pendingVerification) && req.session.pendingVerification.length > 0) {
const initialVerification = req.session.pendingVerification[0];
if (Array.isArray(req.session.pendingVerification) && req.session.pendingVerification.length > 0) {
const initialVerification = req.session.pendingVerification[0];

if (initialVerification.type === 'email') {
await sendEmailVerification(req.session.user.email, initialVerification.token, EMAIL_VERIFICATION_TOKEN_DURATION_HOURS);
} else if (initialVerification.type === '2fa-email') {
await sendVerificationCode(req.session.user.email, initialVerification.token, TOKEN_DURATION_MINUTES);
}
if (initialVerification.type === 'email') {
await sendEmailVerification(req.session.user.email, initialVerification.token, EMAIL_VERIFICATION_TOKEN_DURATION_HOURS);
} else if (initialVerification.type === '2fa-email') {
await sendVerificationCode(req.session.user.email, initialVerification.token, TOKEN_DURATION_MINUTES);
}

await setCsrfCookie(res);
await setCsrfCookie(res);

if (provider.type === 'oauth') {
redirect(res, `/auth/verify`);
} else {
sendJson(res, { error: false, redirect: `/auth/verify` });
}
} else {
if (isNewUser) {
await sendWelcomeEmail(req.session.user.email);
}
// No verification required
if (provider.type === 'oauth') {
redirect(res, ENV.JETSTREAM_CLIENT_URL);
if (provider.type === 'oauth') {
redirect(res, `/auth/verify`);
} else {
sendJson(res, { error: false, redirect: `/auth/verify` });
}
} else {
// this was an API call, client will handle redirect
sendJson(res, {
error: false,
redirect: ENV.JETSTREAM_CLIENT_URL,
});
if (isNewUser) {
await sendWelcomeEmail(req.session.user.email);
}
// No verification required
if (provider.type === 'oauth') {
redirect(res, ENV.JETSTREAM_CLIENT_URL);
} else {
// this was an API call, client will handle redirect
sendJson(res, {
error: false,
redirect: ENV.JETSTREAM_CLIENT_URL,
});
}
}
}

createUserActivityFromReq(req, res, {
action: 'LOGIN',
method: provider.provider.toUpperCase(),
success: true,
});
} catch (ex) {
createUserActivityFromReqWithError(req, res, ex, {
action: 'LOGIN',
email: body && 'email' in body ? body.email : undefined,
method: provider?.provider?.toUpperCase(),
success: false,
});
next(ensureAuthError(ex));
createUserActivityFromReq(req, res, {
action: 'LOGIN',
method: provider.provider.toUpperCase(),
success: true,
});
} catch (ex) {
createUserActivityFromReqWithError(req, res, ex, {
action: 'LOGIN',
email: body && 'email' in body ? body.email : undefined,
method: provider?.provider?.toUpperCase(),
success: false,
});
next(ensureAuthError(ex));
}
}
});
);

const verification = createRoute(routeDefinition.verification.validators, async ({ body, user, setCookie }, req, res, next) => {
try {
Expand Down
3 changes: 0 additions & 3 deletions apps/api/src/app/routes/route.middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,9 +138,6 @@ export async function checkAuth(req: express.Request, res: express.Response, nex
}
}

// TODO: consider adding a check for IP address - but should allow some buffer in case people change networks
// especially if the ip addresses are very far away

if (user && !pendingVerification) {
telemetryAddUserToAttributes(user);
return next();
Expand Down
2 changes: 1 addition & 1 deletion apps/landing/components/auth/VerifyEmailOr2fa.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ const FormSchema = z.object({
csrfToken: z.string(),
code: z.string().min(6).max(6),
type: z.enum(['email', '2fa-otp', '2fa-email']),
rememberDevice: z.boolean().optional().default(false),
rememberDevice: z.boolean().optional().default(true),
});

function getTitleText(authFactor: TwoFactorType, email?: Maybe<string>) {
Expand Down
Loading

0 comments on commit 3aa6e05

Please sign in to comment.