diff --git a/packages/auth/src/db/constants.js b/packages/auth/src/db/constants.js index 477968975a..ecdaae5bad 100644 --- a/packages/auth/src/db/constants.js +++ b/packages/auth/src/db/constants.js @@ -13,6 +13,7 @@ exports.DocumentTypes = { APP_DEV: `${PRE_APP}${exports.SEPARATOR}${PRE_DEV}`, APP_METADATA: `${PRE_APP}${exports.SEPARATOR}metadata`, ROLE: "role", + MIGRATIONS: "migrations", } exports.StaticDatabases = { diff --git a/packages/auth/src/db/views.js b/packages/auth/src/db/views.js index 1b48786e24..fd004ca0c2 100644 --- a/packages/auth/src/db/views.js +++ b/packages/auth/src/db/views.js @@ -21,7 +21,7 @@ exports.createUserEmailView = async db => { // if using variables in a map function need to inject them before use map: `function(doc) { if (doc._id.startsWith("${DocumentTypes.USER}")) { - emit(doc.email, doc._id) + emit(doc.email.toLowerCase(), doc._id) } }`, } diff --git a/packages/auth/src/middleware/passport/tests/third-party-common.spec.js b/packages/auth/src/middleware/passport/tests/third-party-common.spec.js index e2ad9a9300..3a3c55bfa0 100644 --- a/packages/auth/src/middleware/passport/tests/third-party-common.spec.js +++ b/packages/auth/src/middleware/passport/tests/third-party-common.spec.js @@ -1,6 +1,6 @@ // Mock data -require("./utilities/test-config") +require("../../../tests/utilities/dbConfig") const database = require("../../../db") const { authenticateThirdParty } = require("../third-party-common") @@ -72,7 +72,6 @@ describe("third party common", () => { const expectUserIsSynced = (user, thirdPartyUser) => { expect(user.provider).toBe(thirdPartyUser.provider) - expect(user.email).toBe(thirdPartyUser.email) expect(user.firstName).toBe(thirdPartyUser.profile.name.givenName) expect(user.lastName).toBe(thirdPartyUser.profile.name.familyName) expect(user.thirdPartyProfile).toStrictEqual(thirdPartyUser.profile._json) @@ -135,6 +134,24 @@ describe("third party common", () => { }) }) + describe("exists by email with different casing", () => { + beforeEach(async () => { + id = generateGlobalUserID(newid()) // random id + email = thirdPartyUser.email.toUpperCase() // matching email except for casing + await createUser() + }) + + it("syncs and authenticates the user", async () => { + await authenticateThirdParty(thirdPartyUser, true, done, saveUser) + + const user = expectUserIsAuthenticated() + expectUserIsSynced(user, thirdPartyUser) + expectUserIsUpdated(user) + expect(user.email).toBe(thirdPartyUser.email.toUpperCase()) + }) + }) + + describe("exists by id", () => { beforeEach(async () => { id = generateGlobalUserID(thirdPartyUser.userId) // matching id diff --git a/packages/auth/src/middleware/passport/third-party-common.js b/packages/auth/src/middleware/passport/third-party-common.js index 54a5504712..b467c0b10b 100644 --- a/packages/auth/src/middleware/passport/third-party-common.js +++ b/packages/auth/src/middleware/passport/third-party-common.js @@ -66,12 +66,16 @@ exports.authenticateThirdParty = async function ( // setup a blank user using the third party id dbUser = { _id: userId, + email: thirdPartyUser.email, roles: {}, } } dbUser = await syncUser(dbUser, thirdPartyUser) + // never prompt for password reset + dbUser.forceResetPassword = false + // create or sync the user let response try { @@ -122,9 +126,6 @@ async function syncUser(user, thirdPartyUser) { user.provider = thirdPartyUser.provider user.providerType = thirdPartyUser.providerType - // email - user.email = thirdPartyUser.email - if (thirdPartyUser.profile) { const profile = thirdPartyUser.profile diff --git a/packages/auth/src/migrations/index.js b/packages/auth/src/migrations/index.js new file mode 100644 index 0000000000..7492e94511 --- /dev/null +++ b/packages/auth/src/migrations/index.js @@ -0,0 +1,61 @@ +const { DocumentTypes } = require("../db/constants") +const { getGlobalDB } = require("../tenancy") + +exports.MIGRATION_DBS = { + GLOBAL_DB: "GLOBAL_DB", +} + +exports.MIGRATIONS = { + USER_EMAIL_VIEW_CASING: "user_email_view_casing", +} + +const DB_LOOKUP = { + [exports.MIGRATION_DBS.GLOBAL_DB]: [ + exports.MIGRATIONS.USER_EMAIL_VIEW_CASING, + ], +} + +exports.getMigrationsDoc = async db => { + // get the migrations doc + try { + return await db.get(DocumentTypes.MIGRATIONS) + } catch (err) { + if (err.status && err.status === 404) { + return { _id: DocumentTypes.MIGRATIONS } + } + } +} + +exports.migrateIfRequired = async (migrationDb, migrationName, migrateFn) => { + try { + let db + if (migrationDb === exports.MIGRATION_DBS.GLOBAL_DB) { + db = getGlobalDB() + } else { + throw new Error(`Unrecognised migration db [${migrationDb}]`) + } + + if (!DB_LOOKUP[migrationDb].includes(migrationName)) { + throw new Error( + `Unrecognised migration name [${migrationName}] for db [${migrationDb}]` + ) + } + + const doc = await exports.getMigrationsDoc(db) + // exit if the migration has been performed + if (doc[migrationName]) { + return + } + + console.log(`Performing migration: ${migrationName}`) + await migrateFn() + console.log(`Migration complete: ${migrationName}`) + + // mark as complete + doc[migrationName] = Date.now() + await db.put(doc) + } catch (err) { + console.error(`Error performing migration: ${migrationName}: `, err) + throw err + } +} diff --git a/packages/auth/src/migrations/tests/__snapshots__/index.spec.js.snap b/packages/auth/src/migrations/tests/__snapshots__/index.spec.js.snap new file mode 100644 index 0000000000..e9a18eadde --- /dev/null +++ b/packages/auth/src/migrations/tests/__snapshots__/index.spec.js.snap @@ -0,0 +1,9 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`migrations should match snapshot 1`] = ` +Object { + "_id": "migrations", + "_rev": "1-af6c272fe081efafecd2ea49a8fcbb40", + "user_email_view_casing": 1487076708000, +} +`; diff --git a/packages/auth/src/migrations/tests/index.spec.js b/packages/auth/src/migrations/tests/index.spec.js new file mode 100644 index 0000000000..0ed16fc184 --- /dev/null +++ b/packages/auth/src/migrations/tests/index.spec.js @@ -0,0 +1,60 @@ +require("../../tests/utilities/dbConfig") + +const { migrateIfRequired, MIGRATION_DBS, MIGRATIONS, getMigrationsDoc } = require("../index") +const database = require("../../db") +const { + StaticDatabases, +} = require("../../db/utils") + +Date.now = jest.fn(() => 1487076708000) +let db + +describe("migrations", () => { + + const migrationFunction = jest.fn() + + beforeEach(() => { + db = database.getDB(StaticDatabases.GLOBAL.name) + }) + + afterEach(async () => { + jest.clearAllMocks() + await db.destroy() + }) + + const validMigration = () => { + return migrateIfRequired(MIGRATION_DBS.GLOBAL_DB, MIGRATIONS.USER_EMAIL_VIEW_CASING, migrationFunction) + } + + it("should run a new migration", async () => { + await validMigration() + expect(migrationFunction).toHaveBeenCalled() + }) + + it("should match snapshot", async () => { + await validMigration() + const doc = await getMigrationsDoc(db) + expect(doc).toMatchSnapshot() + }) + + it("should skip a previously run migration", async () => { + await validMigration() + await validMigration() + expect(migrationFunction).toHaveBeenCalledTimes(1) + }) + + it("should reject an unknown migration name", async () => { + expect(async () => { + await migrateIfRequired(MIGRATION_DBS.GLOBAL_DB, "bogus_name", migrationFunction) + }).rejects.toThrow() + expect(migrationFunction).not.toHaveBeenCalled() + }) + + it("should reject an unknown database name", async () => { + expect(async () => { + await migrateIfRequired("bogus_db", MIGRATIONS.USER_EMAIL_VIEW_CASING, migrationFunction) + }).rejects.toThrow() + expect(migrationFunction).not.toHaveBeenCalled() + }) + +}) \ No newline at end of file diff --git a/packages/auth/src/middleware/passport/tests/utilities/db.js b/packages/auth/src/tests/utilities/db.js similarity index 87% rename from packages/auth/src/middleware/passport/tests/utilities/db.js rename to packages/auth/src/tests/utilities/db.js index e83784471b..bb99592d1c 100644 --- a/packages/auth/src/middleware/passport/tests/utilities/db.js +++ b/packages/auth/src/tests/utilities/db.js @@ -1,5 +1,5 @@ const PouchDB = require("pouchdb") -const env = require("../../../../environment") +const env = require("../../environment") let POUCH_DB_DEFAULTS diff --git a/packages/auth/src/middleware/passport/tests/utilities/test-config.js b/packages/auth/src/tests/utilities/dbConfig.js similarity index 53% rename from packages/auth/src/middleware/passport/tests/utilities/test-config.js rename to packages/auth/src/tests/utilities/dbConfig.js index 57768d4071..45b9ff33f9 100644 --- a/packages/auth/src/middleware/passport/tests/utilities/test-config.js +++ b/packages/auth/src/tests/utilities/dbConfig.js @@ -1,3 +1,3 @@ -const packageConfiguration = require("../../../../index") +const packageConfiguration = require("../../index") const CouchDB = require("./db") packageConfiguration.init(CouchDB) diff --git a/packages/auth/src/utils.js b/packages/auth/src/utils.js index 823fd06322..e1df289d6e 100644 --- a/packages/auth/src/utils.js +++ b/packages/auth/src/utils.js @@ -20,6 +20,9 @@ const { hash } = require("./hashing") const userCache = require("./cache/user") const env = require("./environment") const { getUserSessions, invalidateSessions } = require("./security/sessions") +const { migrateIfRequired } = require("./migrations") +const { USER_EMAIL_VIEW_CASING } = require("./migrations").MIGRATIONS +const { GLOBAL_DB } = require("./migrations").MIGRATION_DBS const APP_PREFIX = DocumentTypes.APP + SEPARATOR @@ -128,10 +131,16 @@ exports.getGlobalUserByEmail = async email => { throw "Must supply an email address to view" } const db = getGlobalDB() + + await migrateIfRequired(GLOBAL_DB, USER_EMAIL_VIEW_CASING, async () => { + // re-create the view with latest changes + await createUserEmailView(db) + }) + try { let users = ( await db.query(`database/${ViewNames.USER_BY_EMAIL}`, { - key: email, + key: email.toLowerCase(), include_docs: true, }) ).rows