From 4dd28098bfe54676b273e3354a6c065a3b0f7472 Mon Sep 17 00:00:00 2001 From: Alex Mikhalev Date: Fri, 10 Aug 2018 16:09:23 +0300 Subject: [PATCH] Cleanup and refactoring of authentication and server api --- common/TokenClaims.ts | 13 ++++- server/express/api.ts | 52 +++++++++++++++++++ server/express/authentication.ts | 67 +++++++++++++++++++------ server/express/index.ts | 41 ++------------- server/sprinklersRpc/websocketServer.ts | 3 ++ 5 files changed, 120 insertions(+), 56 deletions(-) create mode 100644 server/express/api.ts diff --git a/common/TokenClaims.ts b/common/TokenClaims.ts index b956358..2fe76ec 100644 --- a/common/TokenClaims.ts +++ b/common/TokenClaims.ts @@ -1,7 +1,16 @@ -export default interface TokenClaims { +export interface BaseClaims { iss: string; + exp?: number; +} + +export interface AccessOrRefreshToken extends BaseClaims { type: "access" | "refresh"; aud: number; name: string; - exp: number; } + +export interface DeviceRegistrationToken extends BaseClaims { + type: "device_reg"; +} + +export type TokenClaims = AccessOrRefreshToken | DeviceRegistrationToken; diff --git a/server/express/api.ts b/server/express/api.ts new file mode 100644 index 0000000..e2f96d4 --- /dev/null +++ b/server/express/api.ts @@ -0,0 +1,52 @@ +import PromiseRouter from "express-promise-router"; +import { serialize} from "serializr"; + +import ApiError from "@common/ApiError"; +import { ErrorCode } from "@common/ErrorCode"; +import * as schema from "@common/sprinklersRpc/schema"; +import { ServerState } from "../state"; +import { authentication, verifyAuthorization } from "./authentication"; + +export default function createApi(state: ServerState) { + const router = PromiseRouter(); + + router.get("/devices/:deviceId", verifyAuthorization(), (req, res) => { + // TODO: authorize device + const device = state.mqttClient.getDevice(req.params.deviceId); + const j = serialize(schema.sprinklersDevice, device); + res.send(j); + }); + + // router.post("/devices/register", verifyAuthorization({ + // type: "device_reg", + // }), (req, res) => { + // res.json({ data: "device registered" }); + // }); + + router.get("/users", verifyAuthorization(), (req, res) => { + state.database.users.find() + .then((users) => { + res.json({ + data: users, + }); + }); + }); + + router.get("/api/users/:username", (req, res, next) => { + const { username } = req.params; + state.database.users.findByUsername(username) + .then((user) => { + if (!user) { + throw new ApiError(`user ${username} does not exist`, ErrorCode.NotFound); + } + res.json({ + data: user, + }); + }) + .catch(next); + }); + + router.use("/", authentication(state)); + + return router; +} diff --git a/server/express/authentication.ts b/server/express/authentication.ts index 0c25694..5d606c4 100644 --- a/server/express/authentication.ts +++ b/server/express/authentication.ts @@ -10,7 +10,7 @@ import { TokenGrantRequest, TokenGrantResponse, } from "@common/httpApi"; -import TokenClaims from "@common/TokenClaims"; +import { TokenClaims } from "@common/TokenClaims"; import { User } from "../entities"; import { ServerState } from "../state"; @@ -29,6 +29,8 @@ if (!JWT_SECRET) { throw new Error("Must specify JWT_SECRET environment variable"); } +const ISSUER = "sprinklers3"; + const ACCESS_TOKEN_LIFETIME = (30 * 60); // 30 minutes const REFRESH_TOKEN_LIFETIME = (24 * 60 * 60); // 24 hours @@ -53,7 +55,9 @@ function signToken(claims: TokenClaims): Promise { export function verifyToken(token: string): Promise { return new Promise((resolve, reject) => { - jwt.verify(token, JWT_SECRET, (err, decoded) => { + jwt.verify(token, JWT_SECRET, { + issuer: ISSUER, + }, (err, decoded) => { if (err) { if (err.name === "TokenExpiredError") { reject(new ApiError("The specified token is expired", ErrorCode.BadToken, err)); @@ -71,7 +75,7 @@ export function verifyToken(token: string): Promise { function generateAccessToken(user: User, secret: string): Promise { const access_token_claims: TokenClaims = { - iss: "sprinklers3", + iss: ISSUER, aud: user.id, name: user.name, type: "access", @@ -83,7 +87,7 @@ function generateAccessToken(user: User, secret: string): Promise { function generateRefreshToken(user: User, secret: string): Promise { const refresh_token_claims: TokenClaims = { - iss: "sprinklers3", + iss: ISSUER, aud: user.id, name: user.name, type: "refresh", @@ -93,6 +97,14 @@ function generateRefreshToken(user: User, secret: string): Promise { return signToken(refresh_token_claims); } +function generateDeviceRegistrationToken(secret: string): Promise { + const device_reg_token_claims: TokenClaims = { + iss: ISSUER, + type: "device_reg", + }; + return signToken(device_reg_token_claims); +} + export function authentication(state: ServerState) { const router = Router(); @@ -149,7 +161,12 @@ export function authentication(state: ServerState) { res.json(response); }); - router.post("/token/verify", authorizeAccess, async (req, res) => { + router.post("/token/grant_device_reg", verifyAuthorization(), async (req, res) => { + const token = await generateDeviceRegistrationToken(JWT_SECRET); + res.json({ token }); + }); + + router.post("/token/verify", verifyAuthorization(), async (req, res) => { res.json({ ok: true, token: req.token, @@ -159,16 +176,34 @@ export function authentication(state: ServerState) { return router; } -export async function authorizeAccess(req: Express.Request, res: Express.Response) { - const bearer = req.headers.authorization; - if (!bearer) { - throw new ApiError("No Authorization header specified", ErrorCode.BadToken); - } - const matches = /^Bearer (.*)$/.exec(bearer); - if (!matches || !matches[1]) { - throw new ApiError("Invalid Authorization header, must be Bearer", ErrorCode.BadToken); - } - const token = matches[1]; +export interface VerifyAuthorizationOpts { + type: TokenClaims["type"]; +} - req.token = await verifyToken(token); +export function verifyAuthorization(options?: Partial): Express.RequestHandler { + const opts: VerifyAuthorizationOpts = { + type: "access", + ...options, + }; + return (req, res, next) => { + const fun = async () => { + const bearer = req.headers.authorization; + if (!bearer) { + throw new ApiError("No Authorization header specified", ErrorCode.BadToken); + } + const matches = /^Bearer (.*)$/.exec(bearer); + if (!matches || !matches[1]) { + throw new ApiError("Invalid Authorization header, must be Bearer", ErrorCode.BadToken); + } + const token = matches[1]; + + req.token = await verifyToken(token); + + if (req.token.type !== opts.type) { + throw new ApiError(`Invalid token type "${req.token.type}", must be "${opts.type}"`, + ErrorCode.BadToken); + } + }; + fun().then(() => next(null), (err) => next(err)); + }; } diff --git a/server/express/index.ts b/server/express/index.ts index 726811c..9d2e4fc 100644 --- a/server/express/index.ts +++ b/server/express/index.ts @@ -1,54 +1,19 @@ import * as bodyParser from "body-parser"; import * as express from "express"; -import { serialize} from "serializr"; -import * as schema from "@common/sprinklersRpc/schema"; import { ServerState } from "../state"; +import createApi from "./api"; +import errorHandler from "./errorHandler"; import requestLogger from "./requestLogger"; import serveApp from "./serveApp"; -import ApiError from "@common/ApiError"; -import { ErrorCode } from "@common/ErrorCode"; -import { authentication } from "./authentication"; -import errorHandler from "./errorHandler"; - export function createApp(state: ServerState) { const app = express(); app.use(requestLogger); app.use(bodyParser.json()); - app.get("/api/devices/:deviceId", (req, res) => { - // TODO: authorize device - const device = state.mqttClient.getDevice(req.params.deviceId); - const j = serialize(schema.sprinklersDevice, device); - res.send(j); - }); - - app.get("/api/users", (req, res) => { - state.database.users.find() - .then((users) => { - res.json({ - data: users, - }); - }); - }); - - app.get("/api/users/:username", (req, res, next) => { - const { username } = req.params; - state.database.users.findByUsername(username) - .then((user) => { - if (!user) { - throw new ApiError(`user ${username} does not exist`, ErrorCode.NotFound); - } - res.json({ - data: user, - }); - }) - .catch(next); - }); - - app.use("/api", authentication(state)); + app.use("/api", createApi(state)); serveApp(app); diff --git a/server/sprinklersRpc/websocketServer.ts b/server/sprinklersRpc/websocketServer.ts index 70068fa..9df2ac8 100644 --- a/server/sprinklersRpc/websocketServer.ts +++ b/server/sprinklersRpc/websocketServer.ts @@ -69,6 +69,9 @@ export class WebSocketClient { } catch (e) { throw new ws.RpcError("invalid token", ErrorCode.BadToken, e); } + if (decoded.type !== "access") { + throw new ws.RpcError("not an access token", ErrorCode.BadToken); + } this.userId = decoded.aud; log.info({ userId: decoded.aud, name: decoded.name }, "authenticated websocket client"); this.subscribeBrokerConnection();