@stanlemon/server-with-auth 0.2.11 → 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.
package/README.md ADDED
@@ -0,0 +1,48 @@
1
+ # Express App Server with Authentication
2
+
3
+ [![npm version](https://badge.fury.io/js/%40stanlemon%2Fserver-with-auth.svg)](https://badge.fury.io/js/%40stanlemon%2Fserver-with-auth)
4
+
5
+ This is a base express app server that is wired up with sensible defaults, like compression, json support and serving the `dist` folder statically and also includes basic authentication support. It builds off of the [@stanlemon/server](../server/README.md) package.
6
+
7
+ This package includes authentication against secure endpoints using JWT. There are endpoints for logging in and signing up, as well as basic user management flows such as updating a profile, verifying a user and resetting a password.
8
+
9
+ When `NODE_ENV=development` the server will also proxy requests to webpack.
10
+
11
+ This library goes well with [@stanlemon/webdev](../webdev/README.md). You can see this package, along with [@stanlemon/webdev] in action by using the [@stanlemon/app-template](../../apps/template/README.md) package.
12
+
13
+ ```javascript
14
+ import {
15
+ createAppServer,
16
+ asyncJsonHandler as handler,
17
+ NotFoundException,
18
+ LowDBUserDao,
19
+ createLowDb
20
+ } from "@stanlemon/server-with-auth";
21
+ import from "./src/data/lowdb-user-dao.js";
22
+
23
+ const db = createLowDb();
24
+ const dao = new LowDBUserDao(db);
25
+
26
+ const app = createAppServer({
27
+ port: 3003,secure: ["/api/"],
28
+ schemas,
29
+ dao,
30
+ });
31
+
32
+
33
+ // Insecure endpoint
34
+ app.get(
35
+ "/",
36
+ handler(() => ({ hello: "world" }))
37
+ );
38
+
39
+ // Secure endpoint
40
+ app.get(
41
+ "/api/users",
42
+ handler(() => ({
43
+ users: db.data.users.map((u) =>
44
+ omit(u, ["password", "verification_token"])
45
+ ),
46
+ }))
47
+ );
48
+ ```
package/app.js CHANGED
@@ -1,13 +1,40 @@
1
- import { createAppServer, asyncJsonHandler as handler } from "./src/index.js";
2
- import LowDBUserDao, { createDb } from "./src/data/lowdb-user-dao.js";
1
+ import { omit } from "lodash-es";
2
+ import EventEmitter from "node:events";
3
+ import Joi from "joi";
4
+ import {
5
+ createAppServer,
6
+ asyncJsonHandler as handler,
7
+ createSchemas,
8
+ EVENTS,
9
+ LowDBUserDao,
10
+ createLowDb,
11
+ } from "./src/index.js";
3
12
 
4
- const db = createDb();
13
+ const db = createLowDb();
5
14
  const dao = new LowDBUserDao(db);
15
+ const eventEmitter = new EventEmitter();
16
+ Object.values(EVENTS).forEach((event) => {
17
+ eventEmitter.on(event, (user) => {
18
+ // eslint-disable-next-line no-console
19
+ console.info(
20
+ `Event = ${event}, User = ${JSON.stringify(
21
+ omit(user, ["password", "verification_token"])
22
+ )}`
23
+ );
24
+ });
25
+ });
26
+
27
+ const schemas = createSchemas({
28
+ fullName: Joi.string().required().label("Full Name"),
29
+ email: Joi.string().email().required().label("Email"),
30
+ });
6
31
 
7
32
  const app = createAppServer({
8
33
  port: 3003,
9
34
  secure: ["/api/"],
35
+ schemas,
10
36
  dao,
37
+ eventEmitter,
11
38
  });
12
39
 
13
40
  // Insecure endpoint
@@ -25,5 +52,11 @@ app.get(
25
52
  // Secure endpoint
26
53
  app.get(
27
54
  "/api/users",
28
- handler(() => ({ users: db.data.users }))
55
+ handler(() => ({
56
+ users: db.data.users.map((u) =>
57
+ omit(u, ["password", "verification_token"])
58
+ ),
59
+ }))
29
60
  );
61
+
62
+ app.catch404s("/api/*");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stanlemon/server-with-auth",
3
- "version": "0.2.11",
3
+ "version": "0.3.0",
4
4
  "description": "A basic express web server setup with authentication baked in.",
5
5
  "author": "Stan Lemon <stanlemon@users.noreply.github.com>",
6
6
  "license": "MIT",
@@ -13,7 +13,7 @@
13
13
  "scripts": {
14
14
  "start": "NODE_ENV=development nodemon --ignore db.json ./app.js",
15
15
  "lint": "eslint --ext js,jsx,ts,tsx ./",
16
- "lint:format": "eslint --fix --ext js,jsx,ts,tsx ./",
16
+ "lint:fix": "eslint --fix --ext js,jsx,ts,tsx ./",
17
17
  "test": "jest --detectOpenHandles",
18
18
  "test:coverage": "jest --detectOpenHandles --coverage",
19
19
  "test:watch": "jest --detectOpenHandles --watch"
@@ -22,17 +22,21 @@
22
22
  "@stanlemon/server": "*",
23
23
  "@stanlemon/webdev": "*",
24
24
  "bcryptjs": "^2.4.3",
25
+ "express-session": "^1.17.3",
25
26
  "jsonwebtoken": "^9.0.2",
26
- "lowdb": "^6.1.1",
27
+ "lowdb": "^7.0.1",
27
28
  "lowdb-node": "^3.0.2",
28
29
  "passport": "^0.7.0",
29
30
  "passport-jwt": "^4.0.1",
31
+ "passport-local": "^1.0.0",
30
32
  "uuid": "^9.0.1"
31
33
  },
32
34
  "devDependencies": {
33
35
  "@stanlemon/eslint-config": "*",
34
36
  "@types/supertest": "^6.0.2",
37
+ "better-sqlite3": "^9.2.2",
38
+ "knex": "^3.1.0",
35
39
  "nodemon": "^3.0.2",
36
40
  "supertest": "^6.3.3"
37
41
  }
38
- }
42
+ }
@@ -0,0 +1,59 @@
1
+ export const HIDDEN_FIELDS = ["password", "verification_token"];
2
+
3
+ export const ROUTES = {
4
+ SIGNUP: "/auth/signup",
5
+ REGISTER: "/auth/register",
6
+ SESSION: "/auth/session",
7
+ LOGIN: "/auth/login",
8
+ LOGOUT: "/auth/logout",
9
+ USER: "/auth/user",
10
+ VERIFY: "/auth/verify/:token",
11
+ RESET: "/auth/reset",
12
+ PASSWORD: "/auth/password",
13
+ };
14
+
15
+ /**
16
+ * Authentication related events.
17
+ *
18
+ * Listen to these with the {EventEmitter}.
19
+ */
20
+ export const EVENTS = {
21
+ /**
22
+ * When a user logs in.
23
+ */
24
+ USER_LOGIN: "user.login",
25
+ /**
26
+ * When a user logs out.
27
+ */
28
+ USER_LOGOUT: "user.logout",
29
+ /**
30
+ * When a user is created.
31
+ */
32
+ USER_CREATED: "user.created",
33
+ /**
34
+ * When a user is verified.
35
+ */
36
+ USER_VERIFIED: "user.verified",
37
+ /**
38
+ * When a user is updated.
39
+ */
40
+ USER_UPDATED: "user.updated",
41
+ /**
42
+ * When a user is deleted.
43
+ */
44
+ USER_DELETED: "user.deleted",
45
+ /**
46
+ * When a user changes their password.
47
+ */
48
+ USER_PASSWORD: "user.password",
49
+ /**
50
+ * When a user reset is requested.
51
+ * This is not a managed lifecycle event, you'll need to implement this!
52
+ */
53
+ USER_RESET_REQUESTED: "user.reset.requested",
54
+ /**
55
+ * When a user reset is completed.
56
+ * This is not a managed lifecycle event, you'll need to implement this!
57
+ */
58
+ USER_RESET_COMPLETED: "user.reset.completed",
59
+ };
@@ -1,54 +1,60 @@
1
+ import EventEmitter from "node:events";
1
2
  import dotenv from "dotenv";
2
3
  import {
3
4
  createAppServer as createBaseAppServer,
4
5
  DEFAULTS as BASE_DEFAULTS,
5
6
  } from "@stanlemon/server";
7
+ import expressSession from "express-session";
6
8
  import passport from "passport";
9
+ import { Strategy as LocalStrategy } from "passport-local";
7
10
  import { Strategy as JwtStrategy, ExtractJwt } from "passport-jwt";
8
11
  import { v4 as uuid } from "uuid";
9
- import Joi from "joi";
10
- import defaultUserSchema from "./schema/user.js";
12
+ import SCHEMAS from "./schema/index.js";
11
13
  import checkAuth from "./checkAuth.js";
12
14
  import auth from "./routes/auth.js";
13
15
  import UserDao from "./data/user-dao.js";
16
+ import checkUserDao from "./utilities/checkUserDao.js";
17
+ import checkSchemas from "./utilities/checkSchemas.js";
14
18
 
15
19
  dotenv.config();
16
20
 
17
21
  export const DEFAULTS = {
18
22
  ...BASE_DEFAULTS,
19
23
  secure: [],
20
- schema: defaultUserSchema,
24
+ schemas: SCHEMAS,
21
25
  dao: new UserDao(),
26
+ eventEmitter: new EventEmitter(),
27
+ jwtExpireInMinutes: 10,
22
28
  };
23
29
 
24
30
  /**
25
31
  * Create an app server with authentication.
32
+ * @param {object} options
26
33
  * @param {number} options.port Port to listen on
27
34
  * @param {boolean} options.webpack Whether or not to create a proxy for webpack
28
35
  * @param {string[]} options.secure Paths that require authentication
29
- * @param {Joi.Schema} options.schema Joi schema for user object
36
+ * @param {Object.<string, Joi.Schema>} options.schemas Object map of routes to schema for validating route inputs.
30
37
  * @param {UserDao} options.dao Data access object for user interactions
31
- * @returns {import("express").Express} Express app
38
+ * @returns {import("@stanlemon/server/src/createAppServer.js").AppServer} Pre-configured express app server with extra helper methods
32
39
  */
40
+ /* eslint-disable max-lines-per-function */
33
41
  export default function createAppServer(options) {
34
- const { port, webpack, start, secure, schema, dao } = {
42
+ const {
43
+ port,
44
+ webpack,
45
+ start,
46
+ secure,
47
+ schemas,
48
+ dao,
49
+ eventEmitter,
50
+ jwtExpireInMinutes,
51
+ } = {
35
52
  ...DEFAULTS,
36
53
  ...options,
37
54
  };
38
55
 
39
- if (!(dao instanceof UserDao)) {
40
- throw new Error("The dao object must be of type UserDao.");
41
- }
42
-
43
- if (!Joi.isSchema(schema)) {
44
- throw new Error("The schema object must be of type Joi schema.");
45
- }
46
-
47
- if (!schema.describe().keys.username || !schema.describe().keys.password) {
48
- throw new Error(
49
- "The schema object must have a username and password defined."
50
- );
51
- }
56
+ checkUserDao(dao);
57
+ checkSchemas(schemas);
52
58
 
53
59
  const app = createBaseAppServer({ port, webpack, start });
54
60
 
@@ -56,21 +62,38 @@ export default function createAppServer(options) {
56
62
  return app;
57
63
  }
58
64
 
65
+ if (!process.env.COOKIE_SECRET) {
66
+ console.warn("You need to specify a cookie secret!");
67
+ }
68
+
59
69
  if (!process.env.JWT_SECRET) {
60
70
  console.warn("You need to specify a JWT secret!");
61
71
  }
62
72
 
63
- const secret = process.env.JWT_SECRET || uuid();
73
+ // These secrets will not be stable between restarts
74
+ const cookieSecret = process.env.COOKIE_SECRET || uuid();
75
+ const jwtSecret = process.env.JWT_SECRET || uuid();
76
+
77
+ passport.use(
78
+ new LocalStrategy((username, password, done) => {
79
+ dao.getUserByUsernameAndPassword(username, password).then((user) => {
80
+ if (!user) {
81
+ return done(null, false);
82
+ }
83
+ return done(null, user);
84
+ });
85
+ })
86
+ );
64
87
 
65
88
  passport.use(
66
89
  "jwt",
67
90
  new JwtStrategy(
68
91
  {
69
92
  jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
70
- secretOrKey: secret,
93
+ secretOrKey: jwtSecret,
71
94
  // NOTE: Setting options like 'issuer' here must also be set when the token is signed below
72
95
  jsonWebTokenOptions: {
73
- expiresIn: "120m",
96
+ expiresIn: `${jwtExpireInMinutes}m`,
74
97
  },
75
98
  },
76
99
  (payload, done) => {
@@ -78,27 +101,40 @@ export default function createAppServer(options) {
78
101
  }
79
102
  )
80
103
  );
81
- passport.serializeUser((id, done) => {
82
- done(null, id);
104
+
105
+ passport.serializeUser((user, done) => {
106
+ done(null, user);
83
107
  });
84
- passport.deserializeUser((id, done) => {
108
+
109
+ passport.deserializeUser(({ id }, done) => {
85
110
  dao
86
111
  .getUserById(id)
87
112
  .then((user) => {
88
113
  // An undefined user means we couldn't find it, so the session is invalid
89
- done(null, user === undefined ? false : user);
114
+ done(null, !user ? false : user);
90
115
  })
91
116
  .catch((error) => {
92
117
  done(error, null);
93
118
  });
94
119
  });
95
- passport.initialize();
120
+
121
+ app.use(
122
+ expressSession({
123
+ secret: cookieSecret,
124
+ resave: true,
125
+ saveUninitialized: true,
126
+ })
127
+ );
128
+ app.use(passport.initialize());
129
+ app.use(passport.session());
96
130
 
97
131
  app.use(
98
132
  auth({
99
- secret,
100
- schema,
133
+ secret: jwtSecret,
134
+ schemas,
101
135
  dao,
136
+ eventEmitter,
137
+ jwtExpireInMinutes,
102
138
  })
103
139
  );
104
140
 
@@ -106,5 +142,11 @@ export default function createAppServer(options) {
106
142
  app.use(path, checkAuth());
107
143
  });
108
144
 
145
+ // Handling 500
146
+ app.use(function (error, req, res, next) {
147
+ console.error(error);
148
+ res.status(500).json({ error: "Internal Error" });
149
+ });
150
+
109
151
  return app;
110
152
  }
@@ -0,0 +1,142 @@
1
+ import { v4 as uuidv4 } from "uuid";
2
+ import bcrypt from "bcryptjs";
3
+ import UserDao from "./user-dao.js";
4
+ import knex from "knex";
5
+
6
+ export async function createBetterSqlite3Db() {
7
+ const db = knex({
8
+ client: "better-sqlite3",
9
+ connection: {
10
+ filename: ":memory:",
11
+ },
12
+ });
13
+
14
+ await db.schema.createTable("users", (table) => {
15
+ table.string("id").primary(); // UUID
16
+ table.string("username").notNullable();
17
+ table.string("password").notNullable();
18
+ table.string("email").nullable();
19
+ table.string("name").nullable();
20
+ table.string("verification_token").notNullable();
21
+ table.dateTime("verified_date").nullable();
22
+ table.dateTime("last_login").nullable();
23
+ table.dateTime("created_at").notNullable();
24
+ table.dateTime("last_updated").notNullable();
25
+ });
26
+
27
+ return db;
28
+ }
29
+
30
+ export default class KnexUserDao extends UserDao {
31
+ #db;
32
+
33
+ constructor(db) {
34
+ super();
35
+
36
+ // TODO: Check if db is a knex instance and throw an error if it is now
37
+ this.#db = db;
38
+ }
39
+
40
+ /** @inheritdoc */
41
+ async getUserById(userId) {
42
+ return await this.#db("users").select().where({ id: userId }).first();
43
+ }
44
+
45
+ /** @inheritdoc */
46
+ async getUserByUsername(username) {
47
+ const user = await this.#db
48
+ .select()
49
+ .from("users")
50
+ .where({ username: username })
51
+ .first();
52
+
53
+ return !user ? false : user;
54
+ }
55
+
56
+ /** @inheritdoc */
57
+ async getUserByUsernameAndPassword(username, password) {
58
+ // Treat this like a failed login.
59
+ if (!username || !password) {
60
+ return false;
61
+ }
62
+
63
+ const user = await this.getUserByUsername(username);
64
+
65
+ if (!user || !bcrypt.compareSync(password, user.password)) {
66
+ return false;
67
+ }
68
+
69
+ return user;
70
+ }
71
+
72
+ /** @inheritdoc */
73
+ async getUserByVerificationToken(token) {
74
+ if (!token) {
75
+ return false;
76
+ }
77
+
78
+ const user = await this.#db("users")
79
+ .select()
80
+ .where({ verification_token: token })
81
+ .first();
82
+
83
+ return !user ? false : user;
84
+ }
85
+
86
+ /** @inheritdoc */
87
+ async createUser(user) {
88
+ const existing = await this.getUserByUsername(user.username);
89
+
90
+ if (existing) {
91
+ throw new Error("This username is already taken.");
92
+ }
93
+ const now = new Date();
94
+
95
+ const data = {
96
+ ...user,
97
+ password: bcrypt.hashSync(user.password, 10),
98
+ id: uuidv4(),
99
+ verification_token: uuidv4(),
100
+ created_at: now,
101
+ last_updated: now,
102
+ };
103
+
104
+ await this.#db("users").insert(data);
105
+
106
+ return await this.getUserById(data.id);
107
+ }
108
+
109
+ /** @inheritdoc */
110
+ async updateUser(userId, user) {
111
+ if (!userId) {
112
+ throw new Error("User ID is required.");
113
+ }
114
+ if (!user) {
115
+ throw new Error("User data is required");
116
+ }
117
+
118
+ const existing = await this.getUserById(userId);
119
+
120
+ if (!existing) {
121
+ throw new Error("User does not exist.");
122
+ }
123
+
124
+ await this.#db("users").where({ id: userId }).update(user);
125
+
126
+ return await this.getUserById(userId);
127
+ }
128
+
129
+ /** @inheritdoc */
130
+ async deleteUser(userId) {
131
+ return !!(await this.#db("users").where({ id: userId }).delete());
132
+ }
133
+
134
+ async getAllUsers() {
135
+ const users = await this.#db("users").select();
136
+ return users;
137
+ }
138
+
139
+ close() {
140
+ this.#db.destroy();
141
+ }
142
+ }