@stanlemon/server-with-auth 0.1.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/app.js ADDED
@@ -0,0 +1,25 @@
1
+ import { createAppServer, asyncJsonHandler as handler } from "./src/index.js";
2
+ import SimpleUsersDao from "./src/data/simple-users-dao.js";
3
+
4
+ const users = new SimpleUsersDao();
5
+
6
+ const app = createAppServer({
7
+ port: 3003,
8
+ secure: ["/api/"],
9
+ ...users,
10
+ });
11
+
12
+ app.get(
13
+ "/",
14
+ handler(({ name }) => ({ hello: "world" }))
15
+ );
16
+
17
+ app.get(
18
+ "/api/users",
19
+ handler(() => ({ users: users.db.data.users }))
20
+ );
21
+
22
+ app.get(
23
+ "/insecure",
24
+ handler(() => ({ secure: false }))
25
+ );
package/db.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "users": [
3
+ {
4
+ "username": "user",
5
+ "password": "$2a$10$KBUEk19saDPRLKfJYsI8zOgOYGW7Ms1wLGyAwIISjovUKYlnv2qwi",
6
+ "id": "1e56422f-16a4-4508-a779-6a90d685aca1",
7
+ "verification_token": "nUuif_f3l",
8
+ "created_at": "2022-03-06T18:15:51.821Z",
9
+ "last_updated": "2022-03-06T18:34:07.406Z",
10
+ "last_logged_in": "2022-03-06T18:34:07.405Z"
11
+ },
12
+ {
13
+ "username": "test123",
14
+ "password": "$2a$10$G/XayivKQZ6dYmHFJS25G.r/GtEiUIrUh/mJCPu/y7UA56czAogMa",
15
+ "id": "2aa34e59-bc71-4ba0-a6c6-67c565667d84",
16
+ "verification_token": "zk_ZAEWKI",
17
+ "created_at": "2022-03-06T18:17:44.052Z",
18
+ "last_updated": "2022-03-06T18:17:44.052Z"
19
+ }
20
+ ]
21
+ }
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@stanlemon/server-with-auth",
3
+ "version": "0.1.0",
4
+ "description": "A basic express web server setup with authentication baked in.",
5
+ "author": "Stan Lemon <stanlemon@users.noreply.github.com>",
6
+ "license": "MIT",
7
+ "engines": {
8
+ "node": ">=17.0"
9
+ },
10
+ "type": "module",
11
+ "main": "./src/index.js",
12
+ "exports": "./src/index.js",
13
+ "scripts": {
14
+ "start": "NODE_ENV=development nodemon --ignore db.json ./app.js",
15
+ "lint": "eslint --ext js,jsx,ts,tsx ./",
16
+ "lint:format": "eslint --fix --ext js,jsx,ts,tsx ./",
17
+ "test": "NODE_OPTIONS=--experimental-vm-modules jest --detectOpenHandles",
18
+ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --detectOpenHandles --watch"
19
+ },
20
+ "dependencies": {
21
+ "@stanlemon/server": "*",
22
+ "bcryptjs": "^2.4.3",
23
+ "jsonwebtoken": "^8.5.1",
24
+ "lowdb": "^3.0.0",
25
+ "passport": "^0.5.2",
26
+ "passport-jwt": "^4.0.0",
27
+ "shortid": "^2.2.8",
28
+ "uuid": "^8.3.2"
29
+ },
30
+ "devDependencies": {
31
+ "@stanlemon/eslint-config": "*",
32
+ "nodemon": "^2.0.15",
33
+ "supertest": "^6.2.2"
34
+ }
35
+ }
@@ -0,0 +1,16 @@
1
+ import passport from "passport";
2
+
3
+ export default function checkAuth() {
4
+ return [
5
+ passport.authenticate("jwt", { session: false }),
6
+ (req, res, next) => {
7
+ if (req.isAuthenticated()) {
8
+ next();
9
+ } else {
10
+ res
11
+ .status(401)
12
+ .json({ error: "You must be logged in to access this resource." });
13
+ }
14
+ },
15
+ ];
16
+ }
@@ -0,0 +1,99 @@
1
+ import dotenv from "dotenv";
2
+ import {
3
+ createAppServer as createBaseAppServer,
4
+ DEFAULTS as BASE_DEFAULTS,
5
+ } from "@stanlemon/server";
6
+ import passport from "passport";
7
+ import { Strategy as JwtStrategy, ExtractJwt } from "passport-jwt";
8
+ import defaultUserSchema from "./schema/user.js";
9
+ import checkAuth from "./checkAuth.js";
10
+ import auth from "./routes/auth.js";
11
+
12
+ dotenv.config();
13
+
14
+ // TODO: Add option for schema
15
+ export const DEFAULTS = {
16
+ ...BASE_DEFAULTS,
17
+ secure: [],
18
+ schema: defaultUserSchema,
19
+ getUserById: (userId) => {},
20
+ getUserByUsername: (username) => {},
21
+ getUserByUsernameAndPassword: (username, password) => {},
22
+ getUserByVerificationToken: (token) => {},
23
+ createUser: (user) => {},
24
+ updateUser: (userId, user) => {},
25
+ };
26
+
27
+ export default function createAppServer(options) {
28
+ const {
29
+ port,
30
+ webpack,
31
+ start,
32
+ secure,
33
+ schema,
34
+ getUserById,
35
+ getUserByUsername,
36
+ getUserByUsernameAndPassword,
37
+ getUserByVerificationToken,
38
+ createUser,
39
+ updateUser,
40
+ } = { ...DEFAULTS, ...options };
41
+
42
+ if (!process.env.JWT_SECRET) {
43
+ console.warn("You need to specify a secret.");
44
+ }
45
+
46
+ const secret = process.env.JWT_SECRET || "YouNeedASecret";
47
+
48
+ const app = createBaseAppServer({ port, webpack, start });
49
+
50
+ passport.use(
51
+ "jwt",
52
+ new JwtStrategy(
53
+ {
54
+ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
55
+ secretOrKey: secret,
56
+ // NOTE: Setting options like 'issuer' here must also be set when the token is signed below
57
+ jsonWebTokenOptions: {
58
+ expiresIn: "120m",
59
+ },
60
+ },
61
+ (payload, done) => {
62
+ done(null, payload);
63
+ }
64
+ )
65
+ );
66
+ passport.serializeUser((id, done) => {
67
+ done(null, id);
68
+ });
69
+ passport.deserializeUser((id, done) => {
70
+ getUserById(id)
71
+ .then((user) => {
72
+ // An undefined user means we couldn't find it, so the session is invalid
73
+ done(null, user === undefined ? false : user);
74
+ })
75
+ .catch((error) => {
76
+ done(error, null);
77
+ });
78
+ });
79
+ passport.initialize();
80
+
81
+ app.use(
82
+ auth({
83
+ secret,
84
+ schema,
85
+ getUserById,
86
+ getUserByUsername,
87
+ getUserByUsernameAndPassword,
88
+ getUserByVerificationToken,
89
+ createUser,
90
+ updateUser,
91
+ })
92
+ );
93
+
94
+ secure.forEach((path) => {
95
+ app.use(path, checkAuth());
96
+ });
97
+
98
+ return app;
99
+ }
@@ -0,0 +1,95 @@
1
+ import { Low, JSONFile } from "lowdb";
2
+ import { v4 as uuidv4 } from "uuid";
3
+ import shortid from "shortid";
4
+ import bcrypt from "bcryptjs";
5
+
6
+ export default class SimpleUsersDao {
7
+ constructor(seeds = [], adapter = new JSONFile("./db.json")) {
8
+ this.db = new Low(adapter);
9
+
10
+ this.db.read().then(() => {
11
+ this.db.data ||= { users: [] };
12
+
13
+ if (seeds.length > 0) {
14
+ seeds.forEach((user) => this.createUser(user));
15
+ }
16
+ });
17
+ }
18
+
19
+ getUserById = (userId) => {
20
+ return this.db.data.users
21
+ .filter((user) => {
22
+ // This one can get gross with numerical ids
23
+ // eslint-disable-next-line eqeqeq
24
+ return user.id == userId;
25
+ })
26
+ .shift();
27
+ };
28
+
29
+ getUserByUsername = (username) => {
30
+ return this.db.data.users
31
+ .filter((user) => {
32
+ return user.username === username;
33
+ })
34
+ .shift();
35
+ };
36
+
37
+ getUserByUsernameAndPassword = (username, password) => {
38
+ const user = this.getUserByUsername(username);
39
+
40
+ if (!bcrypt.compareSync(password, user.password)) {
41
+ return false;
42
+ }
43
+
44
+ return user;
45
+ };
46
+
47
+ getUserByVerificationToken = (token) => {
48
+ return this.db.data.users
49
+ .filter((user) => user.verification_token === token)
50
+ .shift();
51
+ };
52
+
53
+ createUser = async (user) => {
54
+ const existing = this.getUserByUsername(user.username);
55
+
56
+ if (existing) {
57
+ throw new Error("This username is already taken.");
58
+ }
59
+
60
+ const now = new Date();
61
+ const data = {
62
+ ...user,
63
+ password: bcrypt.hashSync(user.password, 10),
64
+ id: uuidv4(),
65
+ verification_token: shortid.generate(),
66
+ created_at: now,
67
+ last_updated: now,
68
+ };
69
+ this.db.data.users.push(data);
70
+ await this.db.write();
71
+ return data;
72
+ };
73
+
74
+ updateUser = async (userId, user) => {
75
+ const now = new Date();
76
+ this.db.data.users = this.db.data.users.map((u) => {
77
+ if (u.id === userId) {
78
+ // If the password has been set, encrypt it
79
+ if (user.password) {
80
+ user.password = bcrypt.hashSync(user.password, 10);
81
+ }
82
+
83
+ return {
84
+ ...u,
85
+ ...user,
86
+ id: userId,
87
+ last_updated: now,
88
+ };
89
+ }
90
+ return u;
91
+ });
92
+ await this.db.write();
93
+ return this.getUserById(userId);
94
+ };
95
+ }
package/src/index.js ADDED
@@ -0,0 +1,15 @@
1
+ export {
2
+ convertCase,
3
+ formatInput,
4
+ formatOutput,
5
+ asyncJsonHandler,
6
+ schemaHandler,
7
+ BadRequestException,
8
+ NotAuthorizedException,
9
+ NotFoundException,
10
+ AlreadyExistsException,
11
+ } from "@stanlemon/server";
12
+ export { default as checkAuth } from "./checkAuth.js";
13
+ export { default as createAppServer } from "./createAppServer.js";
14
+ export { default as schema } from "./schema/user.js";
15
+ export { default as SimpleUsersDao } from "./data/simple-users-dao.js";
@@ -0,0 +1,143 @@
1
+ import { isEmpty } from "lodash-es";
2
+ import { Router } from "express";
3
+ import passport from "passport";
4
+ import jwt from "jsonwebtoken";
5
+ import {
6
+ schemaHandler,
7
+ formatOutput,
8
+ BadRequestException,
9
+ } from "@stanlemon/server";
10
+
11
+ /* eslint-disable max-lines-per-function */
12
+ export default function authRoutes({
13
+ secret,
14
+ schema,
15
+ getUserById,
16
+ getUserByUsername,
17
+ getUserByUsernameAndPassword,
18
+ getUserByVerificationToken,
19
+ createUser,
20
+ updateUser,
21
+ }) {
22
+ const router = Router();
23
+
24
+ router.get("/auth/session", (req, res, next) => {
25
+ /* look at the 2nd parameter to the below call */
26
+ passport.authenticate("jwt", { session: false }, (err, userId) => {
27
+ if (err) {
28
+ return next(err);
29
+ }
30
+ if (!userId) {
31
+ return res.status(401).json({
32
+ token: false,
33
+ user: false,
34
+ });
35
+ }
36
+
37
+ req.logIn(userId, async (err) => {
38
+ if (err) {
39
+ return next(err);
40
+ }
41
+
42
+ const user = await getUserById(userId);
43
+
44
+ if (!user) {
45
+ return res.status(401).json({
46
+ token: false,
47
+ user: false,
48
+ });
49
+ } else {
50
+ const token = jwt.sign(user.id, secret);
51
+ res
52
+ .status(200)
53
+ .json({ token, user: formatOutput(user, ["password"]) });
54
+ }
55
+ });
56
+ })(req, res, next);
57
+ });
58
+
59
+ router.post("/auth/login", async (req, res) => {
60
+ const user = await getUserByUsernameAndPassword(
61
+ req.body.username,
62
+ req.body.password
63
+ );
64
+
65
+ if (!user) {
66
+ res.status(401).json({
67
+ message: "Incorrect username or password.",
68
+ });
69
+ return;
70
+ }
71
+
72
+ const update = await updateUser(user.id, {
73
+ last_logged_in: new Date(),
74
+ });
75
+
76
+ const token = jwt.sign(user.id, secret);
77
+
78
+ res.json({
79
+ token,
80
+ user: formatOutput(update, ["password"]),
81
+ });
82
+ });
83
+
84
+ router.get("/auth/logout", (req, res) => {
85
+ req.logout();
86
+
87
+ return res.status(401).json({
88
+ token: false,
89
+ user: false,
90
+ });
91
+ });
92
+
93
+ router.post(
94
+ "/auth/register",
95
+ schemaHandler(schema, async (data) => {
96
+ const existing = await getUserByUsername(data.username);
97
+
98
+ if (existing) {
99
+ throw new BadRequestException(
100
+ "A user with this username already exists"
101
+ );
102
+ }
103
+
104
+ const user = await createUser(data);
105
+
106
+ if (isEmpty(user)) {
107
+ return {
108
+ message: "An error has occurred",
109
+ };
110
+ }
111
+
112
+ // TODO: Add hook for handling verification notification
113
+ // user.verification_token
114
+
115
+ const token = jwt.sign(user.id, secret);
116
+ return { token, user: formatOutput(user, ["password"]) };
117
+ })
118
+ );
119
+
120
+ router.get("/auth/verify/:token", async (req, res) => {
121
+ const { token } = req.params;
122
+
123
+ const user = await getUserByVerificationToken(token);
124
+
125
+ if (isEmpty(user)) {
126
+ return res
127
+ .status(400)
128
+ .json({ success: false, message: "Cannot verify user." });
129
+ }
130
+
131
+ if (user.verified_date) {
132
+ return res
133
+ .status(400)
134
+ .send({ success: false, message: "User already verified." });
135
+ }
136
+
137
+ await updateUser(user.id, { verified_date: new Date() });
138
+
139
+ return res.send({ success: true, message: "User verified!" });
140
+ });
141
+
142
+ return router;
143
+ }
@@ -0,0 +1,142 @@
1
+ import request from "supertest";
2
+ import { Memory } from "lowdb";
3
+ import createAppServer from "../createAppServer";
4
+ import SimpleUsersDao from "../data/simple-users-dao.js";
5
+
6
+ let users = new SimpleUsersDao([], new Memory());
7
+
8
+ const app = createAppServer({ ...users, start: false });
9
+
10
+ describe("/auth", () => {
11
+ let userId;
12
+
13
+ beforeAll(async () => {
14
+ // Reset our users database before each test
15
+ const user = await users.createUser({
16
+ username: "test",
17
+ password: "test",
18
+ });
19
+
20
+ userId = user.id;
21
+ });
22
+
23
+ // Disabling this linting rule because it is unaware of the supertest assertions
24
+ /* eslint-disable jest/expect-expect */
25
+ it("POST /register creates a user", async () => {
26
+ const username = "test1";
27
+ const password = "p@$$w0rd!";
28
+
29
+ const data = {
30
+ username,
31
+ password,
32
+ };
33
+
34
+ await request(app)
35
+ .post("/auth/register")
36
+ .set("Content-Type", "application/json")
37
+ .set("Accept", "application/json")
38
+ .send(data)
39
+ .expect(200)
40
+ .then((res) => {
41
+ expect(res.body.errors).toBeUndefined();
42
+ expect(res.body.token).not.toBeUndefined();
43
+ expect(res.body.user).not.toBeUndefined();
44
+ });
45
+ });
46
+
47
+ it("POST /register returns error on empty data", async () => {
48
+ await request(app)
49
+ .post("/auth/register")
50
+ .set("Content-Type", "application/json")
51
+ .set("Accept", "application/json")
52
+ .expect(400)
53
+ .then((res) => {
54
+ expect(res.body.errors).not.toBe(undefined);
55
+ expect(res.body.errors.username).not.toBe(undefined);
56
+ });
57
+ });
58
+
59
+ it("POST /register returns error on short password", async () => {
60
+ await request(app)
61
+ .post("/auth/register")
62
+ .send({
63
+ username: "testshort",
64
+ password: "short",
65
+ })
66
+ .set("Content-Type", "application/json")
67
+ .set("Accept", "application/json")
68
+ .expect(400)
69
+ .then((res) => {
70
+ expect(res.body.errors).not.toBe(undefined);
71
+ expect(res.body.errors.password).not.toBe(undefined);
72
+ });
73
+ });
74
+
75
+ it("POST /register returns error on too long password", async () => {
76
+ await request(app)
77
+ .post("/auth/register")
78
+ .send({
79
+ username: "testlong",
80
+ password:
81
+ "waytolongpasswordtobeusedforthisapplicationyoushouldtrysomethingmuchmuchshorter",
82
+ })
83
+ .set("Content-Type", "application/json")
84
+ .set("Accept", "application/json")
85
+ .expect(400)
86
+ .then((res) => {
87
+ expect(res.body.errors).not.toBe(undefined);
88
+ expect(res.body.errors.password).not.toBe(undefined);
89
+ });
90
+ });
91
+
92
+ it("POST /register returns error on already taken username", async () => {
93
+ await request(app)
94
+ .post("/auth/register")
95
+ .send({
96
+ username: "test",
97
+ password: "p@$$w0rd!",
98
+ })
99
+ .set("Content-Type", "application/json")
100
+ .set("Accept", "application/json")
101
+ .expect(400)
102
+ .then((res) => {
103
+ expect(res.body).toEqual({
104
+ error: "Bad Request: A user with this username already exists",
105
+ });
106
+ });
107
+ });
108
+
109
+ it("GET /verify verifies user", async () => {
110
+ const user = users.getUserById(userId);
111
+
112
+ expect(user.verification_token).not.toBe(null);
113
+ expect(user.verified_date).toBeUndefined();
114
+
115
+ // First call will verify the user
116
+ await request(app)
117
+ .get("/auth/verify/" + user.verification_token)
118
+ .set("Content-Type", "application/json")
119
+ .set("Accept", "application/json")
120
+ .expect(200)
121
+ .then((res) => {
122
+ expect(res.body.success).toEqual(true);
123
+ });
124
+
125
+ const refresh = await users.getUserById(userId);
126
+
127
+ expect(refresh.verified_date).not.toBe(null);
128
+
129
+ // Subsequent calls are not successful because the user is already verified
130
+ await request(app)
131
+ .get("/auth/verify/" + user.verification_token)
132
+ .set("Content-Type", "application/json")
133
+ .set("Accept", "application/json")
134
+ .expect(400)
135
+ .then((res) => {
136
+ expect(res.body).toEqual({
137
+ success: false,
138
+ message: "User already verified.",
139
+ });
140
+ });
141
+ });
142
+ });
@@ -0,0 +1,8 @@
1
+ import Joi from "joi";
2
+
3
+ const schema = Joi.object({
4
+ username: Joi.string().required().label("Username"),
5
+ password: Joi.string().required().allow("").min(8).max(64).label("Password"),
6
+ });
7
+
8
+ export default schema;
package/test.http ADDED
@@ -0,0 +1,37 @@
1
+ GET http://localhost:3003/ HTTP/1.1
2
+ content-type: application/json
3
+
4
+ ###
5
+ POST http://localhost:3003/auth/register HTTP/1.1
6
+ Content-Type: application/json
7
+
8
+ {
9
+ "name": "Test Example",
10
+ "email": "example@example.com",
11
+ "username": "test123",
12
+ "password": "password"
13
+ }
14
+
15
+ ###
16
+
17
+ # @name login
18
+ POST http://localhost:3003/auth/login HTTP/1.1
19
+ Content-Type: application/json
20
+
21
+ {
22
+ "username": "user",
23
+ "password": "password"
24
+ }
25
+
26
+ ###
27
+
28
+ @authToken = {{login.response.body.token}}
29
+
30
+ # @name session
31
+ GET http://localhost:3003/auth/session
32
+ Authorization: Bearer {{authToken}}
33
+
34
+ ###
35
+
36
+ GET http://localhost:3003/api/users
37
+ Authorization: Bearer {{authToken}}