@stanlemon/server-with-auth 0.2.12 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,24 +1,50 @@
1
- import { isEmpty } from "lodash-es";
1
+ import { EventEmitter } from "node:events";
2
+ import { isEmpty, omit } from "lodash-es";
2
3
  import { Router } from "express";
4
+ import passport from "passport";
3
5
  import jwt from "jsonwebtoken";
4
- import {
5
- schemaHandler,
6
- formatOutput,
7
- BadRequestException,
8
- } from "@stanlemon/server";
6
+ import { schemaHandler, formatOutput } from "@stanlemon/server";
9
7
  import checkAuth from "../checkAuth.js";
10
- import UserDao from "../data/user-dao.js";
8
+ import checkUserDao from "../utilities/checkUserDao.js";
9
+ import checkSchemas from "../utilities/checkSchemas.js";
10
+ import { HIDDEN_FIELDS, ROUTES, EVENTS } from "../constants.js";
11
11
 
12
+ /**
13
+ * Create express routes for authentication operations.
14
+ * @param {object} options
15
+ * @param {string[]} options.secret Route patterns to protect by authentication.
16
+ * @param {Object.<string, Joi.Schema>} options.schemas Object map of routes to schema for validating route inputs.
17
+ * @param {import("../data/user-dao.js").default} options.dao Dao to use for various user operations
18
+ * @param {EventEmitter} options.eventEmitter Event emitter that can be used to hook into user operations
19
+ * @param {number} options.jwtExpireInMinutes Number of minutes before a JWT token expires
20
+ * @returns {Express.Router}
21
+ */
12
22
  /* eslint-disable max-lines-per-function */
13
- export default function authRoutes({ secret, schema, dao }) {
14
- if (!(dao instanceof UserDao)) {
15
- throw new Error("The dao object must be of type UserDao.");
23
+ export default function authRoutes({
24
+ secret,
25
+ schemas,
26
+ dao,
27
+ eventEmitter,
28
+ jwtExpireInMinutes,
29
+ }) {
30
+ checkUserDao(dao);
31
+ checkSchemas(schemas);
32
+
33
+ if (!(eventEmitter instanceof EventEmitter)) {
34
+ throw new Error("The eventEmitter object must be of type EventEmitter.");
16
35
  }
17
36
 
18
37
  const router = Router();
19
38
 
20
- router.get("/auth/session", checkAuth(), async (req, res) => {
21
- const userId = req.user;
39
+ const makeJwtToken = (user) => {
40
+ const token = jwt.sign({ ...user }, secret, {
41
+ expiresIn: 60 * jwtExpireInMinutes,
42
+ });
43
+ return token;
44
+ };
45
+
46
+ router.get(ROUTES.SESSION, checkAuth(), async (req, res) => {
47
+ const userId = req.user.id;
22
48
 
23
49
  if (!userId) {
24
50
  res.status(401).json({
@@ -36,38 +62,56 @@ export default function authRoutes({ secret, schema, dao }) {
36
62
  user: false,
37
63
  });
38
64
  } else {
39
- const token = jwt.sign(user.id, secret);
40
- res.status(200).json({ token, user: formatOutput(user, ["password"]) });
65
+ const token = makeJwtToken(user);
66
+ res.status(200).json({ token, user: formatOutput(user, HIDDEN_FIELDS) });
41
67
  }
42
68
  });
43
69
 
44
- router.post("/auth/login", async (req, res) => {
45
- const user = await dao.getUserByUsernameAndPassword(
46
- req.body.username,
47
- req.body.password
48
- );
70
+ router.post(ROUTES.LOGIN, (req, res, next) => {
71
+ // Customizing the login so we can send a proper JSON response when unable to login
72
+ passport.authenticate("local", (err, user, info) => {
73
+ if (err) {
74
+ return next(err);
75
+ }
76
+ if (!user) {
77
+ return res.status(401).json({
78
+ success: false,
79
+ message: "Incorrect username or password.",
80
+ });
81
+ }
49
82
 
50
- if (!user) {
51
- res.status(401).json({
52
- message: "Incorrect username or password.",
53
- });
54
- return;
55
- }
83
+ return req.logIn(user, (err) => {
84
+ if (err) {
85
+ return next(err);
86
+ }
56
87
 
57
- const update = await dao.updateUser(user.id, {
58
- last_logged_in: new Date(),
59
- });
88
+ return dao
89
+ .updateUser(user.id, {
90
+ last_logged_in: new Date(),
91
+ })
92
+ .then((update) => {
93
+ const token = makeJwtToken(user);
60
94
 
61
- const token = jwt.sign(user.id, secret);
95
+ eventEmitter.emit(EVENTS.USER_LOGIN, omit(user, ["password"]));
62
96
 
63
- res.json({
64
- token,
65
- user: formatOutput(update, ["password"]),
66
- });
97
+ return res.json({
98
+ token,
99
+ user: formatOutput(update, HIDDEN_FIELDS),
100
+ });
101
+ });
102
+ });
103
+ })(req, res, next);
67
104
  });
68
105
 
69
- router.get("/auth/logout", (req, res) => {
70
- req.logout();
106
+ router.get(ROUTES.LOGOUT, (req, res) => {
107
+ // This will happen if you are only using the JWT strategy
108
+ if (req.logout !== undefined) {
109
+ req.logout();
110
+ } else {
111
+ console.warn("Logout attempted, but there is no logout function.");
112
+ }
113
+
114
+ eventEmitter.emit(EVENTS.USER_LOGOUT, req.user.id);
71
115
 
72
116
  return res.status(401).json({
73
117
  token: false,
@@ -76,33 +120,38 @@ export default function authRoutes({ secret, schema, dao }) {
76
120
  });
77
121
 
78
122
  router.post(
79
- "/auth/register",
80
- schemaHandler(schema, async (data) => {
123
+ ROUTES.SIGNUP,
124
+ schemaHandler(schemas[ROUTES.SIGNUP], async (data, req, res) => {
81
125
  const existing = await dao.getUserByUsername(data.username);
82
126
 
83
127
  if (existing) {
84
- throw new BadRequestException(
85
- "A user with this username already exists"
86
- );
128
+ res.status(400).json({
129
+ success: false,
130
+ message: "A user with this username already exists.",
131
+ });
132
+ return;
87
133
  }
88
134
 
89
- const user = await dao.createUser(data);
135
+ const user = await dao.createUser({
136
+ ...data,
137
+ });
90
138
 
91
139
  if (isEmpty(user)) {
92
- return {
140
+ res.status(400).json({
141
+ success: false,
93
142
  message: "An error has occurred",
94
- };
143
+ });
144
+ return;
95
145
  }
96
146
 
97
- // TODO: Add hook for handling verification notification
98
- // user.verification_token
147
+ eventEmitter.emit(EVENTS.USER_CREATED, omit(user, ["password"]));
99
148
 
100
- const token = jwt.sign(user.id, secret);
101
- return { token, user: formatOutput(user, ["password"]) };
149
+ const token = makeJwtToken(user);
150
+ return { token, user: formatOutput(user, HIDDEN_FIELDS) };
102
151
  })
103
152
  );
104
153
 
105
- router.get("/auth/verify/:token", async (req, res) => {
154
+ router.get(ROUTES.VERIFY, async (req, res) => {
106
155
  const { token } = req.params;
107
156
 
108
157
  const user = await dao.getUserByVerificationToken(token);
@@ -121,8 +170,132 @@ export default function authRoutes({ secret, schema, dao }) {
121
170
 
122
171
  await dao.updateUser(user.id, { verified_date: new Date() });
123
172
 
173
+ eventEmitter.emit(EVENTS.USER_VERIFIED, omit(user, ["password"]));
174
+
124
175
  return res.send({ success: true, message: "User verified!" });
125
176
  });
126
177
 
178
+ router.get(ROUTES.USER, checkAuth(), async (req, res) => {
179
+ const userId = req.user.id;
180
+
181
+ if (!userId) {
182
+ res.status(401).json({
183
+ token: false,
184
+ user: false,
185
+ });
186
+ return;
187
+ }
188
+
189
+ const user = await dao.getUserById(userId);
190
+
191
+ if (!user) {
192
+ res.status(401).json({
193
+ token: false,
194
+ user: false,
195
+ });
196
+ } else {
197
+ res.status(200).json(formatOutput(user, HIDDEN_FIELDS));
198
+ }
199
+ });
200
+
201
+ router.put(
202
+ ROUTES.USER,
203
+ checkAuth(),
204
+ schemaHandler(schemas[ROUTES.USER], async (data, req, res) => {
205
+ const user = await dao.getUserById(req.user.id);
206
+
207
+ if (!user) {
208
+ res.status(404).json({
209
+ success: false,
210
+ message: "Not Found",
211
+ });
212
+ return;
213
+ }
214
+
215
+ const input = {
216
+ ...omit(data, [
217
+ "id",
218
+ "created_at",
219
+ "last_updated",
220
+ "last_login",
221
+ "password",
222
+ "username",
223
+ "verification_token",
224
+ "verified_date",
225
+ ]),
226
+ };
227
+
228
+ const updated = await dao.updateUser(user.id, input);
229
+
230
+ eventEmitter.emit(EVENTS.USER_UPDATED, omit(user, ["password"]));
231
+
232
+ res.status(200).json(formatOutput(updated, HIDDEN_FIELDS));
233
+ })
234
+ );
235
+
236
+ router.delete(ROUTES.USER, checkAuth(), async (req, res) => {
237
+ const deleted = await dao.deleteUser(req.user.id);
238
+
239
+ eventEmitter.emit(EVENTS.USER_DELETED, req.user.id, deleted);
240
+
241
+ res.status(200).json({ success: deleted });
242
+ });
243
+
244
+ router.post(
245
+ ROUTES.PASSWORD,
246
+ checkAuth(),
247
+ schemaHandler(schemas[ROUTES.PASSWORD], async (data, req, res) => {
248
+ const currentUser = await dao.getUserById(req.user.id);
249
+ const user = await dao.getUserByUsernameAndPassword(
250
+ currentUser.username,
251
+ data.current_password // Reminder: Joi will switch the casing
252
+ );
253
+
254
+ if (!user) {
255
+ res.status(400).json({
256
+ success: false,
257
+ errors: {
258
+ current_password: "Current password is incorrect",
259
+ },
260
+ });
261
+ return;
262
+ }
263
+
264
+ const success = await dao.updateUser(user.id, {
265
+ password: data.password,
266
+ });
267
+
268
+ if (success) {
269
+ eventEmitter.emit(EVENTS.USER_PASSWORD, omit(user, ["password"]));
270
+ }
271
+
272
+ return { success: success };
273
+ })
274
+ );
275
+
276
+ // TODO: This is a placeholder for a password reset request, you should implement the event handler to handle this.
277
+ router.get(
278
+ ROUTES.RESET,
279
+ schemaHandler(schemas[ROUTES.RESET], async (data) => {
280
+ const user = await dao.getUserByUsername(data.username);
281
+
282
+ eventEmitter.emit(EVENTS.USER_RESET_REQUESTED, omit(user, ["password"]));
283
+
284
+ return { success: true };
285
+ })
286
+ );
287
+
288
+ // TODO: This is a placeholder for a password reset completion, you should implement the event handler to handle this.
289
+ router.post(
290
+ ROUTES.RESET,
291
+ schemaHandler(schemas[ROUTES.RESET], async (data) => {
292
+ const user = await dao.getUserByUsername(data.username);
293
+
294
+ eventEmitter.emit(EVENTS.USER_RESET_COMPLETED, omit(user, ["password"]));
295
+
296
+ return { success: true };
297
+ })
298
+ );
299
+
127
300
  return router;
128
301
  }
@@ -2,59 +2,62 @@
2
2
  * @jest-environment node
3
3
  */
4
4
  import request from "supertest";
5
- import createAppServer from "../createAppServer";
6
- import LowDBUserDao, { createInMemoryDb } from "../data/lowdb-user-dao.js";
5
+ import Joi from "joi";
6
+ import { v4 as uuidv4 } from "uuid";
7
+ import { createAppServer, createSchemas } from "../index.js";
8
+ import LowDBUserDao, { createInMemoryLowDb } from "../data/lowdb-user-dao.js";
7
9
 
8
10
  // This suppresses a warning we don't need in tests
9
11
  process.env.JWT_SECRET = "SECRET";
10
12
 
11
- let dao = new LowDBUserDao(createInMemoryDb());
13
+ let dao = new LowDBUserDao(createInMemoryLowDb());
12
14
 
13
15
  // We want to explicitly test functionality we disable during testing
14
16
  process.env.NODE_ENV = "override";
15
- const app = createAppServer({ dao, start: false });
17
+ const app = createAppServer({
18
+ dao,
19
+ start: false,
20
+ schemas: createSchemas({
21
+ name: Joi.string().label("Name"),
22
+ email: Joi.string().email().label("Email"),
23
+ }),
24
+ });
16
25
  process.env.NODE_ENV = "test";
17
26
 
18
27
  describe("/auth", () => {
19
- let userId;
20
-
21
- beforeAll(async () => {
22
- // Reset our users database before each test
23
- const user = await dao.createUser({
24
- username: "test",
25
- password: "test",
26
- });
27
-
28
- userId = user.id;
29
- });
30
-
31
28
  // Disabling this linting rule because it is unaware of the supertest assertions
32
29
  /* eslint-disable jest/expect-expect */
33
- it("POST /register creates a user", async () => {
34
- const username = "test1";
30
+ it("POST /signup creates a user", async () => {
31
+ const username = "test" + uuidv4();
35
32
  const password = "p@$$w0rd!";
33
+ const fullName = "Test User";
36
34
 
37
35
  const data = {
38
36
  username,
39
37
  password,
38
+ fullName, // Not in the schema, should be discarded
40
39
  };
41
40
 
42
41
  await request(app)
43
- .post("/auth/register")
42
+ .post("/auth/signup")
44
43
  .set("Content-Type", "application/json")
45
44
  .set("Accept", "application/json")
46
45
  .send(data)
47
46
  .expect(200)
48
47
  .then((res) => {
48
+ expect(res.body.user.username).toEqual(username);
49
+ expect(res.body.user.createdAt).not.toBeUndefined();
50
+ expect(res.body.user.lastUpdated).not.toBeUndefined();
51
+ expect(res.body.user.fullName).toBeUndefined();
49
52
  expect(res.body.errors).toBeUndefined();
50
53
  expect(res.body.token).not.toBeUndefined();
51
54
  expect(res.body.user).not.toBeUndefined();
52
55
  });
53
56
  });
54
57
 
55
- it("POST /register returns error on empty data", async () => {
58
+ it("POST /signup returns error on empty data", async () => {
56
59
  await request(app)
57
- .post("/auth/register")
60
+ .post("/auth/signup")
58
61
  .set("Content-Type", "application/json")
59
62
  .set("Accept", "application/json")
60
63
  .expect(400)
@@ -64,11 +67,11 @@ describe("/auth", () => {
64
67
  });
65
68
  });
66
69
 
67
- it("POST /register returns error on short password", async () => {
70
+ it("POST /signup returns error on short password", async () => {
68
71
  await request(app)
69
- .post("/auth/register")
72
+ .post("/auth/signup")
70
73
  .send({
71
- username: "testshort",
74
+ username: "test" + uuidv4(),
72
75
  password: "short",
73
76
  })
74
77
  .set("Content-Type", "application/json")
@@ -80,11 +83,11 @@ describe("/auth", () => {
80
83
  });
81
84
  });
82
85
 
83
- it("POST /register returns error on too long password", async () => {
86
+ it("POST /signup returns error on too long password", async () => {
84
87
  await request(app)
85
- .post("/auth/register")
88
+ .post("/auth/signup")
86
89
  .send({
87
- username: "testlong",
90
+ username: "test" + uuidv4(),
88
91
  password:
89
92
  "waytolongpasswordtobeusedforthisapplicationyoushouldtrysomethingmuchmuchshorter",
90
93
  })
@@ -97,54 +100,206 @@ describe("/auth", () => {
97
100
  });
98
101
  });
99
102
 
100
- it("POST /register returns error on already taken username", async () => {
103
+ it("POST /signup returns error on already taken username", async () => {
104
+ const data = {
105
+ username: "test" + uuidv4(),
106
+ password: "p@$$w0rd!",
107
+ };
108
+
109
+ // First call should succeed
101
110
  await request(app)
102
- .post("/auth/register")
103
- .send({
104
- username: "test",
105
- password: "p@$$w0rd!",
106
- })
111
+ .post("/auth/signup")
112
+ .send(data)
113
+ .set("Content-Type", "application/json")
114
+ .set("Accept", "application/json")
115
+ .expect(200);
116
+
117
+ // Second call will fail
118
+ await request(app)
119
+ .post("/auth/signup")
120
+ .send(data)
107
121
  .set("Content-Type", "application/json")
108
122
  .set("Accept", "application/json")
109
123
  .expect(400)
110
124
  .then((res) => {
111
125
  expect(res.body).toEqual({
112
- error: "Bad Request: A user with this username already exists",
126
+ success: false,
127
+ message: "A user with this username already exists.",
113
128
  });
114
129
  });
115
130
  });
116
131
 
132
+ /**
133
+ * @returns Session token
134
+ */
135
+ async function signupAndLogin(
136
+ username = "test" + uuidv4(),
137
+ password = "p@$$w0rd!"
138
+ ) {
139
+ const signup = await request(app)
140
+ .post("/auth/signup")
141
+ .set("Content-Type", "application/json")
142
+ .set("Accept", "application/json")
143
+ .send({
144
+ username,
145
+ password,
146
+ })
147
+ .expect(200);
148
+
149
+ const session = await request(app)
150
+ .post("/auth/login")
151
+ .set("Content-Type", "application/json")
152
+ .set("Accept", "application/json")
153
+ .send({
154
+ username,
155
+ password,
156
+ })
157
+ .expect(200);
158
+
159
+ expect(signup.body.user.id).toEqual(session.body.user.id);
160
+
161
+ return {
162
+ id: session.body.user.id,
163
+ token: session.body.token,
164
+ };
165
+ }
166
+
117
167
  it("GET /verify verifies user", async () => {
118
- const user = dao.getUserById(userId);
168
+ const { id } = await signupAndLogin();
169
+ // Verification token should not be part of the response, so load it from the DB
170
+ const user = await dao.getUserById(id);
119
171
 
120
172
  expect(user.verification_token).not.toBe(null);
121
173
  expect(user.verified_date).toBeUndefined();
122
174
 
123
175
  // First call will verify the user
124
- await request(app)
176
+ const verify = await request(app)
125
177
  .get("/auth/verify/" + user.verification_token)
126
178
  .set("Content-Type", "application/json")
127
179
  .set("Accept", "application/json")
128
- .expect(200)
129
- .then((res) => {
130
- expect(res.body.success).toEqual(true);
131
- });
180
+ .expect(200);
181
+
182
+ expect(verify.body.success).toEqual(true);
132
183
 
133
- const refresh = await dao.getUserById(userId);
184
+ const refresh = await dao.getUserById(user.id);
134
185
 
135
186
  expect(refresh.verified_date).not.toBe(null);
136
187
 
137
188
  // Subsequent calls are not successful because the user is already verified
138
- await request(app)
189
+ const reverify = await request(app)
139
190
  .get("/auth/verify/" + user.verification_token)
140
191
  .set("Content-Type", "application/json")
141
192
  .set("Accept", "application/json")
142
- .expect(400)
143
- .then((res) => {
144
- expect(res.body).toEqual({
145
- success: false,
146
- message: "User already verified.",
147
- });
148
- });
193
+ .expect(400);
194
+ expect(reverify.body).toEqual({
195
+ success: false,
196
+ message: "User already verified.",
197
+ });
198
+ });
199
+
200
+ it("GET /user retrieves the user", async () => {
201
+ const username = "test" + uuidv4();
202
+ const password = "p@$$w0rd!";
203
+
204
+ const { id, token } = await signupAndLogin(username, password);
205
+
206
+ const user = await request(app)
207
+ .get("/auth/user")
208
+ .set("Content-Type", "application/json")
209
+ .set("Accept", "application/json")
210
+ .set("Authorization", "Bearer " + token);
211
+
212
+ expect(user.status).toEqual(200);
213
+ expect(user.body.id).toEqual(id);
214
+ expect(user.body.username).toEqual(username);
215
+ expect(user.body.createdAt).not.toBeUndefined();
216
+ expect(user.body.lastUpdated).not.toBeUndefined();
217
+ expect(user.body.fullName).toBeUndefined();
218
+ });
219
+
220
+ it("UPDATE /user updates the user", async () => {
221
+ const { id, token } = await signupAndLogin();
222
+
223
+ const check = await dao.getUserById(id);
224
+
225
+ expect(check.name).toBeFalsy();
226
+ expect(check.email).toBeFalsy();
227
+
228
+ const data = {
229
+ name: "Test User",
230
+ email: "test@test.com",
231
+ };
232
+
233
+ const update = await request(app)
234
+ .put("/auth/user")
235
+ .send(data)
236
+ .set("Content-Type", "application/json")
237
+ .set("Accept", "application/json")
238
+ .set("Authorization", "Bearer " + token);
239
+
240
+ expect(update.status).toEqual(200);
241
+
242
+ const user = await dao.getUserById(id);
243
+
244
+ expect(user.name).toEqual(data.name);
245
+ expect(user.email).toEqual(data.email);
246
+ });
247
+
248
+ it("DELETE /user deletes the user", async () => {
249
+ const { id, token } = await signupAndLogin();
250
+
251
+ const del = await request(app)
252
+ .delete("/auth/user")
253
+ .set("Content-Type", "application/json")
254
+ .set("Accept", "application/json")
255
+ .set("Authorization", "Bearer " + token);
256
+
257
+ expect(del.status).toEqual(200);
258
+
259
+ const user = await dao.getUserById(id);
260
+
261
+ expect(user).toBeFalsy();
262
+ });
263
+
264
+ it("POST /password updates the password", async () => {
265
+ const username = "test" + uuidv4();
266
+ const password1 = "FirstP@ssw0rd";
267
+ const password2 = "SecondP@ssw0rd";
268
+ const data = { password: password2, current_password: password1 };
269
+
270
+ const { token } = await signupAndLogin(username, password1);
271
+
272
+ const reset = await request(app)
273
+ .post("/auth/password")
274
+ .send(data)
275
+ .set("Content-Type", "application/json")
276
+ .set("Accept", "application/json")
277
+ .set("Authorization", "Bearer " + token);
278
+
279
+ expect(reset.status).toEqual(200);
280
+
281
+ // Using the old password should fail
282
+ await request(app)
283
+ .post("/auth/login")
284
+ .set("Content-Type", "application/json")
285
+ .set("Accept", "application/json")
286
+ .send({
287
+ username,
288
+ password: password1,
289
+ })
290
+ .expect(401);
291
+
292
+ // Using the new password should succeed
293
+ const attempt2 = await request(app)
294
+ .post("/auth/login")
295
+ .set("Content-Type", "application/json")
296
+ .set("Accept", "application/json")
297
+ .send({
298
+ username,
299
+ password: password2,
300
+ })
301
+ .expect(200);
302
+
303
+ expect(attempt2.body.user.username).toEqual(username);
149
304
  });
150
305
  });