From 6a78df1ecda30e3eaa4effb8769b84ca5fb182eb Mon Sep 17 00:00:00 2001 From: Sebastian Rindom Date: Thu, 7 May 2020 13:31:38 +0200 Subject: [PATCH] Adds endpoints to manage customers (#54) --- .../admin/users/reset-password-token.js | 4 - .../api/routes/store/auth/create-session.js | 37 +++ .../customers/__tests__/create-customer.js | 58 +++++ .../__tests__/reset-password-token.js | 41 ++++ .../customers/__tests__/reset-password.js | 59 +++++ .../customers/__tests__/update-customer.js | 75 ++++++ .../customers/__tests__/update-password.js | 73 ++++++ .../store/customers/authorize-customer.js | 12 + .../routes/store/customers/create-customer.js | 26 +++ .../src/api/routes/store/customers/index.js | 31 +++ .../store/customers/reset-password-token.js | 25 ++ .../routes/store/customers/reset-password.js | 35 +++ .../routes/store/customers/update-customer.js | 27 +++ .../routes/store/customers/update-password.js | 26 +++ packages/medusa/src/api/routes/store/index.js | 3 + packages/medusa/src/helpers/test-request.js | 10 + .../medusa/src/models/__mocks__/customer.js | 3 + packages/medusa/src/models/customer.js | 3 + .../medusa/src/services/__mocks__/customer.js | 51 ++++ .../medusa/src/services/__tests__/auth.js | 30 +++ .../medusa/src/services/__tests__/customer.js | 217 +++++++++--------- packages/medusa/src/services/auth.js | 50 +++- packages/medusa/src/services/customer.js | 130 +++++++---- packages/medusa/src/services/user.js | 2 +- 24 files changed, 858 insertions(+), 170 deletions(-) create mode 100644 packages/medusa/src/api/routes/store/auth/create-session.js create mode 100644 packages/medusa/src/api/routes/store/customers/__tests__/create-customer.js create mode 100644 packages/medusa/src/api/routes/store/customers/__tests__/reset-password-token.js create mode 100644 packages/medusa/src/api/routes/store/customers/__tests__/reset-password.js create mode 100644 packages/medusa/src/api/routes/store/customers/__tests__/update-customer.js create mode 100644 packages/medusa/src/api/routes/store/customers/__tests__/update-password.js create mode 100644 packages/medusa/src/api/routes/store/customers/authorize-customer.js create mode 100644 packages/medusa/src/api/routes/store/customers/create-customer.js create mode 100644 packages/medusa/src/api/routes/store/customers/index.js create mode 100644 packages/medusa/src/api/routes/store/customers/reset-password-token.js create mode 100644 packages/medusa/src/api/routes/store/customers/reset-password.js create mode 100644 packages/medusa/src/api/routes/store/customers/update-customer.js create mode 100644 packages/medusa/src/api/routes/store/customers/update-password.js create mode 100644 packages/medusa/src/services/__mocks__/customer.js diff --git a/packages/medusa/src/api/routes/admin/users/reset-password-token.js b/packages/medusa/src/api/routes/admin/users/reset-password-token.js index d477503837..b1537cedc6 100644 --- a/packages/medusa/src/api/routes/admin/users/reset-password-token.js +++ b/packages/medusa/src/api/routes/admin/users/reset-password-token.js @@ -3,10 +3,6 @@ export default async (req, res) => { try { const userService = req.scope.resolve("userService") const token = await userService.generateResetPasswordToken(user_id) - if (!token) { - res.sendStatus(404) - return - } res.json(token) } catch (error) { throw error diff --git a/packages/medusa/src/api/routes/store/auth/create-session.js b/packages/medusa/src/api/routes/store/auth/create-session.js new file mode 100644 index 0000000000..0ca21f9b07 --- /dev/null +++ b/packages/medusa/src/api/routes/store/auth/create-session.js @@ -0,0 +1,37 @@ +import jwt from "jsonwebtoken" +import { Validator } from "medusa-core-utils" +import config from "../../../../config" + +export default async (req, res) => { + const { body } = req + const schema = Validator.object().keys({ + email: Validator.string().required(), + password: Validator.string().required(), + }) + const { value, error } = schema.validate(body) + + if (error) { + throw error + } + + const authService = req.scope.resolve("authService") + const result = await authService.authenticateCustomer( + value.email, + value.password + ) + if (!result.success) { + res.sendStatus(401) + return + } + + // Add JWT to cookie + req.session.jwt = jwt.sign( + { customer_id: result.user._id }, + config.jwtSecret, + { + expiresIn: "30d", + } + ) + + res.json(result.customer) +} diff --git a/packages/medusa/src/api/routes/store/customers/__tests__/create-customer.js b/packages/medusa/src/api/routes/store/customers/__tests__/create-customer.js new file mode 100644 index 0000000000..e5896a6c99 --- /dev/null +++ b/packages/medusa/src/api/routes/store/customers/__tests__/create-customer.js @@ -0,0 +1,58 @@ +import { request } from "../../../../../helpers/test-request" +import { CustomerServiceMock } from "../../../../../services/__mocks__/customer" + +describe("POST /store/customers", () => { + describe("successfully creates a customer", () => { + let subject + beforeAll(async () => { + subject = await request("POST", `/store/customers`, { + payload: { + email: "lebron@james.com", + first_name: "LeBron", + last_name: "James", + password: "TheGame", + }, + }) + }) + + afterAll(() => { + jest.clearAllMocks() + }) + + it("calls CustomerService create", () => { + expect(CustomerServiceMock.create).toHaveBeenCalledTimes(1) + expect(CustomerServiceMock.create).toHaveBeenCalledWith({ + email: "lebron@james.com", + first_name: "LeBron", + last_name: "James", + password: "TheGame", + }) + }) + + it("returns customer decorated", () => { + expect(subject.body.email).toEqual("lebron@james.com") + expect(subject.body.decorated).toEqual(true) + }) + }) + + describe("fails if missing field", () => { + let subject + beforeAll(async () => { + subject = await request("POST", `/store/customers`, { + payload: { + first_name: "LeBron", + last_name: "James", + password: "TheGame", + }, + }) + }) + + afterAll(() => { + jest.clearAllMocks() + }) + + it("returns product decorated", () => { + expect(subject.body.name).toEqual("invalid_data") + }) + }) +}) diff --git a/packages/medusa/src/api/routes/store/customers/__tests__/reset-password-token.js b/packages/medusa/src/api/routes/store/customers/__tests__/reset-password-token.js new file mode 100644 index 0000000000..b67d6d19b0 --- /dev/null +++ b/packages/medusa/src/api/routes/store/customers/__tests__/reset-password-token.js @@ -0,0 +1,41 @@ +import { IdMap } from "medusa-test-utils" +import jwt from "jsonwebtoken" +import { request } from "../../../../../helpers/test-request" +import { CustomerServiceMock } from "../../../../../services/__mocks__/customer" + +describe("POST /store/customers/password-token", () => { + describe("successfully creates a customer", () => { + let subject + beforeAll(async () => { + subject = await request("POST", `/store/customers/password-token`, { + payload: { + email: "lebron@james.com", + }, + }) + }) + + afterAll(() => { + jest.clearAllMocks() + }) + + it("calls CustomerService retrieve", () => { + expect(CustomerServiceMock.retrieveByEmail).toHaveBeenCalledTimes(1) + expect(CustomerServiceMock.retrieveByEmail).toHaveBeenCalledWith( + "lebron@james.com" + ) + }) + + it("calls CustomerService retrieve", () => { + expect( + CustomerServiceMock.generateResetPasswordToken + ).toHaveBeenCalledTimes(1) + expect( + CustomerServiceMock.generateResetPasswordToken + ).toHaveBeenCalledWith(IdMap.getId("lebron")) + }) + + it("returns customer decorated", () => { + expect(subject.status).toEqual(204) + }) + }) +}) diff --git a/packages/medusa/src/api/routes/store/customers/__tests__/reset-password.js b/packages/medusa/src/api/routes/store/customers/__tests__/reset-password.js new file mode 100644 index 0000000000..4761738174 --- /dev/null +++ b/packages/medusa/src/api/routes/store/customers/__tests__/reset-password.js @@ -0,0 +1,59 @@ +import { IdMap } from "medusa-test-utils" +import jwt from "jsonwebtoken" +import { request } from "../../../../../helpers/test-request" +import { CustomerServiceMock } from "../../../../../services/__mocks__/customer" + +describe("POST /store/customers/password-reset", () => { + describe("successfully creates a customer", () => { + let subject + beforeAll(async () => { + subject = await request("POST", `/store/customers/password-reset`, { + payload: { + email: "lebron@james.com", + token: jwt.sign({ customer_id: IdMap.getId("lebron") }, "1234"), + password: "TheGame", + }, + }) + }) + + afterAll(() => { + jest.clearAllMocks() + }) + + it("calls CustomerService create", () => { + expect(CustomerServiceMock.update).toHaveBeenCalledTimes(1) + expect(CustomerServiceMock.update).toHaveBeenCalledWith( + IdMap.getId("lebron"), + { + password: "TheGame", + } + ) + }) + + it("returns customer decorated", () => { + expect(subject.body.email).toEqual("lebron@james.com") + expect(subject.body.decorated).toEqual(true) + }) + }) + + describe("fails if id in webtoken not matching", () => { + let subject + beforeAll(async () => { + subject = await request("POST", `/store/customers/password-reset`, { + payload: { + email: "lebron@james.com", + token: jwt.sign({ customer_id: IdMap.getId("not-lebron") }, "1234"), + password: "TheGame", + }, + }) + }) + + afterAll(() => { + jest.clearAllMocks() + }) + + it("fails", () => { + expect(subject.status).toEqual(401) + }) + }) +}) diff --git a/packages/medusa/src/api/routes/store/customers/__tests__/update-customer.js b/packages/medusa/src/api/routes/store/customers/__tests__/update-customer.js new file mode 100644 index 0000000000..c9c1401d1e --- /dev/null +++ b/packages/medusa/src/api/routes/store/customers/__tests__/update-customer.js @@ -0,0 +1,75 @@ +import { IdMap } from "medusa-test-utils" +import { request } from "../../../../../helpers/test-request" +import { CustomerServiceMock } from "../../../../../services/__mocks__/customer" + +describe("POST /store/customers/:id", () => { + describe("successfully updates a customer", () => { + let subject + beforeAll(async () => { + subject = await request( + "POST", + `/store/customers/${IdMap.getId("lebron")}`, + { + payload: { + first_name: "LeBron", + last_name: "James", + }, + clientSession: { + jwt: { + customer_id: IdMap.getId("lebron"), + }, + }, + } + ) + }) + + afterAll(() => { + jest.clearAllMocks() + }) + + it("calls CustomerService create", () => { + expect(CustomerServiceMock.update).toHaveBeenCalledTimes(1) + expect(CustomerServiceMock.update).toHaveBeenCalledWith( + IdMap.getId("lebron"), + { + first_name: "LeBron", + last_name: "James", + } + ) + }) + + it("returns product decorated", () => { + expect(subject.body.first_name).toEqual("LeBron") + expect(subject.body.decorated).toEqual(true) + }) + }) + + describe("fails if not authenticated", () => { + let subject + beforeAll(async () => { + subject = await request( + "POST", + `/store/customers/${IdMap.getId("customer1")}`, + { + payload: { + first_name: "LeBron", + last_name: "James", + }, + clientSession: { + jwt: { + customer_id: IdMap.getId("lebron"), + }, + }, + } + ) + }) + + afterAll(() => { + jest.clearAllMocks() + }) + + it("status code 400", () => { + expect(subject.status).toEqual(400) + }) + }) +}) diff --git a/packages/medusa/src/api/routes/store/customers/__tests__/update-password.js b/packages/medusa/src/api/routes/store/customers/__tests__/update-password.js new file mode 100644 index 0000000000..860a4cf061 --- /dev/null +++ b/packages/medusa/src/api/routes/store/customers/__tests__/update-password.js @@ -0,0 +1,73 @@ +import { IdMap } from "medusa-test-utils" +import { request } from "../../../../../helpers/test-request" +import { CustomerServiceMock } from "../../../../../services/__mocks__/customer" + +describe("POST /store/customers/:id/password", () => { + describe("successfully updates a customer", () => { + let subject + beforeAll(async () => { + subject = await request( + "POST", + `/store/customers/${IdMap.getId("lebron")}/password`, + { + payload: { + password: "NewPass", + }, + clientSession: { + jwt: { + customer_id: IdMap.getId("lebron"), + }, + }, + } + ) + }) + + afterAll(() => { + jest.clearAllMocks() + }) + + it("calls CustomerService update", () => { + expect(CustomerServiceMock.update).toHaveBeenCalledTimes(1) + expect(CustomerServiceMock.update).toHaveBeenCalledWith( + IdMap.getId("lebron"), + { + password: "NewPass", + } + ) + }) + + it("returns product decorated", () => { + expect(subject.body.first_name).toEqual("LeBron") + expect(subject.body.decorated).toEqual(true) + }) + }) + + describe("fails if not authenticated", () => { + let subject + beforeAll(async () => { + subject = await request( + "POST", + `/store/customers/${IdMap.getId("customer1")}/password`, + { + payload: { + first_name: "LeBron", + last_name: "James", + }, + clientSession: { + jwt: { + customer_id: IdMap.getId("lebron"), + }, + }, + } + ) + }) + + afterAll(() => { + jest.clearAllMocks() + }) + + it("status code 400", () => { + expect(subject.status).toEqual(400) + }) + }) +}) diff --git a/packages/medusa/src/api/routes/store/customers/authorize-customer.js b/packages/medusa/src/api/routes/store/customers/authorize-customer.js new file mode 100644 index 0000000000..c789c84bed --- /dev/null +++ b/packages/medusa/src/api/routes/store/customers/authorize-customer.js @@ -0,0 +1,12 @@ +import { MedusaError } from "medusa-core-utils" + +export default async (req, res, next, id) => { + if (!(req.user && req.user.customer_id === id)) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "You must be logged in to update" + ) + } else { + next() + } +} diff --git a/packages/medusa/src/api/routes/store/customers/create-customer.js b/packages/medusa/src/api/routes/store/customers/create-customer.js new file mode 100644 index 0000000000..49b883fe13 --- /dev/null +++ b/packages/medusa/src/api/routes/store/customers/create-customer.js @@ -0,0 +1,26 @@ +import { Validator, MedusaError } from "medusa-core-utils" + +export default async (req, res) => { + const schema = Validator.object().keys({ + email: Validator.string() + .email() + .required(), + first_name: Validator.string().required(), + last_name: Validator.string().required(), + password: Validator.string().required(), + }) + + const { value, error } = schema.validate(req.body) + if (error) { + throw new MedusaError(MedusaError.Types.INVALID_DATA, error.details) + } + try { + const customerService = req.scope.resolve("customerService") + const customer = await customerService.create(value) + + const data = await customerService.decorate(customer) + res.status(201).json(data) + } catch (err) { + throw err + } +} diff --git a/packages/medusa/src/api/routes/store/customers/index.js b/packages/medusa/src/api/routes/store/customers/index.js new file mode 100644 index 0000000000..4f7c8552a1 --- /dev/null +++ b/packages/medusa/src/api/routes/store/customers/index.js @@ -0,0 +1,31 @@ +import { Router } from "express" +import middlewares from "../../../middlewares" + +const route = Router() + +export default app => { + app.use("/customers", route) + + route.post("/", middlewares.wrap(require("./create-customer").default)) + route.post( + "/password-reset", + middlewares.wrap(require("./reset-password").default) + ) + + route.post( + "/password-token", + middlewares.wrap(require("./reset-password-token").default) + ) + + // Authenticated endpoints + route.use(middlewares.authenticate()) + + route.param("id", middlewares.wrap(require("./authorize-customer").default)) + + route.post("/:id", middlewares.wrap(require("./update-customer").default)) + route.post( + "/:id/password", + middlewares.wrap(require("./update-password").default) + ) + return app +} diff --git a/packages/medusa/src/api/routes/store/customers/reset-password-token.js b/packages/medusa/src/api/routes/store/customers/reset-password-token.js new file mode 100644 index 0000000000..29d052d986 --- /dev/null +++ b/packages/medusa/src/api/routes/store/customers/reset-password-token.js @@ -0,0 +1,25 @@ +import { MedusaError, Validator } from "medusa-core-utils" + +export default async (req, res) => { + const schema = Validator.object().keys({ + email: Validator.string() + .email() + .required(), + }) + + const { value, error } = schema.validate(req.body) + if (error) { + throw new MedusaError(MedusaError.Types.INVALID_DATA, error.details) + } + + try { + const customerService = req.scope.resolve("customerService") + const customer = await customerService.retrieveByEmail(value.email) + + await customerService.generateResetPasswordToken(customer._id) + + res.sendStatus(204) + } catch (error) { + throw error + } +} diff --git a/packages/medusa/src/api/routes/store/customers/reset-password.js b/packages/medusa/src/api/routes/store/customers/reset-password.js new file mode 100644 index 0000000000..b0d2e7b33e --- /dev/null +++ b/packages/medusa/src/api/routes/store/customers/reset-password.js @@ -0,0 +1,35 @@ +import { MedusaError, Validator } from "medusa-core-utils" +import jwt from "jsonwebtoken" + +export default async (req, res) => { + const schema = Validator.object().keys({ + email: Validator.string() + .email() + .required(), + token: Validator.string().required(), + password: Validator.string().required(), + }) + + const { value, error } = schema.validate(req.body) + if (error) { + throw new MedusaError(MedusaError.Types.INVALID_DATA, error.details) + } + + try { + const customerService = req.scope.resolve("customerService") + const customer = await customerService.retrieveByEmail(value.email) + + const decodedToken = await jwt.verify(value.token, customer.password_hash) + if (!decodedToken || decodedToken.customer_id !== customer._id) { + res.status(401).send("Invalid or expired password reset token") + } + + await customerService.update(customer._id, { password: value.password }) + + const updated = await customerService.retrieve(customer._id) + const data = await customerService.decorate(customer) + res.status(200).json(data) + } catch (error) { + throw error + } +} diff --git a/packages/medusa/src/api/routes/store/customers/update-customer.js b/packages/medusa/src/api/routes/store/customers/update-customer.js new file mode 100644 index 0000000000..b8bf01d90b --- /dev/null +++ b/packages/medusa/src/api/routes/store/customers/update-customer.js @@ -0,0 +1,27 @@ +import { Validator, MedusaError } from "medusa-core-utils" + +export default async (req, res) => { + const { id } = req.params + + const schema = Validator.object().keys({ + first_name: Validator.string(), + last_name: Validator.string(), + password: Validator.string(), + }) + + const { value, error } = schema.validate(req.body) + if (error) { + throw new MedusaError(MedusaError.Types.INVALID_DATA, error.details) + } + + try { + const customerService = req.scope.resolve("customerService") + await customerService.update(id, value) + + const customer = await customerService.retrieve(id) + const data = await customerService.decorate(customer) + res.status(200).json(data) + } catch (err) { + throw err + } +} diff --git a/packages/medusa/src/api/routes/store/customers/update-password.js b/packages/medusa/src/api/routes/store/customers/update-password.js new file mode 100644 index 0000000000..a452f31827 --- /dev/null +++ b/packages/medusa/src/api/routes/store/customers/update-password.js @@ -0,0 +1,26 @@ +import { Validator, MedusaError } from "medusa-core-utils" + +export default async (req, res) => { + const { id } = req.params + + const schema = Validator.object().keys({ + password: Validator.string().required(), + }) + + const { value, error } = schema.validate(req.body) + if (error) { + throw new MedusaError(MedusaError.Types.INVALID_DATA, error.details) + } + + try { + const customerService = req.scope.resolve("customerService") + + await customerService.update(id, value) + + const customer = await customerService.retrieve(id) + const data = await customerService.decorate(customer) + res.status(200).json(data) + } catch (err) { + throw err + } +} diff --git a/packages/medusa/src/api/routes/store/index.js b/packages/medusa/src/api/routes/store/index.js index 57515b0b4c..98a711a38e 100644 --- a/packages/medusa/src/api/routes/store/index.js +++ b/packages/medusa/src/api/routes/store/index.js @@ -1,6 +1,8 @@ import { Router } from "express" + import productRoutes from "./products" import cartRoutes from "./carts" +import customerRoutes from "./customers" import shippingOptionRoutes from "./shipping-options" const route = Router() @@ -8,6 +10,7 @@ const route = Router() export default app => { app.use("/store", route) + customerRoutes(route) productRoutes(route) cartRoutes(route) shippingOptionRoutes(route) diff --git a/packages/medusa/src/helpers/test-request.js b/packages/medusa/src/helpers/test-request.js index 4920661cc6..b0feb084e6 100644 --- a/packages/medusa/src/helpers/test-request.js +++ b/packages/medusa/src/helpers/test-request.js @@ -69,6 +69,16 @@ export async function request(method, url, opts = {}) { // console.log(sessions.util.decode(adminSessionOpts, opts.headers.Cookie)) } if (opts.clientSession) { + if (opts.clientSession.jwt) { + opts.clientSession.jwt = jwt.sign( + opts.clientSession.jwt, + config.jwtSecret, + { + expiresIn: "30d", + } + ) + } + headers.Cookie += clientSessionOpts.cookieName + "=" + diff --git a/packages/medusa/src/models/__mocks__/customer.js b/packages/medusa/src/models/__mocks__/customer.js index 2d0c7af0e2..c7c8adadfa 100644 --- a/packages/medusa/src/models/__mocks__/customer.js +++ b/packages/medusa/src/models/__mocks__/customer.js @@ -26,6 +26,9 @@ export const CustomerModelMock = { }), deleteOne: jest.fn().mockReturnValue(Promise.resolve()), findOne: jest.fn().mockImplementation(query => { + if (query.email === "oliver@medusa.com") { + return Promise.resolve(customers.testCustomer) + } if (query._id === IdMap.getId("testCustomer")) { return Promise.resolve(customers.testCustomer) } diff --git a/packages/medusa/src/models/customer.js b/packages/medusa/src/models/customer.js index 90e74435ca..2988f7e88d 100644 --- a/packages/medusa/src/models/customer.js +++ b/packages/medusa/src/models/customer.js @@ -14,6 +14,9 @@ class CustomerModel extends BaseModel { first_name: { type: String, required: true }, last_name: { type: String, required: true }, billing_address: { type: AddressSchema }, + password_hash: { type: String }, + has_account: { type: Boolean, default: false }, + orders: { type: [String], default: [] }, metadata: { type: mongoose.Schema.Types.Mixed, default: {} }, } } diff --git a/packages/medusa/src/services/__mocks__/customer.js b/packages/medusa/src/services/__mocks__/customer.js new file mode 100644 index 0000000000..195bb4e674 --- /dev/null +++ b/packages/medusa/src/services/__mocks__/customer.js @@ -0,0 +1,51 @@ +import bcrypt from "bcrypt" +import { IdMap } from "medusa-test-utils" + +export const CustomerServiceMock = { + create: jest.fn().mockImplementation(data => { + return Promise.resolve(data) + }), + update: jest.fn().mockImplementation((id, data) => { + return Promise.resolve(data) + }), + decorate: jest.fn().mockImplementation(data => { + let d = Object.assign({}, data) + d.decorated = true + return d + }), + generateResetPasswordToken: jest.fn().mockImplementation(id => { + return Promise.resolve() + }), + retrieve: jest.fn().mockImplementation(id => { + if (id === IdMap.getId("lebron")) { + return Promise.resolve({ + _id: IdMap.getId("lebron"), + first_name: "LeBron", + last_name: "James", + email: "lebron@james.com", + password_hash: "1234", + }) + } + }), + retrieveByEmail: jest.fn().mockImplementation(email => { + if (email === "lebron@james.com") { + return Promise.resolve({ + _id: IdMap.getId("lebron"), + email, + password_hash: "1234", + }) + } + if (email === "oliver@test.dk") { + return bcrypt + .hash("123456789", 10) + .then(hash => ({ email, password_hash: hash })) + } + return Promise.resolve(undefined) + }), +} + +const mock = jest.fn().mockImplementation(() => { + return CustomerServiceMock +}) + +export default mock diff --git a/packages/medusa/src/services/__tests__/auth.js b/packages/medusa/src/services/__tests__/auth.js index 71d0174f6d..a6d1b6f212 100644 --- a/packages/medusa/src/services/__tests__/auth.js +++ b/packages/medusa/src/services/__tests__/auth.js @@ -1,5 +1,6 @@ import AuthService from "../auth" import { users, UserServiceMock } from "../__mocks__/user" +import { customers, CustomerServiceMock } from "../__mocks__/customer" describe("AuthService", () => { describe("authenticate", () => { @@ -31,6 +32,35 @@ describe("AuthService", () => { }) }) + describe("authenticateCustomer", () => { + let authService + authService = new AuthService({ customerService: CustomerServiceMock }) + beforeEach(() => { + jest.clearAllMocks() + }) + + it("returns success and user when passwords match", async () => { + const result = await authService.authenticateCustomer( + "oliver@test.dk", + "123456789" + ) + + expect(result.success).toEqual(true) + expect(result.customer.email).toEqual("oliver@test.dk") + }) + + it("returns failure when passwords don't match", async () => { + const result = await authService.authenticateCustomer( + "oliver@test.dk", + "invalid-password" + ) + + expect(result.success).toEqual(false) + expect(result.error).toEqual("Invalid email or password") + expect(result.customer).toEqual(undefined) + }) + }) + describe("authenticateAPIToken", () => { let authService authService = new AuthService({ userService: UserServiceMock }) diff --git a/packages/medusa/src/services/__tests__/customer.js b/packages/medusa/src/services/__tests__/customer.js index 51114bbeb6..ccdd9bc444 100644 --- a/packages/medusa/src/services/__tests__/customer.js +++ b/packages/medusa/src/services/__tests__/customer.js @@ -26,6 +26,28 @@ describe("CustomerService", () => { }) }) + describe("retrieveByEmail", () => { + let result + beforeAll(async () => { + jest.clearAllMocks() + const customerService = new CustomerService({ + customerModel: CustomerModelMock, + }) + result = await customerService.retrieveByEmail("oliver@medusa.com") + }) + + it("calls customer model functions", () => { + expect(CustomerModelMock.findOne).toHaveBeenCalledTimes(1) + expect(CustomerModelMock.findOne).toHaveBeenCalledWith({ + email: "oliver@medusa.com", + }) + }) + + it("returns the customer", () => { + expect(result).toEqual(customers.testCustomer) + }) + }) + describe("setMetadata", () => { const customerService = new CustomerService({ customerModel: CustomerModelMock, @@ -68,8 +90,8 @@ describe("CustomerService", () => { jest.clearAllMocks() }) - it("calls model layer create", () => { - customerService.create({ + it("calls model layer create", async () => { + await customerService.create({ email: "oliver@medusa.com", first_name: "Oliver", last_name: "Juhl", @@ -83,20 +105,38 @@ describe("CustomerService", () => { }) }) - it("fails if email is in incorrect format", () => { - try { + it("calls model layer create", async () => { + await customerService.create({ + email: "oliver@medusa.com", + first_name: "Oliver", + last_name: "Juhl", + password: "secretsauce", + }) + + expect(CustomerModelMock.create).toBeCalledTimes(1) + expect(CustomerModelMock.create).toBeCalledWith({ + email: "oliver@medusa.com", + first_name: "Oliver", + last_name: "Juhl", + has_account: true, + password_hash: expect.stringMatching( + /^\$2[aby]?\$[\d]+\$[./A-Za-z0-9]{53}$/ + ), + }) + }) + + it("fails if email is in incorrect format", async () => { + expect( customerService.create({ email: "olivermedusa.com", first_name: "Oliver", last_name: "Juhl", }) - } catch (error) { - expect(error.message).toEqual("The email is not valid") - } + ).rejects.toThrow("The email is not valid") }) it("fails if billing address is in incorrect format", () => { - try { + expect( customerService.create({ email: "oliver@medusa.com", first_name: "Oliver", @@ -105,9 +145,7 @@ describe("CustomerService", () => { first_name: 1234, }, }) - } catch (error) { - expect(error.message).toEqual("The address is not valid") - } + ).rejects.toThrow("The address is not valid") }) }) @@ -124,12 +162,19 @@ describe("CustomerService", () => { await customerService.update(IdMap.getId("testCustomer"), { first_name: "Olli", last_name: "Test", + email: "oliver@medusa2.com", }) expect(CustomerModelMock.updateOne).toBeCalledTimes(1) expect(CustomerModelMock.updateOne).toBeCalledWith( { _id: IdMap.getId("testCustomer") }, - { $set: { first_name: "Olli", last_name: "Test" } }, + { + $set: { + first_name: "Olli", + last_name: "Test", + email: "oliver@medusa2.com", + }, + }, { runValidators: true } ) }) @@ -144,118 +189,62 @@ describe("CustomerService", () => { } }) - it("fails if billing address updates are attempted", async () => { - try { - await customerService.update(IdMap.getId("testCustomer"), { - billing_address: { - last_name: "nnonono", - }, - }) - } catch (err) { - expect(err.message).toEqual( - "Use updateBillingAddress to update billing address" - ) - } - }) - }) - - describe("updateEmail", () => { - const customerService = new CustomerService({ - customerModel: CustomerModelMock, - }) - - beforeEach(() => { - jest.clearAllMocks() - }) - - it("successfully updates an email", async () => { - await customerService.updateEmail( - IdMap.getId("testCustomer"), - "oliver@medusa2.com" - ) - - expect(CustomerModelMock.updateOne).toHaveBeenCalledTimes(1) - expect(CustomerModelMock.updateOne).toHaveBeenCalledWith( - { - _id: IdMap.getId("testCustomer"), + it("updates with billing address", async () => { + await customerService.update(IdMap.getId("testCustomer"), { + first_name: "Olli", + last_name: "Test", + billing_address: { + first_name: "Olli", + last_name: "Juhl", + address_1: "Laksegade", + city: "Copenhagen", + country_code: "DK", + postal_code: "2100", }, + }) + + expect(CustomerModelMock.updateOne).toBeCalledTimes(1) + expect(CustomerModelMock.updateOne).toBeCalledWith( + { _id: IdMap.getId("testCustomer") }, { - $set: { email: "oliver@medusa2.com" }, - } + $set: { + first_name: "Olli", + last_name: "Test", + billing_address: { + first_name: "Olli", + last_name: "Juhl", + address_1: "Laksegade", + city: "Copenhagen", + country_code: "DK", + postal_code: "2100", + }, + }, + }, + { runValidators: true } ) }) - it("throws on invalid email", async () => { - try { - await customerService.updateEmail( - IdMap.getId("testCustomer"), - "olivermedusa" - ) - } catch (err) { - expect(err.message).toEqual("The email is not valid") - } - - expect(CustomerModelMock.updateOne).toHaveBeenCalledTimes(0) - }) - - describe("updateBillingAddress", () => { - const customerService = new CustomerService({ - customerModel: CustomerModelMock, + it("updates with password", async () => { + await customerService.update(IdMap.getId("testCustomer"), { + password: "newpassword", }) - beforeEach(() => { - jest.clearAllMocks() - }) - - it("successfully updates billing address", async () => { - await customerService.updateBillingAddress( - IdMap.getId("testCustomer"), - { - first_name: "Olli", - last_name: "Juhl", - address_1: "Laksegade", - city: "Copenhagen", - country_code: "DK", - postal_code: "2100", - } - ) - - expect(CustomerModelMock.updateOne).toHaveBeenCalledTimes(1) - expect(CustomerModelMock.updateOne).toHaveBeenCalledWith( - { - _id: IdMap.getId("testCustomer"), + expect(CustomerModelMock.updateOne).toBeCalledTimes(1) + expect(CustomerModelMock.updateOne).toBeCalledWith( + { _id: IdMap.getId("testCustomer") }, + { + $set: { + has_account: true, + password_hash: expect.stringMatching( + /^\$2[aby]?\$[\d]+\$[./A-Za-z0-9]{53}$/ + ), }, - { - $set: { - billing_address: { - first_name: "Olli", - last_name: "Juhl", - address_1: "Laksegade", - city: "Copenhagen", - country_code: "DK", - postal_code: "2100", - }, - }, - } - ) - }) - - it("throws on invalid address", async () => { - try { - await customerService.updateBillingAddress( - IdMap.getId("testCustomer"), - { - first_name: 1234, - } - ) - } catch (err) { - expect(err.message).toEqual("The address is not valid") - } - - expect(CustomerModelMock.updateOne).toHaveBeenCalledTimes(0) - }) + }, + { runValidators: true } + ) }) }) + describe("delete", () => { const customerService = new CustomerService({ customerModel: CustomerModelMock, diff --git a/packages/medusa/src/services/auth.js b/packages/medusa/src/services/auth.js index a6954554b9..f6b624def1 100644 --- a/packages/medusa/src/services/auth.js +++ b/packages/medusa/src/services/auth.js @@ -6,10 +6,14 @@ import { BaseService } from "medusa-interfaces" * @implements BaseService */ class AuthService extends BaseService { - constructor({ userService }) { + constructor({ userService, customerService }) { super() + /** @private @const {UserService} */ this.userService_ = userService + + /** @private @const {CustomerService} */ + this.customerService_ = customerService } /** @@ -38,6 +42,7 @@ class AuthService extends BaseService { } } } + /** * Authenticates a given user based on an email, password combination. Uses * bcrypt to match password with hashed value. @@ -70,6 +75,49 @@ class AuthService extends BaseService { } } } + + /** + * Authenticates a customer based on an email, password combination. Uses + * bcrypt to match password with hashed value. + * @param {string} email - the email of the user + * @param {string} password - the password of the user + * @return {{ success: (bool), user: (object | undefined) }} + * success: whether authentication succeeded + * user: the user document if authentication succeded + * error: a string with the error message + */ + async authenticateCustomer(email, password) { + try { + const customer = await this.customerService_.retrieveByEmail(email) + if (!customer.password_hash) { + return { + success: false, + error: "Invalid email or password", + } + } + + const passwordsMatch = await bcrypt.compare( + password, + customer.password_hash + ) + if (passwordsMatch) { + return { + success: true, + customer, + } + } else { + return { + success: false, + error: "Invalid email or password", + } + } + } catch (error) { + return { + success: false, + error: "Invalid email or password", + } + } + } } export default AuthService diff --git a/packages/medusa/src/services/customer.js b/packages/medusa/src/services/customer.js index b2f16f36b1..37fae0dfde 100644 --- a/packages/medusa/src/services/customer.js +++ b/packages/medusa/src/services/customer.js @@ -1,4 +1,5 @@ import mongoose from "mongoose" +import bcrypt from "bcrypt" import _ from "lodash" import { Validator, MedusaError } from "medusa-core-utils" import { BaseService } from "medusa-interfaces" @@ -68,6 +69,36 @@ class CustomerService extends BaseService { return value } + /** + * Generate a JSON Web token, that will be sent to a customer, that wishes to + * reset password. + * The token will be signed with the customer's current password hash as a + * secret a long side a payload with userId and the expiry time for the token, + * which is always 15 minutes. + * @param {string} customerId - the customer to reset the password for + * @returns {string} the generated JSON web token + */ + async generateResetPasswordToken(customerId) { + const customer = await this.retrieve(customerId) + + if (!customer.has_account) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "You must have an account to reset the password. Create an account first" + ) + } + + const secret = customer.password_hash + const expiry = Math.floor(Date.now() / 1000) + 60 * 15 // 15 minutes ahead + const payload = { customer_id: customer._id, exp: expiry } + const token = jwt.sign(payload, secret) + + // TODO: Call event layer to ensure that there is an email service that + // sends the token. + + return token + } + /** * @param {Object} selector - the query object for find * @return {Promise} the result of the find operation @@ -99,63 +130,54 @@ class CustomerService extends BaseService { } /** - * Creates a customer with email and billing address - * (if provided) being validated. + * Gets a customer by email. + * @param {string} email - the email of the customer to get. + * @return {Promise} the customer document. + */ + async retrieveByEmail(email) { + this.validateEmail_(email) + const customer = await this.customerModel_.findOne({ email }).catch(err => { + throw new MedusaError(MedusaError.Types.DB_ERROR, err.message) + }) + + if (!customer) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `Customer with email ${email} was not found` + ) + } + + return customer + } + + /** + * Creates a customer from an email - customers can have accounts associated, + * e.g. to login and view order history, etc. If a password is provided the + * customer will automatically get an account, otherwise the customer is just + * used to hold details of customers. * @param {object} customer - the customer to create * @return {Promise} the result of create */ - create(customer) { - const { email, billing_address } = customer + async create(customer) { + const { email, billing_address, password } = customer this.validateEmail_(email) + if (billing_address) { this.validateBillingAddress_(billing_address) } - this.customerModel_.create(customer).catch(err => { + + if (password) { + const hashedPassword = await bcrypt.hash(password, 10) + customer.password_hash = hashedPassword + customer.has_account = true + delete customer.password + } + + return this.customerModel_.create(customer).catch(err => { throw new MedusaError(MedusaError.Types.DB_ERROR, err.message) }) } - /** - * Sets the email of a customer - * @param {string} customerId - the id of the customer to update - * @param {string} email - the email to add to customer - * @return {Promise} the result of the update operation - */ - async updateEmail(customerId, email) { - const customer = await this.retrieve(customerId) - this.validateEmail_(email) - - return this.customerModel_.updateOne( - { - _id: customer._id, - }, - { - $set: { email }, - } - ) - } - - /** - * Sets the billing address of a customer - * @param {*} customerId - the customer to update address on - * @param {*} address - the new address to replace the current one - * @return {Promise} the result of the update operation - */ - async updateBillingAddress(customerId, address) { - const customer = await this.retrieve(customerId) - - this.validateBillingAddress_(address) - - return this.customerModel_.updateOne( - { - _id: customer._id, - }, - { - $set: { billing_address: address }, - } - ) - } - /** * Updates a customer. Metadata updates and address updates should * use dedicated methods, e.g. `setMetadata`, etc. The function @@ -175,11 +197,19 @@ class CustomerService extends BaseService { ) } + if (update.email) { + this.validateEmail_(update.email) + } + if (update.billing_address) { - throw new MedusaError( - MedusaError.Types.INVALID_DATA, - "Use updateBillingAddress to update billing address" - ) + this.validateBillingAddress_(update.billing_address) + } + + if (update.password) { + const hashedPassword = await bcrypt.hash(update.password, 10) + update.password_hash = hashedPassword + update.has_account = true + delete update.password } return this.customerModel_ diff --git a/packages/medusa/src/services/user.js b/packages/medusa/src/services/user.js index e61a712431..c0ccff3a08 100644 --- a/packages/medusa/src/services/user.js +++ b/packages/medusa/src/services/user.js @@ -243,7 +243,7 @@ class UserService extends BaseService { const user = await this.retrieve(userId) const secret = user.password_hash const expiry = Math.floor(Date.now() / 1000) + 60 * 15 - const payload = { userId: user._id, exp: expiry } + const payload = { user_id: user._id, exp: expiry } const token = jwt.sign(payload, secret) return token }