From dbb5c37efe3ac53f00042cf26398ad44f6f9f32f Mon Sep 17 00:00:00 2001 From: Alex Mikhalev Date: Thu, 28 Jun 2018 17:25:08 -0600 Subject: [PATCH] Some great token routes and stuff --- package.json | 3 + server/express/authentication.ts | 170 +++++++++++++++++++++++++++++++ server/express/errors.ts | 11 ++ server/express/index.ts | 22 +--- server/models/User.ts | 10 ++ yarn.lock | 87 +++++++++++++++- 6 files changed, 282 insertions(+), 21 deletions(-) create mode 100644 server/express/authentication.ts create mode 100644 server/express/errors.ts diff --git a/package.json b/package.json index 43e6388..dccdbcd 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,9 @@ "chalk": "^2.4.1", "express": "^4.16.3", "express-pino-logger": "^3.0.2", + "express-promise-router": "^3.0.2", "fork-ts-checker-webpack-plugin": "^0.4.2", + "jsonwebtoken": "^8.3.0", "mobx": "^5.0.3", "module-alias": "^2.1.0", "moment": "^2.22.2", @@ -57,6 +59,7 @@ "@types/classnames": "^2.2.4", "@types/core-js": "^2.5.0", "@types/express": "^4.16.0", + "@types/jsonwebtoken": "^7.2.7", "@types/lodash": "^4.14.110", "@types/lodash-es": "^4.17.0", "@types/node": "^10.3.5", diff --git a/server/express/authentication.ts b/server/express/authentication.ts new file mode 100644 index 0000000..03d0e61 --- /dev/null +++ b/server/express/authentication.ts @@ -0,0 +1,170 @@ +import log from "@common/logger"; +import * as Express from "express"; +import Router from "express-promise-router"; +import * as jwt from "jsonwebtoken"; +import { User } from "../models/User"; +import { ServerState } from "../state"; +import { ApiError } from "./errors"; + +const ACCESS_TOKEN_LIFETIME = (30 * 60); // 30 minutes +const REFRESH_TOKEN_LIFETIME = (24 * 60 * 60); // 24 hours + +/** + * @param {number} lifetime in seconds + */ +function getExpTime(lifetime: number) { + return Math.floor(Date.now() / 1000) + lifetime; +} + +interface TokenClaims { + iss: string; + type: "access" | "refresh"; + aud: string; + name: string; + exp: number; +} + +function signToken(claims: TokenClaims, secret: string): Promise { + return new Promise((resolve, reject) => { + jwt.sign(claims, secret, (err: Error, encoded: string) => { + if (err) { + reject(err); + } else { + resolve(encoded); + } + }); + }); +} + +function verifyToken(token: string, secret: string): Promise { + return new Promise((resolve, reject) => { + jwt.verify(token, secret, (err, decoded) => { + if (err) { + if (err.name === "TokenExpiredError") { + reject(new ApiError(401, "The specified token is expired", err)); + } else if (err.name === "JsonWebTokenError") { + reject(new ApiError(400, "Invalid token", err)); + } else { + reject(err); + } + } else { + resolve(decoded as any); + } + }); + }); +} + +function generateAccessToken(user: User, secret: string): Promise { + const access_token_claims: TokenClaims = { + iss: "sprinklers3", + aud: user.id || "", + name: user.name, + type: "access", + exp: getExpTime(ACCESS_TOKEN_LIFETIME), + }; + + return signToken(access_token_claims, secret); +} + +function generateRefreshToken(user: User, secret: string): Promise { + const refresh_token_claims: TokenClaims = { + iss: "sprinklers3", + aud: user.id || "", + name: user.name, + type: "refresh", + exp: getExpTime(REFRESH_TOKEN_LIFETIME), + }; + + return signToken(refresh_token_claims, secret); +} + +export function authentication(state: ServerState) { + const JWT_SECRET = process.env.JWT_SECRET!; + if (!JWT_SECRET) { + throw new Error("Must specify JWT_SECRET environment variable"); + } + + const router = Router(); + + async function passwordGrant(req: Express.Request, res: Express.Response) { + const { body } = req; + const { username, password } = body; + if (!body || !username || !password) { + throw new ApiError(400, "Must specify username and password"); + } + const user = await User.loadByUsername(state.database, username); + if (!user) { + throw new ApiError(401, "User does not exist"); + } + const passwordMatches = user.comparePassword(password); + if (passwordMatches) { + const [access_token, refresh_token] = await Promise.all( + [await generateAccessToken(user, JWT_SECRET), + await generateRefreshToken(user, JWT_SECRET)]); + res.json({ + access_token, refresh_token, + }); + } else { + res.status(400) + .json({ + message: "incorrect login", + }); + } + } + + async function refreshGrant(req: Express.Request, res: Express.Response) { + const { body } = req; + const { refresh_token } = body; + if (!body || !refresh_token) { + throw new ApiError(400, "Must specify a refresh_token"); + } + const claims = await verifyToken(refresh_token, JWT_SECRET); + if (claims.type !== "refresh") { + throw new ApiError(400, "Not a refresh token"); + } + const user = await User.load(state.database, claims.aud); + if (!user) { + throw new ApiError(400, "User does not exist"); + } + const [access_token, new_refresh_token] = await Promise.all( + [await generateAccessToken(user, JWT_SECRET), + await generateRefreshToken(user, JWT_SECRET)]); + res.json({ + access_token, refresh_token: new_refresh_token, + }); + } + + router.post("/token/grant", async (req, res) => { + const { body } = req; + const { grant_type } = body; + if (grant_type === "password") { + await passwordGrant(req, res); + } else if (grant_type === "refresh") { + await refreshGrant(req, res); + } else { + throw new ApiError(400, "Invalid grant_type"); + } + }); + + router.post("/token/verify", async (req, res) => { + const bearer = req.headers.authorization; + if (!bearer) { + throw new ApiError(401, "No bearer token specified"); + } + const matches = /^Bearer (.*)$/.exec(bearer); + if (!matches || !matches[1]) { + throw new ApiError(400, "Invalid bearer token specified"); + } + const token = matches[1]; + + log.info({ token }); + + const decoded = await verifyToken(token, JWT_SECRET); + res.json({ + ok: true, + decoded, + }); + }); + + return router; +} diff --git a/server/express/errors.ts b/server/express/errors.ts new file mode 100644 index 0000000..541d4a7 --- /dev/null +++ b/server/express/errors.ts @@ -0,0 +1,11 @@ +export class ApiError extends Error { + name = "ApiError"; + statusCode: number; + cause?: Error; + + constructor(statusCode: number, message: string, cause?: Error) { + super(message); + this.statusCode = statusCode; + this.cause = cause; + } +} diff --git a/server/express/index.ts b/server/express/index.ts index 8a26010..132e4a3 100644 --- a/server/express/index.ts +++ b/server/express/index.ts @@ -7,9 +7,8 @@ import { ServerState } from "../state"; import logger from "./logger"; import serveApp from "./serveApp"; -import log from "@common/logger"; - import { User } from "../models/User"; +import { authentication } from "./authentication"; export function createApp(state: ServerState) { const app = express(); @@ -41,24 +40,7 @@ export function createApp(state: ServerState) { .catch(next); }); - app.post("/api/authenticate", (req, res, next) => { - const body = req.body; - log.info({body}, "/api/authenticate: " + req.body); - if (!body || !body.username || !body.password) { - return next(new Error("must specify username and password")); - } - User.loadByUsername(state.database, body.username) - .then((user) => { - if (!user) { - throw new Error("user does not exist"); - } - return user.comparePassword(body.password); - }).then((passwordMatches) => { - res.json({ - passwordMatches, - }); - }).catch(next); - }); + app.use("/api", authentication(state)); serveApp(app); diff --git a/server/models/User.ts b/server/models/User.ts index ec9db21..00d6ce3 100644 --- a/server/models/User.ts +++ b/server/models/User.ts @@ -51,6 +51,16 @@ export class User implements IUser { }); } + static async load(db: Database, id: string): Promise { + const data = await db.db.table(User.tableName) + .get(id) + .run(db.conn); + if (data == null) { + return null; + } + return new User(db, data); + } + static async loadByUsername(db: Database, username: string): Promise { const seq = await db.db.table(User.tableName) .filter(r.row("username").eq(username)) diff --git a/yarn.lock b/yarn.lock index fc5ccc5..9cd880e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -76,6 +76,12 @@ version "4.6.2" resolved "https://registry.yarnpkg.com/@types/history/-/history-4.6.2.tgz#12cfaba693ba20f114ed5765467ff25fdf67ddb0" +"@types/jsonwebtoken@^7.2.7": + version "7.2.7" + resolved "https://registry.yarnpkg.com/@types/jsonwebtoken/-/jsonwebtoken-7.2.7.tgz#5dd62e0c0a0c6f211c3c1d13d322360894625b47" + dependencies: + "@types/node" "*" + "@types/lodash-es@^4.17.0": version "4.17.0" resolved "https://registry.yarnpkg.com/@types/lodash-es/-/lodash-es-4.17.0.tgz#ed9044d62ee36a93e0650b112701986b1c74c766" @@ -884,6 +890,10 @@ browserslist@^3.2.8: caniuse-lite "^1.0.30000844" electron-to-chromium "^1.3.47" +buffer-equal-constant-time@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819" + buffer-from@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.0.tgz#87fcaa3a298358e0ade6e442cfce840740d1ad04" @@ -1853,6 +1863,12 @@ ecc-jsbn@~0.1.1: dependencies: jsbn "~0.1.0" +ecdsa-sig-formatter@1.0.10: + version "1.0.10" + resolved "https://registry.yarnpkg.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.10.tgz#1c595000f04a8897dfb85000892a0f4c33af86c3" + dependencies: + safe-buffer "^5.0.1" + ee-first@1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" @@ -2121,6 +2137,14 @@ express-pino-logger@^3.0.2: dependencies: pino-http "^3.0.1" +express-promise-router@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/express-promise-router/-/express-promise-router-3.0.2.tgz#2cf0dde8d903605071b52278a6dd1ae0ff0093e2" + dependencies: + is-promise "^2.1.0" + lodash.flattendeep "^4.0.0" + methods "^1.0.0" + express@^4.16.2, express@^4.16.3: version "4.16.3" resolved "https://registry.yarnpkg.com/express/-/express-4.16.3.tgz#6af8a502350db3246ecc4becf6b5a34d22f7ed53" @@ -3563,6 +3587,20 @@ jsonpointer@^4.0.0: version "4.0.1" resolved "https://registry.yarnpkg.com/jsonpointer/-/jsonpointer-4.0.1.tgz#4fd92cb34e0e9db3c89c8622ecf51f9b978c6cb9" +jsonwebtoken@^8.3.0: + version "8.3.0" + resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-8.3.0.tgz#056c90eee9a65ed6e6c72ddb0a1d325109aaf643" + dependencies: + jws "^3.1.5" + lodash.includes "^4.3.0" + lodash.isboolean "^3.0.3" + lodash.isinteger "^4.0.4" + lodash.isnumber "^3.0.3" + lodash.isplainobject "^4.0.6" + lodash.isstring "^4.0.1" + lodash.once "^4.0.0" + ms "^2.1.1" + jsprim@^1.2.2: version "1.4.1" resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-1.4.1.tgz#313e66bc1e5cc06e438bc1b7499c2e5c56acb6a2" @@ -3572,6 +3610,21 @@ jsprim@^1.2.2: json-schema "0.2.3" verror "1.10.0" +jwa@^1.1.5: + version "1.1.6" + resolved "https://registry.yarnpkg.com/jwa/-/jwa-1.1.6.tgz#87240e76c9808dbde18783cf2264ef4929ee50e6" + dependencies: + buffer-equal-constant-time "1.0.1" + ecdsa-sig-formatter "1.0.10" + safe-buffer "^5.0.1" + +jws@^3.1.5: + version "3.1.5" + resolved "https://registry.yarnpkg.com/jws/-/jws-3.1.5.tgz#80d12d05b293d1e841e7cb8b4e69e561adcf834f" + dependencies: + jwa "^1.1.5" + safe-buffer "^5.0.1" + keyboard-key@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/keyboard-key/-/keyboard-key-1.0.1.tgz#a946294fe59ad5431c63a3ea269f023e51fac6aa" @@ -3699,10 +3752,34 @@ lodash.endswith@^4.2.1: version "4.2.1" resolved "https://registry.yarnpkg.com/lodash.endswith/-/lodash.endswith-4.2.1.tgz#fed59ac1738ed3e236edd7064ec456448b37bc09" +lodash.flattendeep@^4.0.0: + version "4.4.0" + resolved "https://registry.yarnpkg.com/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz#fb030917f86a3134e5bc9bec0d69e0013ddfedb2" + +lodash.includes@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f" + +lodash.isboolean@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz#6c2e171db2a257cd96802fd43b01b20d5f5870f6" + lodash.isfunction@^3.0.8: version "3.0.9" resolved "https://registry.yarnpkg.com/lodash.isfunction/-/lodash.isfunction-3.0.9.tgz#06de25df4db327ac931981d1bdb067e5af68d051" +lodash.isinteger@^4.0.4: + version "4.0.4" + resolved "https://registry.yarnpkg.com/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz#619c0af3d03f8b04c31f5882840b77b11cd68343" + +lodash.isnumber@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz#3ce76810c5928d03352301ac287317f11c0b1ffc" + +lodash.isplainobject@^4.0.6: + version "4.0.6" + resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb" + lodash.isstring@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/lodash.isstring/-/lodash.isstring-4.0.1.tgz#d527dfb5456eca7cc9bb95d5daeaf88ba54a5451" @@ -3715,6 +3792,10 @@ lodash.mergewith@^4.6.0: version "4.6.1" resolved "https://registry.yarnpkg.com/lodash.mergewith/-/lodash.mergewith-4.6.1.tgz#639057e726c3afbdb3e7d42741caa8d6e4335927" +lodash.once@^4.0.0: + version "4.1.1" + resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac" + lodash.startswith@^4.2.1: version "4.2.1" resolved "https://registry.yarnpkg.com/lodash.startswith/-/lodash.startswith-4.2.1.tgz#c598c4adce188a27e53145731cdc6c0e7177600c" @@ -3880,7 +3961,7 @@ merge-descriptors@1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61" -methods@~1.1.2: +methods@^1.0.0, methods@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" @@ -4119,6 +4200,10 @@ ms@2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" +ms@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.1.tgz#30a5864eb3ebb0a66f2ebe6d727af06a09d86e0a" + multicast-dns-service-types@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/multicast-dns-service-types/-/multicast-dns-service-types-1.1.0.tgz#899f11d9686e5e05cb91b35d5f0e63b773cfc901"