From c80df9b30ffbfbf1b9b75af92938a9941215a075 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Fri, 3 Nov 2023 21:04:06 -0700 Subject: [PATCH] Update crypto library, CryptoJS CVE & deprecation (#9350) So CryptoJS just dropped a bomb: everything they do by default is not as strong as it could be. Oh and by the way, the entire library is now deprecated. :( https://github.com/brix/crypto-js/security/advisories/GHSA-xwcq-pm8m-c4vf Unfortunately we can't just upgrade to the latest release 4.2.0 because the hashing algorithm has changed, and a user would no longer be able to login: the default hash generated by CryptoJS 4.2.0 won't match the hash generated by CryptoJS 4.1.0. Note that this is only an issue if someone got the contents of your database and wanted to figure out user passwords (but it still cost [$45,000 per password](https://eprint.iacr.org/2020/014.pdf) apparently?). In the wake of this CVE we're going to convert dbAuth to use the built-in `node:crypto` library instead, with more sensible default configuration. There are two areas where we use the crypto libs: 1. Hashing the user's password to store in the DB and compare on login 2. Encrypting/decrypting the session data in a cookie We're going to do this in a non-breaking way by supporting *both* the original CryptoJS-derived values, and the new `node:crypto` ones. The alternative would be to require every user to change their password, which seems like a non-starter. 1. On signup, store the hashedPassword using the new `node:crypto` algorithm 2. On login, compare the user's hashedPassword using the `node:crypto` algorithm: * If a match is found, user is logged in * If a match fails, fall back to the original CryptoJS algorithm (but using the `node:crypto` implementation) * If a match is found, update the `hashedPassword` in the database to the new algorithm, user is logged in * If a match is still not found, the user entered the wrong password. Likewise for cookies and login: 1. When encrypting the user's session, always use the new `node:crypto` algorithm 2. When decrypting the user's session, first try with `node:crypto` * If decrypting works, user is logged in * If decrypting fails, try the older CryptoJS algorithm ([I haven't figured how](https://github.com/brix/crypto-js/issues/468) to use `node:crypto` to decrypt something that was encrypted with CryptoJS yet, so we'll need to keep the dependency on CryptoJS around for now) * If decrypting works, re-encrypt the cookie using the new `node:crypto` algorithm, user is logged in * If decrypting still fails, the session is invalid (someone tampered with the cookie) so log them out ## Notifying Users We could announce in the Release Notes that if a platform wants the absolute safest route, they should change their `SESSION_SECRET` *and* have users change their password if, for example, they suspect that their database may have been compromised before our release. The next most secure thing would be to just change `SESSION_SECRET` which would log everyone out, and on next login their password will get re-hashed with the new algorithm. But the default for most people will probably be to just go about business as usual, and as time goes by more and more users' passwords will be re-hashed on login. Related to #9337 #9338 #9339 #9340 --------- Co-authored-by: Dominic Saadi --- .../auth-providers/dbAuth/api/package.json | 2 - .../dbAuth/api/src/DbAuthHandler.ts | 98 ++++-- .../api/src/__tests__/DbAuthHandler.test.js | 94 +++++- .../dbAuth/api/src/__tests__/shared.test.ts | 282 +++++++++++++----- .../auth-providers/dbAuth/api/src/shared.ts | 151 ++++++++-- .../auth-providers/dbAuth/setup/package.json | 2 - .../dbAuth/setup/src/setupData.ts | 8 +- packages/cli/package.json | 4 - .../generate/secret/__tests__/secret.test.js | 21 +- .../src/commands/generate/secret/secret.js | 10 +- .../__tests__/graphiqlHandler.test.js | 16 +- .../setup/graphiql/graphiqlHandler.js | 4 +- .../setup/graphiql/supportedProviders.js | 11 +- .../lib/authProviderEncoders/dbAuthEncoder.ts | 8 +- packages/studio/package.json | 2 - .../smoke-tests/auth/tests/authChecks.spec.ts | 79 +++-- tasks/smoke-tests/shared/common.ts | 58 ++-- yarn.lock | 47 --- 18 files changed, 581 insertions(+), 316 deletions(-) diff --git a/packages/auth-providers/dbAuth/api/package.json b/packages/auth-providers/dbAuth/api/package.json index 0e0033b5a22d..2d4ec6095086 100644 --- a/packages/auth-providers/dbAuth/api/package.json +++ b/packages/auth-providers/dbAuth/api/package.json @@ -26,7 +26,6 @@ "@redwoodjs/project-config": "6.0.7", "base64url": "3.0.1", "core-js": "3.32.2", - "crypto-js": "4.1.1", "md5": "2.3.0", "uuid": "9.0.0" }, @@ -35,7 +34,6 @@ "@babel/core": "^7.22.20", "@redwoodjs/api": "6.0.7", "@simplewebauthn/server": "7.3.1", - "@types/crypto-js": "4.1.1", "@types/md5": "2.3.2", "@types/uuid": "9.0.2", "jest": "29.7.0", diff --git a/packages/auth-providers/dbAuth/api/src/DbAuthHandler.ts b/packages/auth-providers/dbAuth/api/src/DbAuthHandler.ts index c48ecc8586d1..7608844813e3 100644 --- a/packages/auth-providers/dbAuth/api/src/DbAuthHandler.ts +++ b/packages/auth-providers/dbAuth/api/src/DbAuthHandler.ts @@ -13,7 +13,6 @@ import type { } from '@simplewebauthn/typescript-types' import type { APIGatewayProxyEvent, Context as LambdaContext } from 'aws-lambda' import base64url from 'base64url' -import CryptoJS from 'crypto-js' import md5 from 'md5' import { v4 as uuidv4 } from 'uuid' @@ -24,11 +23,15 @@ import * as DbAuthError from './errors' import { cookieName, decryptSession, + encryptSession, extractCookie, getSession, hashPassword, + legacyHashPassword, + isLegacySession, hashToken, webAuthnSession, + extractHashingOptions, } from './shared' type SetCookieHeader = { 'set-cookie': string } @@ -569,11 +572,15 @@ export class DbAuthHandler< async getToken() { try { const user = await this._getCurrentUser() + let headers = {} - // need to return *something* for our existing Authorization header stuff - // to work, so return the user's ID in case we can use it for something - // in the future - return [user[this.options.authFields.id]] + // if the session was encrypted with the old algorithm, re-encrypt it + // with the new one + if (isLegacySession(this.cookie)) { + headers = this._loginResponse(user)[1] + } + + return [user[this.options.authFields.id], headers] } catch (e: any) { if (e instanceof DbAuthError.NotLoggedInError) { return this._logoutResponse() @@ -636,12 +643,16 @@ export class DbAuthHandler< } let user = await this._findUserByToken(resetToken as string) - const [hashedPassword] = hashPassword(password, user.salt) + const [hashedPassword] = hashPassword(password, { + salt: user.salt, + }) + const [legacyHashedPassword] = legacyHashPassword(password, user.salt) if ( - !(this.options.resetPassword as ResetPasswordFlowOptions) + (!(this.options.resetPassword as ResetPasswordFlowOptions) .allowReusedPassword && - user.hashedPassword === hashedPassword + user.hashedPassword === hashedPassword) || + user.hashedPassword === legacyHashedPassword ) { throw new DbAuthError.ReusedPasswordError( ( @@ -1154,11 +1165,6 @@ export class DbAuthHandler< return meta } - // encrypts a string with the SESSION_SECRET - _encrypt(data: string) { - return CryptoJS.AES.encrypt(data, process.env.SESSION_SECRET as string) - } - // returns the set-cookie header to be returned in the request (effectively // creates the session) _createSessionHeader( @@ -1166,9 +1172,9 @@ export class DbAuthHandler< csrfToken: string ): SetCookieHeader { const session = JSON.stringify(data) + ';' + csrfToken - const encrypted = this._encrypt(session) + const encrypted = encryptSession(session) const cookie = [ - `${cookieName(this.options.cookie?.name)}=${encrypted.toString()}`, + `${cookieName(this.options.cookie?.name)}=${encrypted}`, ...this._cookieAttributes({ expires: this.sessionExpiresDate }), ].join(';') @@ -1279,19 +1285,56 @@ export class DbAuthHandler< ) } - // is password correct? - const [hashedPassword, _salt] = hashPassword( - password, - user[this.options.authFields.salt] + await this._verifyPassword(user, password) + return user + } + + // extracts scrypt strength options from hashed password (if present) and + // compares the hashed plain text password just submitted using those options + // with the one in the database. Falls back to the legacy CryptoJS algorihtm + // if no options are present. + async _verifyPassword(user: Record, password: string) { + const options = extractHashingOptions( + user[this.options.authFields.hashedPassword] as string ) - if (hashedPassword === user[this.options.authFields.hashedPassword]) { - return user + + if (Object.keys(options).length) { + // hashed using the node:crypto algorithm + const [hashedPassword] = hashPassword(password, { + salt: user[this.options.authFields.salt] as string, + options, + }) + + if (hashedPassword === user[this.options.authFields.hashedPassword]) { + return user + } } else { - throw new DbAuthError.IncorrectPasswordError( - username, - (this.options.login as LoginFlowOptions)?.errors?.incorrectPassword + // fallback to old CryptoJS hashing + const [legacyHashedPassword] = legacyHashPassword( + password, + user[this.options.authFields.salt] as string ) + + if ( + legacyHashedPassword === user[this.options.authFields.hashedPassword] + ) { + const [newHashedPassword] = hashPassword(password, { + salt: user[this.options.authFields.salt] as string, + }) + + // update user's hash to the new algorithm + await this.dbAccessor.update({ + where: { id: user.id }, + data: { [this.options.authFields.hashedPassword]: newHashedPassword }, + }) + return user + } } + + throw new DbAuthError.IncorrectPasswordError( + user[this.options.authFields.username] as string, + (this.options.login as LoginFlowOptions)?.errors?.incorrectPassword + ) } // gets the user from the database and returns only its ID @@ -1351,8 +1394,7 @@ export class DbAuthHandler< ) } - // if we get here everything is good, call the app's signup handler and let - // them worry about scrubbing data and saving to the DB + // if we get here everything is good, call the app's signup handler const [hashedPassword, salt] = hashPassword(password) const newUser = await (this.options.signup as SignupFlowOptions).handler({ username, @@ -1406,9 +1448,7 @@ export class DbAuthHandler< ] { const sessionData = { id: user[this.options.authFields.id] } - // TODO: this needs to go into graphql somewhere so that each request makes - // a new CSRF token and sets it in both the encrypted session and the - // csrf-token header + // TODO: this needs to go into graphql somewhere so that each request makes a new CSRF token and sets it in both the encrypted session and the csrf-token header const csrfToken = DbAuthHandler.CSRF_TOKEN return [ diff --git a/packages/auth-providers/dbAuth/api/src/__tests__/DbAuthHandler.test.js b/packages/auth-providers/dbAuth/api/src/__tests__/DbAuthHandler.test.js index 5abdf3aa731c..4d19f4847055 100644 --- a/packages/auth-providers/dbAuth/api/src/__tests__/DbAuthHandler.test.js +++ b/packages/auth-providers/dbAuth/api/src/__tests__/DbAuthHandler.test.js @@ -1,7 +1,6 @@ +import crypto from 'node:crypto' import path from 'node:path' -import CryptoJS from 'crypto-js' - import { DbAuthHandler } from '../DbAuthHandler' import * as dbAuthError from '../errors' import { hashToken } from '../shared' @@ -81,10 +80,10 @@ const db = new DbMock(['user', 'userCredential']) const UUID_REGEX = /\b[0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12}\b/ -const SET_SESSION_REGEX = /^session=[a-zA-Z0-9+=/]+;/ +const SET_SESSION_REGEX = /^session=[a-zA-Z0-9+=/]|[a-zA-Z0-9+=/]+;/ const UTC_DATE_REGEX = /\w{3}, \d{2} \w{3} \d{4} [\d:]{8} GMT/ const LOGOUT_COOKIE = 'session=;Expires=Thu, 01 Jan 1970 00:00:00 GMT' - +const SESSION_SECRET = '540d03ebb00b441f8f7442cbc39958ad' const FIXTURE_PATH = path.resolve( __dirname, '../../../../../../__fixtures__/example-todo-main' @@ -102,9 +101,10 @@ const createDbUser = async (attributes = {}) => { return await db.user.create({ data: { email: 'rob@redwoodjs.com', + // default hashedPassword is from `node:crypto` hashedPassword: - '0c2b24e20ee76a887eac1415cc2c175ff961e7a0f057cead74789c43399dd5ba', - salt: '2ef27f4073c603ba8b7807c6de6d6a89', + '230847bea5154b6c7d281d09593ad1be26fa03a93c04a73bcc2b608c073a8213|16384|8|1', + salt: 'ba8b7807c6de6d6a892ef27f4073c603', ...attributes, }, }) @@ -119,7 +119,16 @@ const expectLoggedInResponse = (response) => { } const encryptToCookie = (data) => { - return `session=${CryptoJS.AES.encrypt(data, process.env.SESSION_SECRET)}` + const iv = crypto.randomBytes(16) + const cipher = crypto.createCipheriv( + 'aes-256-cbc', + SESSION_SECRET.substring(0, 32), + iv + ) + let encryptedSession = cipher.update(data, 'utf-8', 'base64') + encryptedSession += cipher.final('base64') + + return `session=${encryptedSession}|${iv.toString('base64')}` } let event, context, options @@ -129,7 +138,7 @@ describe('dbAuth', () => { // hide deprecation warnings during test jest.spyOn(console, 'warn').mockImplementation(() => {}) // encryption key so results are consistent regardless of settings in .env - process.env.SESSION_SECRET = 'nREjs1HPS7cFia6tQHK70EWGtfhOgbqJQKsHQz3S' + process.env.SESSION_SECRET = SESSION_SECRET delete process.env.DBAUTH_COOKIE_DOMAIN event = { @@ -581,7 +590,7 @@ describe('dbAuth', () => { event = { headers: { cookie: - 'session=U2FsdGVkX1/zRHVlEQhffsOufy7VLRAR6R4gb818vxblQQJFZI6W/T8uzxNUbQMx', + 'session=ko6iXKV11DSjb6kFJ4iwcf1FEqa5wPpbL1sdtKiV51Y=|cQaYkOPG/r3ILxWiFiz90w==', }, } const dbAuth = new DbAuthHandler(event, context, options) @@ -614,7 +623,7 @@ describe('dbAuth', () => { event.body = JSON.stringify({ method: 'logout' }) event.httpMethod = 'GET' event.headers.cookie = - 'session=U2FsdGVkX1/zRHVlEQhffsOufy7VLRAR6R4gb818vxblQQJFZI6W/T8uzxNUbQMx' + 'session=ko6iXKV11DSjb6kFJ4iwcf1FEqa5wPpbL1sdtKiV51Y=|cQaYkOPG/r3ILxWiFiz90w==' const dbAuth = new DbAuthHandler(event, context, options) const response = await dbAuth.invoke() @@ -625,7 +634,7 @@ describe('dbAuth', () => { event.body = JSON.stringify({ method: 'foobar' }) event.httpMethod = 'POST' event.headers.cookie = - 'session=U2FsdGVkX1/zRHVlEQhffsOufy7VLRAR6R4gb818vxblQQJFZI6W/T8uzxNUbQMx' + 'session=ko6iXKV11DSjb6kFJ4iwcf1FEqa5wPpbL1sdtKiV51Y=|cQaYkOPG/r3ILxWiFiz90w==' const dbAuth = new DbAuthHandler(event, context, options) const response = await dbAuth.invoke() @@ -636,7 +645,7 @@ describe('dbAuth', () => { event.body = JSON.stringify({ method: 'logout' }) event.httpMethod = 'POST' event.headers.cookie = - 'session=U2FsdGVkX1/zRHVlEQhffsOufy7VLRAR6R4gb818vxblQQJFZI6W/T8uzxNUbQMx' + 'session=ko6iXKV11DSjb6kFJ4iwcf1FEqa5wPpbL1sdtKiV51Y=|cQaYkOPG/r3ILxWiFiz90w==' const dbAuth = new DbAuthHandler(event, context, options) dbAuth.logout = jest.fn(() => { throw Error('Logout error') @@ -674,7 +683,7 @@ describe('dbAuth', () => { event.body = JSON.stringify({ method: 'logout' }) event.httpMethod = 'POST' event.headers.cookie = - 'session=U2FsdGVkX1/zRHVlEQhffsOufy7VLRAR6R4gb818vxblQQJFZI6W/T8uzxNUbQMx' + 'session=ko6iXKV11DSjb6kFJ4iwcf1FEqa5wPpbL1sdtKiV51Y=|cQaYkOPG/r3ILxWiFiz90w==' const dbAuth = new DbAuthHandler(event, context, options) dbAuth.logout = jest.fn(() => ['body', { foo: 'bar' }]) const response = await dbAuth.invoke() @@ -1595,6 +1604,27 @@ describe('dbAuth', () => { expect(response[0]).toEqual('{"error":"User not found"}') }) + + it('re-encrypts the session cookie if using the legacy algorithm', async () => { + await createDbUser({ id: 7 }) + event = { + headers: { + // legacy session with { id: 7 } for userID + cookie: 'session=U2FsdGVkX1+s7seQJnVgGgInxuXm13l8VvzA3Mg2fYg=', + }, + } + process.env.SESSION_SECRET = + 'QKxN2vFSHAf94XYynK8LUALfDuDSdFowG6evfkFX8uszh4YZqhTiqEdshrhWbwbw' + + const dbAuth = new DbAuthHandler(event, context, options) + const [userId, headers] = await dbAuth.getToken() + + expect(userId).toEqual(7) + expect(headers['set-cookie']).toMatch(SET_SESSION_REGEX) + + // set session back to default + process.env.SESSION_SECRET = SESSION_SECRET + }) }) describe('When a developer has set GraphiQL headers to mock a session cookie', () => { @@ -2168,11 +2198,11 @@ describe('dbAuth', () => { `Expires=${dbAuth.sessionExpiresDate}` ) // can't really match on the session value since it will change on every render, - // due to CSRF token generation but we can check that it contains a only the - // characters that would be returned by the hash function + // due to CSRF token generation but we can check that it contains only the + // characters that would be returned by the encrypt function expect(headers['set-cookie']).toMatch(SET_SESSION_REGEX) // and we can check that it's a certain number of characters - expect(headers['set-cookie'].split(';')[0].length).toEqual(72) + expect(headers['set-cookie'].split(';')[0].length).toEqual(77) }) }) @@ -2335,6 +2365,38 @@ describe('dbAuth', () => { expect(user.id).toEqual(dbUser.id) }) + + it('returns the user if password is hashed with legacy algorithm', async () => { + const dbUser = await createDbUser({ + // CryptoJS hashed password + hashedPassword: + '0c2b24e20ee76a887eac1415cc2c175ff961e7a0f057cead74789c43399dd5ba', + salt: '2ef27f4073c603ba8b7807c6de6d6a89', + }) + const dbAuth = new DbAuthHandler(event, context, options) + const user = await dbAuth._verifyUser(dbUser.email, 'password') + + expect(user.id).toEqual(dbUser.id) + }) + + it('updates the user hashPassword to the new algorithm', async () => { + const dbUser = await createDbUser({ + // CryptoJS hashed password + hashedPassword: + '0c2b24e20ee76a887eac1415cc2c175ff961e7a0f057cead74789c43399dd5ba', + salt: '2ef27f4073c603ba8b7807c6de6d6a89', + }) + const dbAuth = new DbAuthHandler(event, context, options) + await dbAuth._verifyUser(dbUser.email, 'password') + const user = await db.user.findFirst({ where: { id: dbUser.id } }) + + // password now hashed by node:crypto + expect(user.hashedPassword).toEqual( + 'f20d69d478fa1afc85057384e21bd457a76b23b23e2a94f5bd982976f700a552|16384|8|1' + ) + // salt should remain the same + expect(user.salt).toEqual('2ef27f4073c603ba8b7807c6de6d6a89') + }) }) describe('_getCurrentUser()', () => { diff --git a/packages/auth-providers/dbAuth/api/src/__tests__/shared.test.ts b/packages/auth-providers/dbAuth/api/src/__tests__/shared.test.ts index c96c7bc85892..67b1becccc60 100644 --- a/packages/auth-providers/dbAuth/api/src/__tests__/shared.test.ts +++ b/packages/auth-providers/dbAuth/api/src/__tests__/shared.test.ts @@ -1,7 +1,7 @@ +import crypto from 'node:crypto' import path from 'node:path' import type { APIGatewayProxyEvent } from 'aws-lambda' -import CryptoJS from 'crypto-js' import * as error from '../errors' import { @@ -9,19 +9,31 @@ import { getSession, cookieName, hashPassword, + isLegacySession, + legacyHashPassword, decryptSession, dbAuthSession, webAuthnSession, + extractHashingOptions, } from '../shared' const FIXTURE_PATH = path.resolve( __dirname, '../../../../../../__fixtures__/example-todo-main' ) -process.env.SESSION_SECRET = 'nREjs1HPS7cFia6tQHK70EWGtfhOgbqJQKsHQz3S' +const SESSION_SECRET = '540d03ebb00b441f8f7442cbc39958ad' const encrypt = (data) => { - return CryptoJS.AES.encrypt(data, process.env.SESSION_SECRET).toString() + const iv = crypto.randomBytes(16) + const cipher = crypto.createCipheriv( + 'aes-256-cbc', + SESSION_SECRET.substring(0, 32), + iv + ) + let encryptedSession = cipher.update(data, 'utf-8', 'base64') + encryptedSession += cipher.final('base64') + + return `${encryptedSession}|${iv.toString('base64')}` } function dummyEvent(cookie?: string) { @@ -80,7 +92,27 @@ describe('cookieName()', () => { }) }) +describe('isLegacySession()', () => { + it('returns `true` if the session cookie appears to be encrypted with CryptoJS', () => { + expect( + isLegacySession('U2FsdGVkX1+s7seQJnVgGgInxuXm13l8VvzA3Mg2fYg=') + ).toEqual(true) + }) + + it('returns `false` if the session cookie appears to be encrypted with node:crypto', () => { + expect( + isLegacySession( + 'ko6iXKV11DSjb6kFJ4iwcf1FEqa5wPpbL1sdtKiV51Y=|cQaYkOPG/r3ILxWiFiz90w==' + ) + ).toEqual(false) + }) +}) + describe('decryptSession()', () => { + beforeEach(() => { + process.env.SESSION_SECRET = SESSION_SECRET + }) + it('returns an empty array if no session', () => { expect(decryptSession(null)).toEqual([]) }) @@ -103,9 +135,23 @@ describe('decryptSession()', () => { expect(decryptSession(text)).toEqual([first, second]) }) + + it('decrypts a session cookie that was created with the legacy CryptoJS algorithm', () => { + process.env.SESSION_SECRET = + 'QKxN2vFSHAf94XYynK8LUALfDuDSdFowG6evfkFX8uszh4YZqhTiqEdshrhWbwbw' + const [json] = decryptSession( + 'U2FsdGVkX1+s7seQJnVgGgInxuXm13l8VvzA3Mg2fYg=' + ) + + expect(json).toEqual({ id: 7 }) + }) }) describe('dbAuthSession()', () => { + beforeEach(() => { + process.env.SESSION_SECRET = SESSION_SECRET + }) + it('returns null if no cookies', () => { expect(dbAuthSession(dummyEvent(), 'session_%port%')).toEqual(null) }) @@ -136,7 +182,52 @@ describe('webAuthnSession', () => { describe('hashPassword', () => { it('hashes a password with a given salt and returns both', () => { - const [hash, salt] = hashPassword( + const [hash, salt] = hashPassword('password', { + salt: 'ba8b7807c6de6d6a892ef27f4073c603', + }) + + expect(hash).toEqual( + '230847bea5154b6c7d281d09593ad1be26fa03a93c04a73bcc2b608c073a8213|16384|8|1' + ) + expect(salt).toEqual('ba8b7807c6de6d6a892ef27f4073c603') + }) + + it('hashes a password with a generated salt if none provided', () => { + const [hash, salt] = hashPassword('password') + + expect(hash).toMatch(/^[a-f0-9]+|16384|8|1$/) + expect(hash.length).toEqual(74) + expect(salt).toMatch(/^[a-f0-9]+$/) + expect(salt.length).toEqual(64) + }) + + it('normalizes strings so utf-8 variants hash to the same output', () => { + const salt = crypto.randomBytes(32).toString('hex') + const [hash1] = hashPassword('\u0041\u006d\u00e9\u006c\u0069\u0065', { + salt, + }) // Amélie + const [hash2] = hashPassword('\u0041\u006d\u0065\u0301\u006c\u0069\u0065', { + salt, + }) // Amélie but separate e and accent codepoints + + expect(hash1).toEqual(hash2) + }) + + it('encodes the scrypt difficulty options into the hash', () => { + const [hash] = hashPassword('password', { + options: { cost: 8192, blockSize: 16, parallelization: 2 }, + }) + const [_hash, cost, blockSize, parallelization] = hash.split('|') + + expect(cost).toEqual('8192') + expect(blockSize).toEqual('16') + expect(parallelization).toEqual('2') + }) +}) + +describe('legacyHashPassword', () => { + it('hashes a password with CryptoJS given a salt and returns both', () => { + const [hash, salt] = legacyHashPassword( 'password', '2ef27f4073c603ba8b7807c6de6d6a89' ) @@ -148,41 +239,62 @@ describe('hashPassword', () => { }) it('hashes a password with a generated salt if none provided', () => { - const [hash, salt] = hashPassword('password') + const [hash, salt] = legacyHashPassword('password') expect(hash).toMatch(/^[a-f0-9]+$/) expect(hash.length).toEqual(64) expect(salt).toMatch(/^[a-f0-9]+$/) - expect(salt.length).toEqual(32) + expect(salt.length).toEqual(64) }) +}) - describe('session cookie extraction', () => { - let event +describe('session cookie extraction', () => { + let event - const encryptToCookie = (data) => { - return `session=${CryptoJS.AES.encrypt(data, process.env.SESSION_SECRET)}` + const encryptToCookie = (data) => { + return `session=${encrypt(data)}` + } + + beforeEach(() => { + event = { + queryStringParameters: {}, + path: '/.redwood/functions/auth', + headers: {}, } + }) - beforeEach(() => { - event = { - queryStringParameters: {}, - path: '/.redwood/functions/auth', - headers: {}, - } - }) + it('extracts from the event', () => { + const cookie = encryptToCookie( + JSON.stringify({ id: 9999999999 }) + ';' + 'token' + ) - it('extracts from the event', () => { - const cookie = encryptToCookie( - JSON.stringify({ id: 9999999999 }) + ';' + 'token' - ) + event = { + headers: { + cookie, + }, + } - event = { - headers: { - cookie, - }, - } + expect(extractCookie(event)).toEqual(cookie) + }) - expect(extractCookie(event)).toEqual(cookie) + it('extract cookie handles non-JSON event body', () => { + event.body = '' + + expect(extractCookie(event)).toBeUndefined() + }) + + describe('when in development', () => { + const curNodeEnv = process.env.NODE_ENV + + beforeAll(() => { + // Session cookie from graphiQLHeaders only extracted in dev + process.env.NODE_ENV = 'development' + }) + + afterAll(() => { + process.env.NODE_ENV = curNodeEnv + event = {} + expect(process.env.NODE_ENV).toBe('test') }) it('extract cookie handles non-JSON event body', () => { @@ -191,69 +303,81 @@ describe('hashPassword', () => { expect(extractCookie(event)).toBeUndefined() }) - describe('when in development', () => { - const curNodeEnv = process.env.NODE_ENV + it('extracts GraphiQL cookie from the header extensions', () => { + const dbUserId = 42 - beforeAll(() => { - // Session cookie from graphiQLHeaders only extracted in dev - process.env.NODE_ENV = 'development' + const cookie = encryptToCookie(JSON.stringify({ id: dbUserId })) + event.body = JSON.stringify({ + extensions: { + headers: { + 'auth-provider': 'dbAuth', + cookie, + authorization: 'Bearer ' + dbUserId, + }, + }, }) - afterAll(() => { - process.env.NODE_ENV = curNodeEnv - event = {} - expect(process.env.NODE_ENV).toBe('test') - }) + expect(extractCookie(event)).toEqual(cookie) + }) - it('extract cookie handles non-JSON event body', () => { - event.body = '' + it('overwrites cookie with event header GraphiQL when in dev', () => { + const sessionCookie = encryptToCookie( + JSON.stringify({ id: 9999999999 }) + ';' + 'token' + ) - expect(extractCookie(event)).toBeUndefined() - }) + event = { + headers: { + cookie: sessionCookie, + }, + } - it('extracts GraphiQL cookie from the header extensions', () => { - const dbUserId = 42 - - const cookie = encryptToCookie(JSON.stringify({ id: dbUserId })) - event.body = JSON.stringify({ - extensions: { - headers: { - 'auth-provider': 'dbAuth', - cookie, - authorization: 'Bearer ' + dbUserId, - }, - }, - }) + const dbUserId = 42 - expect(extractCookie(event)).toEqual(cookie) + const cookie = encryptToCookie(JSON.stringify({ id: dbUserId })) + event.body = JSON.stringify({ + extensions: { + headers: { + 'auth-provider': 'dbAuth', + cookie, + authorization: 'Bearer ' + dbUserId, + }, + }, }) - it('overwrites cookie with event header GraphiQL when in dev', () => { - const sessionCookie = encryptToCookie( - JSON.stringify({ id: 9999999999 }) + ';' + 'token' - ) + expect(extractCookie(event)).toEqual(cookie) + }) + }) +}) - event = { - headers: { - cookie: sessionCookie, - }, - } - - const dbUserId = 42 - - const cookie = encryptToCookie(JSON.stringify({ id: dbUserId })) - event.body = JSON.stringify({ - extensions: { - headers: { - 'auth-provider': 'dbAuth', - cookie, - authorization: 'Bearer ' + dbUserId, - }, - }, - }) +describe('extractHashingOptions()', () => { + it('returns an empty object if no options', () => { + expect(extractHashingOptions('')).toEqual({}) + expect( + extractHashingOptions( + '0c2b24e20ee76a887eac1415cc2c175ff961e7a0f057cead74789c43399dd5ba' + ) + ).toEqual({}) + expect( + extractHashingOptions( + '0c2b24e20ee76a887eac1415cc2c175ff961e7a0f057cead74789c43399dd5ba|1' + ) + ).toEqual({}) + expect( + extractHashingOptions( + '0c2b24e20ee76a887eac1415cc2c175ff961e7a0f057cead74789c43399dd5ba|1|2' + ) + ).toEqual({}) + }) - expect(extractCookie(event)).toEqual(cookie) - }) + it('returns an object with scrypt options', () => { + expect( + extractHashingOptions( + '0c2b24e20ee76a887eac1415cc2c175ff961e7a0f057cead74789c43399dd5ba|16384|8|1' + ) + ).toEqual({ + cost: 16384, + blockSize: 8, + parallelization: 1, }) }) }) diff --git a/packages/auth-providers/dbAuth/api/src/shared.ts b/packages/auth-providers/dbAuth/api/src/shared.ts index ced1e85f6a04..78cf1db0de46 100644 --- a/packages/auth-providers/dbAuth/api/src/shared.ts +++ b/packages/auth-providers/dbAuth/api/src/shared.ts @@ -1,15 +1,45 @@ +import crypto from 'node:crypto' + import type { APIGatewayProxyEvent } from 'aws-lambda' -import CryptoJS from 'crypto-js' import { getConfig, getConfigPath } from '@redwoodjs/project-config' import * as DbAuthError from './errors' +type ScryptOptions = { + cost?: number + blockSize?: number + parallelization?: number + N?: number + r?: number + p?: number + maxmem?: number +} + +const DEFAULT_SCRYPT_OPTIONS: ScryptOptions = { + cost: 2 ** 14, + blockSize: 8, + parallelization: 1, +} + // Extracts the cookie from an event, handling lower and upper case header names. const eventHeadersCookie = (event: APIGatewayProxyEvent) => { return event.headers.cookie || event.headers.Cookie } +const getPort = () => { + let configPath + + try { + configPath = getConfigPath() + } catch { + // If this throws, we're in a serverless environment, and the `redwood.toml` file doesn't exist. + return 8911 + } + + return getConfig(configPath).api.port +} + // When in development environment, check for cookie in the request extension headers // if user has generated graphiql headers const eventGraphiQLHeadersCookie = (event: APIGatewayProxyEvent) => { @@ -29,23 +59,71 @@ const eventGraphiQLHeadersCookie = (event: APIGatewayProxyEvent) => { return } +// decrypts session text using old CryptoJS algorithm (using node:crypto library) +const legacyDecryptSession = (encryptedText: string) => { + const cypher = Buffer.from(encryptedText, 'base64') + const salt = cypher.slice(8, 16) + const password = Buffer.concat([ + Buffer.from(process.env.SESSION_SECRET as string, 'binary'), + salt, + ]) + const md5Hashes = [] + let digest = password + for (let i = 0; i < 3; i++) { + md5Hashes[i] = crypto.createHash('md5').update(digest).digest() + digest = Buffer.concat([md5Hashes[i], password]) + } + const key = Buffer.concat([md5Hashes[0], md5Hashes[1]]) + const iv = md5Hashes[2] + const contents = cypher.slice(16) + const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv) + + return decipher.update(contents) + decipher.final('utf-8') +} + // Extracts the session cookie from an event, handling both // development environment GraphiQL headers and production environment headers. export const extractCookie = (event: APIGatewayProxyEvent) => { return eventGraphiQLHeadersCookie(event) || eventHeadersCookie(event) } +// whether this encrypted session was made with the old CryptoJS algorithm +export const isLegacySession = (text: string | undefined) => { + if (!text) { + return false + } + + const [_encryptedText, iv] = text.split('|') + return !iv +} + // decrypts the session cookie and returns an array: [data, csrf] export const decryptSession = (text: string | null) => { if (!text || text.trim() === '') { return [] } + let decoded + // if cookie contains a pipe then it was encrypted using the `node:crypto` + // algorithm (first element is the ecrypted data, second is the initialization vector) + // otherwise fall back to using the older CryptoJS algorithm + const [encryptedText, iv] = text.split('|') + try { - const decoded = CryptoJS.AES.decrypt( - text, - process.env.SESSION_SECRET as string - ).toString(CryptoJS.enc.Utf8) + if (iv) { + // decrypt using the `node:crypto` algorithm + const decipher = crypto.createDecipheriv( + 'aes-256-cbc', + (process.env.SESSION_SECRET as string).substring(0, 32), + Buffer.from(iv, 'base64') + ) + decoded = + decipher.update(encryptedText, 'base64', 'utf-8') + + decipher.final('utf-8') + } else { + decoded = legacyDecryptSession(text) + } + const [data, csrf] = decoded.split(';') const json = JSON.parse(data) @@ -55,6 +133,19 @@ export const decryptSession = (text: string | null) => { } } +export const encryptSession = (dataString: string) => { + const iv = crypto.randomBytes(16) + const cipher = crypto.createCipheriv( + 'aes-256-cbc', + (process.env.SESSION_SECRET as string).substring(0, 32), + iv + ) + let encryptedData = cipher.update(dataString, 'utf-8', 'base64') + encryptedData += cipher.final('base64') + + return `${encryptedData}|${iv.toString('base64')}` +} + // returns the actual value of the session cookie export const getSession = ( text: string | undefined, @@ -73,7 +164,7 @@ export const getSession = ( return null } - return sessionCookie.split('=')[1].trim() + return sessionCookie.replace(`${cookieName(cookieNameOption)}=`, '').trim() } // Convenience function to get session, decrypt, and return session data all @@ -110,16 +201,37 @@ export const webAuthnSession = (event: APIGatewayProxyEvent) => { } export const hashToken = (token: string) => { - return CryptoJS.SHA256(token).toString(CryptoJS.enc.Hex) + return crypto.createHash('sha256').update(token).digest('hex') } // hashes a password using either the given `salt` argument, or creates a new // salt and hashes using that. Either way, returns an array with [hash, salt] -export const hashPassword = (text: string, salt?: string) => { - const useSalt = salt || CryptoJS.lib.WordArray.random(128 / 8).toString() +// normalizes the string in case it contains unicode characters: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/normalize +// TODO: Add validation that the options are valid values for the scrypt algorithm +export const hashPassword = ( + text: string, + { + salt = crypto.randomBytes(32).toString('hex'), + options = DEFAULT_SCRYPT_OPTIONS, + }: { salt?: string; options?: ScryptOptions } = {} +) => { + const encryptedString = crypto + .scryptSync(text.normalize('NFC'), salt, 32, options) + .toString('hex') + const optionsToString = [ + options.cost, + options.blockSize, + options.parallelization, + ] + return [`${encryptedString}|${optionsToString.join('|')}`, salt] +} +// uses the old algorithm from CryptoJS: +// CryptoJS.PBKDF2(password, salt, { keySize: 8 }).toString() +export const legacyHashPassword = (text: string, salt?: string) => { + const useSalt = salt || crypto.randomBytes(32).toString('hex') return [ - CryptoJS.PBKDF2(text, useSalt, { keySize: 256 / 32 }).toString(), + crypto.pbkdf2Sync(text, useSalt, 1, 32, 'SHA1').toString('hex'), useSalt, ] } @@ -131,15 +243,16 @@ export const cookieName = (name: string | undefined) => { return cookieName } -function getPort() { - let configPath +export const extractHashingOptions = (text: string): ScryptOptions => { + const [_hash, ...options] = text.split('|') - try { - configPath = getConfigPath() - } catch { - // If this throws, we're in a serverless environment, and the `redwood.toml` file doesn't exist. - return 8911 + if (options.length === 3) { + return { + cost: parseInt(options[0]), + blockSize: parseInt(options[1]), + parallelization: parseInt(options[2]), + } + } else { + return {} } - - return getConfig(configPath).api.port } diff --git a/packages/auth-providers/dbAuth/setup/package.json b/packages/auth-providers/dbAuth/setup/package.json index 980069bb5957..d2bb2f1a7480 100644 --- a/packages/auth-providers/dbAuth/setup/package.json +++ b/packages/auth-providers/dbAuth/setup/package.json @@ -27,14 +27,12 @@ "@simplewebauthn/browser": "7.2.0", "core-js": "3.32.2", "prompts": "2.4.2", - "secure-random-password": "0.2.3", "terminal-link": "2.1.1" }, "devDependencies": { "@babel/cli": "7.23.0", "@babel/core": "^7.22.20", "@simplewebauthn/typescript-types": "7.0.0", - "@types/secure-random-password": "0.2.1", "@types/yargs": "17.0.24", "jest": "29.7.0", "typescript": "5.2.2" diff --git a/packages/auth-providers/dbAuth/setup/src/setupData.ts b/packages/auth-providers/dbAuth/setup/src/setupData.ts index 284ee1548927..82bdf2d88dab 100644 --- a/packages/auth-providers/dbAuth/setup/src/setupData.ts +++ b/packages/auth-providers/dbAuth/setup/src/setupData.ts @@ -1,7 +1,6 @@ +import crypto from 'node:crypto' import path from 'path' -import password from 'secure-random-password' - import { getPaths, colors, addEnvVarTask } from '@redwoodjs/cli-helpers' export const libPath = getPaths().api.lib.replace(getPaths().base, '') @@ -10,10 +9,7 @@ export const functionsPath = getPaths().api.functions.replace( '' ) -const secret = password.randomPassword({ - length: 64, - characters: [password.lower, password.upper, password.digits], -}) +const secret = crypto.randomBytes(32).toString('base64') export const extraTask = addEnvVarTask( 'SESSION_SECRET', diff --git a/packages/cli/package.json b/packages/cli/package.json index c7f515923536..046a41ed0082 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -45,7 +45,6 @@ "@redwoodjs/project-config": "6.0.7", "@redwoodjs/structure": "6.0.7", "@redwoodjs/telemetry": "6.0.7", - "@types/secure-random-password": "0.2.1", "boxen": "5.1.2", "camelcase": "6.3.0", "chalk": "4.1.2", @@ -54,7 +53,6 @@ "configstore": "3.1.5", "core-js": "3.32.2", "cross-env": "7.0.3", - "crypto-js": "4.1.1", "decamelize": "5.0.1", "dotenv-defaults": "5.0.2", "enquirer": "2.4.1", @@ -74,7 +72,6 @@ "prisma": "5.4.2", "prompts": "2.4.2", "rimraf": "5.0.5", - "secure-random-password": "0.2.3", "semver": "7.5.3", "string-env-interpolation": "1.0.1", "systeminformation": "5.21.7", @@ -86,7 +83,6 @@ "devDependencies": { "@babel/cli": "7.23.0", "@babel/core": "^7.22.20", - "@types/crypto-js": "4.1.1", "jest": "29.7.0", "typescript": "5.2.2" }, diff --git a/packages/cli/src/commands/generate/secret/__tests__/secret.test.js b/packages/cli/src/commands/generate/secret/__tests__/secret.test.js index be2680dbede0..417f52f9cadd 100644 --- a/packages/cli/src/commands/generate/secret/__tests__/secret.test.js +++ b/packages/cli/src/commands/generate/secret/__tests__/secret.test.js @@ -1,18 +1,27 @@ import yargs from 'yargs' -import { generateSecret, handler, builder } from './../secret.js' +import { + DEFAULT_LENGTH, + generateSecret, + handler, + builder, +} from './../secret.js' describe('generateSecret', () => { - it('contains only uppercase letters, lowercase letters, and digits', () => { + it('contains base64-encoded string', () => { const secret = generateSecret() + const buffer = Buffer.alloc(DEFAULT_LENGTH) + const stringLength = buffer.toString('base64').length - expect(secret).toMatch(/^[A-Za-z0-9]{64}$/) + expect(secret).toMatch(new RegExp(`^[A-Za-z0-9+/=]{${stringLength}}$`)) }) it('can optionally accept a length', () => { const secret = generateSecret(16) + const buffer = Buffer.alloc(16) - expect(secret.length).toEqual(16) + // however long a 16-byte buffer is when base64-encoded (24 characters) + expect(secret.length).toEqual(buffer.toString('base64').length) }) it('prints nothing but the secret when setting the --raw flag', () => { @@ -26,7 +35,7 @@ describe('generateSecret', () => { console.info = (...args) => (output += args.join(' ') + '\n') process.stdout.write = (str) => (output += str) - const { raw, length } = yargs + const { raw } = yargs .command('secret', false, builder, handler) .parse('secret --raw') @@ -35,6 +44,6 @@ describe('generateSecret', () => { process.stdout.write = realWrite expect(raw).toBeTruthy() - expect(output).toMatch(new RegExp(`^[A-Za-z0-9]{${length}}\n$`)) + expect(output).toMatch(new RegExp(`^[A-Za-z0-9+/=]+\n$`)) }) }) diff --git a/packages/cli/src/commands/generate/secret/secret.js b/packages/cli/src/commands/generate/secret/secret.js index d8cf9514e229..a012efe5e227 100644 --- a/packages/cli/src/commands/generate/secret/secret.js +++ b/packages/cli/src/commands/generate/secret/secret.js @@ -1,15 +1,13 @@ -import password from 'secure-random-password' +import crypto from 'node:crypto' + import terminalLink from 'terminal-link' import { recordTelemetryAttributes } from '@redwoodjs/cli-helpers' -const DEFAULT_LENGTH = 64 +export const DEFAULT_LENGTH = 32 export const generateSecret = (length = DEFAULT_LENGTH) => { - return password.randomPassword({ - length, - characters: [password.lower, password.upper, password.digits], - }) + return crypto.randomBytes(length).toString('base64') } export const command = 'secret' diff --git a/packages/cli/src/commands/setup/graphiql/__tests__/graphiqlHandler.test.js b/packages/cli/src/commands/setup/graphiql/__tests__/graphiqlHandler.test.js index 4354c56aadd5..49f8250fa3d7 100644 --- a/packages/cli/src/commands/setup/graphiql/__tests__/graphiqlHandler.test.js +++ b/packages/cli/src/commands/setup/graphiql/__tests__/graphiqlHandler.test.js @@ -53,18 +53,18 @@ describe('Graphiql generator tests', () => { expect(processExitSpy).toHaveBeenCalledWith(1) }) - it('throws an error if auth provider is dbAuth and no user id is provided', () => { + it('throws an error if auth provider is dbAuth and no user id is provided', async () => { try { - graphiqlHelpers.generatePayload('dbAuth') + await graphiqlHelpers.generatePayload('dbAuth') } catch (e) { expect(e.message).toBe('Require an unique id to generate session cookie') } }) - it('throws an error if auth provider is dbAuth and no supabase env is set', () => { - process.env.SESSION_SECRET = null + it('throws an error if auth provider is dbAuth and no supabase env is set', async () => { + delete process.env.SESSION_SECRET try { - graphiqlHelpers.generatePayload('dbAuth', 'user-id-123') + await graphiqlHelpers.generatePayload('dbAuth', 'user-id-123') } catch (e) { expect(e.message).toBe( 'dbAuth requires a SESSION_SECRET environment variable that is used to encrypt session cookies. Use `yarn rw g secret` to create one, then add to your `.env` file. DO NOT check this variable in your version control system!!' @@ -75,7 +75,11 @@ describe('Graphiql generator tests', () => { it('returns a payload if a token is provided', async () => { const provider = 'supabase' const token = 'mock-token' - const response = graphiqlHelpers.generatePayload(provider, null, token) + const response = await graphiqlHelpers.generatePayload( + provider, + null, + token + ) expect(response).toEqual({ 'auth-provider': provider, authorization: `Bearer ${token}`, diff --git a/packages/cli/src/commands/setup/graphiql/graphiqlHandler.js b/packages/cli/src/commands/setup/graphiql/graphiqlHandler.js index 6b1aaeca11a1..c2f0c148f71c 100644 --- a/packages/cli/src/commands/setup/graphiql/graphiqlHandler.js +++ b/packages/cli/src/commands/setup/graphiql/graphiqlHandler.js @@ -68,8 +68,8 @@ export const handler = async ({ provider, id, token, expiry, view }) => { [ { title: 'Generating graphiql header...', - task: () => { - payload = generatePayload(provider, id, token, expiry) + task: async () => { + payload = await generatePayload(provider, id, token, expiry) }, }, { diff --git a/packages/cli/src/commands/setup/graphiql/supportedProviders.js b/packages/cli/src/commands/setup/graphiql/supportedProviders.js index b02c48f42498..fdb50cafa9b5 100644 --- a/packages/cli/src/commands/setup/graphiql/supportedProviders.js +++ b/packages/cli/src/commands/setup/graphiql/supportedProviders.js @@ -1,4 +1,3 @@ -import CryptoJS from 'crypto-js' import { v4 as uuidv4 } from 'uuid' // tests if id, which is always a string from cli, is actually a number or uuid @@ -10,7 +9,7 @@ const getExpiryTime = (expiry) => { return expiry ? Date.now() + expiry * 60 * 1000 : Date.now() + 3600 * 1000 } -const getDBAuthHeader = (userId) => { +const getDBAuthHeader = async (userId) => { if (!userId) { throw new Error('Require an unique id to generate session cookie') } @@ -20,11 +19,11 @@ const getDBAuthHeader = (userId) => { 'dbAuth requires a SESSION_SECRET environment variable that is used to encrypt session cookies. Use `yarn rw g secret` to create one, then add to your `.env` file. DO NOT check this variable in your version control system!!' ) } + + const { encryptSession } = await import('@redwoodjs/auth-dbauth-api') + const id = isNumeric(userId) ? parseInt(userId) : userId - const cookie = CryptoJS.AES.encrypt( - JSON.stringify({ id }) + ';' + uuidv4(), - process.env.SESSION_SECRET - ).toString() + const cookie = encryptSession(JSON.stringify({ id }) + ';' + uuidv4()) return { 'auth-provider': 'dbAuth', diff --git a/packages/studio/api/lib/authProviderEncoders/dbAuthEncoder.ts b/packages/studio/api/lib/authProviderEncoders/dbAuthEncoder.ts index bbd89198ed3e..771fefca29a1 100644 --- a/packages/studio/api/lib/authProviderEncoders/dbAuthEncoder.ts +++ b/packages/studio/api/lib/authProviderEncoders/dbAuthEncoder.ts @@ -1,4 +1,3 @@ -import CryptoJS from 'crypto-js' import { v4 as uuidv4 } from 'uuid' import { SESSION_SECRET } from '../envars' @@ -18,11 +17,10 @@ export const getDBAuthHeader = async (userId?: string) => { ) } + const { encryptSession } = await import('@redwoodjs/auth-dbauth-api') + const id = isNumeric(userId) ? parseInt(userId) : userId - const cookie = CryptoJS.AES.encrypt( - JSON.stringify({ id }) + ';' + uuidv4(), - SESSION_SECRET - ).toString() + const cookie = encryptSession(JSON.stringify({ id }) + ';' + uuidv4()) return { authProvider: 'dbAuth', diff --git a/packages/studio/package.json b/packages/studio/package.json index a76ac633deef..676cc9f9798b 100644 --- a/packages/studio/package.json +++ b/packages/studio/package.json @@ -32,7 +32,6 @@ "ansi-colors": "4.1.3", "chokidar": "3.5.3", "core-js": "3.32.2", - "crypto-js": "4.1.1", "dotenv": "16.3.1", "fast-json-parse": "1.0.3", "fastify": "4.23.2", @@ -68,7 +67,6 @@ "@tailwindcss/forms": "0.5.3", "@tremor/react": "3.4.1", "@types/aws-lambda": "8.10.119", - "@types/crypto-js": "4.1.1", "@types/jsonwebtoken": "9.0.2", "@types/lodash": "4.14.195", "@types/mailparser": "^3", diff --git a/tasks/smoke-tests/auth/tests/authChecks.spec.ts b/tasks/smoke-tests/auth/tests/authChecks.spec.ts index b20171bbe277..86b4c711eb88 100644 --- a/tasks/smoke-tests/auth/tests/authChecks.spec.ts +++ b/tasks/smoke-tests/auth/tests/authChecks.spec.ts @@ -2,12 +2,16 @@ import { test, expect } from '@playwright/test' import { loginAsTestUser, signUpTestUser } from '../../shared/common' -// Signs up a user before these tests +const testUser = { + email: 'testuser@bazinga.com', + password: 'test123', + fullName: 'Test User', +} test.beforeAll(async ({ browser }) => { const page = await browser.newPage() - await signUpTestUser({ page }) + await signUpTestUser({ page, ...testUser }) await page.close() }) @@ -20,7 +24,7 @@ test('useAuth hook, auth redirects checks', async ({ page }) => { `http://localhost:8910/login?redirectTo=/profile` ) - await loginAsTestUser({ page }) + await loginAsTestUser({ page, ...testUser }) await page.goto('/profile') @@ -41,14 +45,19 @@ test('useAuth hook, auth redirects checks', async ({ page }) => { 'Is Adminfalse' ) - // Log Out await page.goto('/') - await page.click('text=Log Out') - await expect(await page.locator('text=Login')).toBeTruthy() + await page.getByText('Log Out').click() + await expect(page.getByText('Log In')).toBeVisible() }) +const post = { + title: 'Hello world! Soft kittens are the best.', + body: 'Bazinga, bazinga, bazinga', + authorId: '2', +} + test('requireAuth graphql checks', async ({ page }) => { - // Create posts + // Try to create a post as an anonymous user. await createNewPost({ page }) await expect( @@ -59,48 +68,36 @@ test('requireAuth graphql checks', async ({ page }) => { await page.goto('/') - await expect( - await page - .locator('article:has-text("Hello world! Soft kittens are the best.")') - .count() - ).toBe(0) + await expect(page.getByText(post.title)).not.toBeVisible() - await loginAsTestUser({ - page, - }) + // Now log in and try again. + await loginAsTestUser({ page, ...testUser }) await createNewPost({ page }) await page.goto('/') - await expect( - await page - .locator('article:has-text("Hello world! Soft kittens are the best.")') - .first() - ).not.toBeEmpty() + + await expect(page.getByText(post.title)).toBeVisible() + + // Delete the post to keep this test idempotent. + // Clicking "Delete" opens a confirmation dialog that we havee to accept. + await page.goto('/posts') + + page.once('dialog', (dialog) => dialog.accept()) + + await page + .getByRole('row') + .filter({ has: page.getByText(post.title) }) + .getByRole('button', { name: 'Delete' }) + .click() }) async function createNewPost({ page }) { await page.goto('/posts/new') - await page.locator('input[name="title"]').click() - await page - .locator('input[name="title"]') - .fill('Hello world! Soft kittens are the best.') - await page.locator('input[name="title"]').press('Tab') - await page.locator('input[name="body"]').fill('Bazinga, bazinga, bazinga') - await page.locator('input[name="authorId"]').fill('2') - - const permissionError = page - .locator('.rw-form-error-title') - .locator(`text=You don't have permission to do that`) - - // Either wait for success and redirect - // Or get the error - await Promise.all([ - Promise.race([ - page.waitForURL('**/'), - permissionError.waitFor({ timeout: 5000 }), - ]), - await page.click('text=SAVE'), - ]) + await page.getByLabel('Title').fill(post.title) + await page.getByLabel('Body').fill(post.body) + await page.getByLabel('Author id').fill(post.authorId) + + await page.getByRole('button', { name: 'Save' }).click() } diff --git a/tasks/smoke-tests/shared/common.ts b/tasks/smoke-tests/shared/common.ts index d2ab977dddba..829f68854f26 100644 --- a/tasks/smoke-tests/shared/common.ts +++ b/tasks/smoke-tests/shared/common.ts @@ -59,31 +59,21 @@ export const signUpTestUser = async ({ }: AuthUtilsParams) => { await page.goto('/signup') - await page.locator('input[name="username"]').click() - // Fill input[name="username"] - await page.locator('input[name="username"]').fill(email) - // Press Tab - await page.locator('input[name="username"]').press('Tab') - // Fill input[name="password"] - await page.locator('input[name="password"]').fill(password) - await page.locator('input[name="full-name"]').click() - await page.locator('input[name="full-name"]').fill(fullName) - - const alreadyRegisteredErr = page.locator( - `text=Username \`${email}\` already in use` - ) - - // Either wait for signup to succeed and redirect - // Or get the username already registered error, either way is fine! - await Promise.all([ - Promise.race([ - page.waitForURL('**/'), - alreadyRegisteredErr.waitFor({ timeout: 5000 }), - ]), - page.locator('text=Sign Up').click(), + await page.getByLabel('Username').fill(email) + await page.getByLabel('Password').fill(password) + await page.getByLabel('Full Name').fill(fullName) + + await page.getByRole('button', { name: 'Sign Up' }).click() + + // Wait for either... + // - signup to succeed and redirect to the home page + // - an error message to appear in a toast + await Promise.race([ + page.waitForURL('/'), + expect( + page.getByText(`Username \`${email}\` already in use`) + ).toBeVisible(), ]) - - console.log(`Signup successful for ${email}!`) } export const loginAsTestUser = async ({ @@ -93,18 +83,10 @@ export const loginAsTestUser = async ({ }: AuthUtilsParams) => { await page.goto('/login') - // Click input[name="username"] - await page.locator('input[name="username"]').click() - // Fill input[name="username"] - await page.locator('input[name="username"]').fill(email) - // Click input[name="password"] - await page.locator('input[name="password"]').click() - // Fill input[name="password"] - await page.locator('input[name="password"]').fill(password) - - // Click button:has-text("Login") - await Promise.all([ - page.waitForURL('**/'), - page.locator('button:has-text("Login")').click(), - ]) + await page.getByLabel('Username').fill(email) + await page.getByLabel('Password').fill(password) + + await page.getByRole('button', { name: 'Login' }).click() + + await page.waitForURL('/') } diff --git a/yarn.lock b/yarn.lock index 57418c80df2e..fca460bb418c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8072,12 +8072,10 @@ __metadata: "@redwoodjs/api": 6.0.7 "@redwoodjs/project-config": 6.0.7 "@simplewebauthn/server": 7.3.1 - "@types/crypto-js": 4.1.1 "@types/md5": 2.3.2 "@types/uuid": 9.0.2 base64url: 3.0.1 core-js: 3.32.2 - crypto-js: 4.1.1 jest: 29.7.0 md5: 2.3.0 typescript: 5.2.2 @@ -8095,12 +8093,10 @@ __metadata: "@redwoodjs/cli-helpers": 6.0.7 "@simplewebauthn/browser": 7.2.0 "@simplewebauthn/typescript-types": 7.0.0 - "@types/secure-random-password": 0.2.1 "@types/yargs": 17.0.24 core-js: 3.32.2 jest: 29.7.0 prompts: 2.4.2 - secure-random-password: 0.2.3 terminal-link: 2.1.1 typescript: 5.2.2 languageName: unknown @@ -8480,8 +8476,6 @@ __metadata: "@redwoodjs/project-config": 6.0.7 "@redwoodjs/structure": 6.0.7 "@redwoodjs/telemetry": 6.0.7 - "@types/crypto-js": 4.1.1 - "@types/secure-random-password": 0.2.1 boxen: 5.1.2 camelcase: 6.3.0 chalk: 4.1.2 @@ -8490,7 +8484,6 @@ __metadata: configstore: 3.1.5 core-js: 3.32.2 cross-env: 7.0.3 - crypto-js: 4.1.1 decamelize: 5.0.1 dotenv-defaults: 5.0.2 enquirer: 2.4.1 @@ -8511,7 +8504,6 @@ __metadata: prisma: 5.4.2 prompts: 2.4.2 rimraf: 5.0.5 - secure-random-password: 0.2.3 semver: 7.5.3 string-env-interpolation: 1.0.1 systeminformation: 5.21.7 @@ -9082,7 +9074,6 @@ __metadata: "@tailwindcss/forms": 0.5.3 "@tremor/react": 3.4.1 "@types/aws-lambda": 8.10.119 - "@types/crypto-js": 4.1.1 "@types/jsonwebtoken": 9.0.2 "@types/lodash": 4.14.195 "@types/mailparser": ^3 @@ -9101,7 +9092,6 @@ __metadata: buffer: 6.0.3 chokidar: 3.5.3 core-js: 3.32.2 - crypto-js: 4.1.1 dotenv: 16.3.1 fast-json-parse: 1.0.3 fastify: 4.23.2 @@ -11330,13 +11320,6 @@ __metadata: languageName: node linkType: hard -"@types/crypto-js@npm:4.1.1": - version: 4.1.1 - resolution: "@types/crypto-js@npm:4.1.1" - checksum: e53b712c5d3b72d19c67a06c8bbaaafd989d78f71a2168f1376c8fb84d5744e5166b58d79528a124645e13f13fe4d2c97ee8f03d649ef913e93ca6b8cee41370 - languageName: node - linkType: hard - "@types/d3-array@npm:^3.0.3": version: 3.0.5 resolution: "@types/d3-array@npm:3.0.5" @@ -12189,13 +12172,6 @@ __metadata: languageName: node linkType: hard -"@types/secure-random-password@npm:0.2.1": - version: 0.2.1 - resolution: "@types/secure-random-password@npm:0.2.1" - checksum: 87f0528b7ccb907706b0cc77160c6771279508de3852213f2c4f28a83af482b016cf3e0b414f62d2e8b3713f1733ad787e3bd40c201463ac822c117856a7886a - languageName: node - linkType: hard - "@types/semver@npm:^7.3.12, @types/semver@npm:^7.3.4": version: 7.5.0 resolution: "@types/semver@npm:7.5.0" @@ -16835,13 +16811,6 @@ __metadata: languageName: node linkType: hard -"crypto-js@npm:4.1.1": - version: 4.1.1 - resolution: "crypto-js@npm:4.1.1" - checksum: 50cc66a35f2738171d9a6d80c85ba7d00cb6440b756db035ba9ccd03032c0a803029a62969ecd4c844106c980af87687c64b204dd967989379c4f354fb482d37 - languageName: node - linkType: hard - "crypto-random-string@npm:^1.0.0": version: 1.0.0 resolution: "crypto-random-string@npm:1.0.0" @@ -31712,22 +31681,6 @@ __metadata: languageName: node linkType: hard -"secure-random-password@npm:0.2.3": - version: 0.2.3 - resolution: "secure-random-password@npm:0.2.3" - dependencies: - secure-random: ^1.1.2 - checksum: ced04529b96724b921b82b7527e1ba2f1299c3f327f941076e9edaea66445118b67a6b7f6edfe3b705a240e595de49144b2b796116c994cb52eb52efdd3d51b5 - languageName: node - linkType: hard - -"secure-random@npm:^1.1.2": - version: 1.1.2 - resolution: "secure-random@npm:1.1.2" - checksum: 612934cd5b1ea217d5e248a16ff2752411474997ede1f460ff37fe3214eedfd66ef6a5936ff76b3a5df3d057a8d2d4ed48298f5500bf837beb911522caac7f5c - languageName: node - linkType: hard - "selderee@npm:^0.10.0": version: 0.10.0 resolution: "selderee@npm:0.10.0"