diff --git a/client/state/TokenStore.ts b/client/state/TokenStore.ts index ee16b0c..ff64232 100644 --- a/client/state/TokenStore.ts +++ b/client/state/TokenStore.ts @@ -1,13 +1,13 @@ import { action, observable } from "mobx"; import { Token } from "@client/state/Token"; -import { AccessToken, RefreshToken } from "@common/TokenClaims"; +import { AccessToken, BaseClaims, RefreshToken } from "@common/TokenClaims"; const LOCAL_STORAGE_KEY = "TokenStore"; export class TokenStore { - @observable accessToken: Token = new Token(); - @observable refreshToken: Token = new Token(); + @observable accessToken: Token = new Token(); + @observable refreshToken: Token = new Token(); @action clearAccessToken() { diff --git a/common/TokenClaims.ts b/common/TokenClaims.ts index d19e567..fb805c5 100644 --- a/common/TokenClaims.ts +++ b/common/TokenClaims.ts @@ -3,30 +3,32 @@ export interface BaseClaims { exp?: number; } -export interface AccessToken extends BaseClaims { +export interface AccessToken { type: "access"; aud: number; name: string; } -export interface RefreshToken extends BaseClaims { +export interface RefreshToken { type: "refresh"; aud: number; name: string; } -export interface DeviceRegistrationToken extends BaseClaims { +export interface DeviceRegistrationToken { type: "device_reg"; } -export interface DeviceToken extends BaseClaims { +export interface DeviceToken { type: "device"; aud: string; id: number; } -export interface SuperuserToken extends BaseClaims { +export interface SuperuserToken { type: "superuser"; } -export type TokenClaims = AccessToken | RefreshToken | DeviceRegistrationToken | DeviceToken | SuperuserToken; +export type TokenClaimTypes = AccessToken | RefreshToken | DeviceRegistrationToken | DeviceToken | SuperuserToken; + +export type TokenClaims = TokenClaimTypes & BaseClaims; diff --git a/server/authentication.ts b/server/authentication.ts new file mode 100644 index 0000000..f192f9a --- /dev/null +++ b/server/authentication.ts @@ -0,0 +1,111 @@ +import * as Express from "express"; +import Router from "express-promise-router"; +import * as jwt from "jsonwebtoken"; + +import ApiError from "@common/ApiError"; +import { ErrorCode } from "@common/ErrorCode"; +import { + TokenGrantPasswordRequest, + TokenGrantRefreshRequest, + TokenGrantRequest, + TokenGrantResponse, +} from "@common/httpApi"; +import * as tok from "@common/TokenClaims"; +import { User } from "@server/entities"; +import { ServerState } from "@server/state"; + +const JWT_SECRET = process.env.JWT_SECRET!; +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 + +function signToken(claims: tok.TokenClaimTypes, opts?: jwt.SignOptions): Promise { + const options: jwt.SignOptions = { + issuer: ISSUER, + ...opts, + }; + return new Promise((resolve, reject) => { + jwt.sign(claims, JWT_SECRET, options, (err: Error, encoded: string) => { + if (err) { + reject(err); + } else { + resolve(encoded); + } + }); + }); +} + +export function verifyToken( + token: string, type?: TClaims["type"], +): Promise { + return new Promise((resolve, reject) => { + 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)); + } else if (err.name === "JsonWebTokenError") { + reject(new ApiError("Invalid token", ErrorCode.BadToken, err)); + } else { + reject(err); + } + } else { + const claims: tok.TokenClaims = decoded as any; + if (type != null && claims.type !== type) { + reject(new ApiError(`Expected a "${type}" token, received a "${claims.type}" token`, + ErrorCode.BadToken)); + } + resolve(claims as TClaims & tok.BaseClaims); + } + }); + }); +} + +export function generateAccessToken(user: User): Promise { + const access_token_claims: tok.AccessToken = { + aud: user.id, + name: user.name, + type: "access", + }; + + return signToken(access_token_claims, { expiresIn: ACCESS_TOKEN_LIFETIME }); +} + +export function generateRefreshToken(user: User): Promise { + const refresh_token_claims: tok.RefreshToken = { + aud: user.id, + name: user.name, + type: "refresh", + }; + + return signToken(refresh_token_claims, { expiresIn: REFRESH_TOKEN_LIFETIME }); +} + +export function generateDeviceRegistrationToken(): Promise { + const device_reg_token_claims: tok.DeviceRegistrationToken = { + type: "device_reg", + }; + return signToken(device_reg_token_claims); +} + +export function generateDeviceToken(id: number, deviceId: string): Promise { + const device_token_claims: tok.DeviceToken = { + type: "device", + aud: deviceId, + id, + }; + return signToken(device_token_claims); +} + +export function generateSuperuserToken(): Promise { + const superuser_claims: tok.SuperuserToken = { + type: "superuser", + }; + return signToken(superuser_claims); +} diff --git a/server/express/api/devices.ts b/server/express/api/devices.ts index fcffd82..fbb6d7a 100644 --- a/server/express/api/devices.ts +++ b/server/express/api/devices.ts @@ -4,7 +4,8 @@ import { serialize} from "serializr"; import ApiError from "@common/ApiError"; import { ErrorCode } from "@common/ErrorCode"; import * as schema from "@common/sprinklersRpc/schema"; -import { generateDeviceToken, verifyAuthorization } from "@server/express/authentication"; +import { generateDeviceToken } from "@server/authentication"; +import { verifyAuthorization } from "@server/express/verifyAuthorization"; import { ServerState } from "@server/state"; const DEVICE_ID_LEN = 20; diff --git a/server/express/api/index.ts b/server/express/api/index.ts index 65a723a..7458a65 100644 --- a/server/express/api/index.ts +++ b/server/express/api/index.ts @@ -2,10 +2,11 @@ import PromiseRouter from "express-promise-router"; import ApiError from "@common/ApiError"; import { ErrorCode } from "@common/ErrorCode"; -import { authentication } from "@server/express/authentication"; import { ServerState } from "@server/state"; + import { devices } from "./devices"; import { mosquitto } from "./mosquitto"; +import { token } from "./token"; import { users } from "./users"; export default function createApi(state: ServerState) { @@ -14,7 +15,7 @@ export default function createApi(state: ServerState) { router.use("/devices", devices(state)); router.use("/users", users(state)); router.use("/mosquitto", mosquitto(state)); - router.use("/token", authentication(state)); + router.use("/token", token(state)); router.use("*", (req, res) => { throw new ApiError("API endpoint not found", ErrorCode.NotFound); diff --git a/server/express/api/mosquitto.ts b/server/express/api/mosquitto.ts index fd8332d..9adcaf3 100644 --- a/server/express/api/mosquitto.ts +++ b/server/express/api/mosquitto.ts @@ -4,7 +4,7 @@ import ApiError from "@common/ApiError"; import { ErrorCode } from "@common/ErrorCode"; import { DEVICE_PREFIX } from "@common/sprinklersRpc/mqtt"; import { DeviceToken, SuperuserToken } from "@common/TokenClaims"; -import { verifyToken } from "@server/express/authentication"; +import { verifyToken } from "@server/authentication"; import { ServerState } from "@server/state"; export const SUPERUSER = "sprinklers3"; diff --git a/server/express/api/token.ts b/server/express/api/token.ts new file mode 100644 index 0000000..294c76c --- /dev/null +++ b/server/express/api/token.ts @@ -0,0 +1,81 @@ +import PromiseRouter from "express-promise-router"; + +import ApiError from "@common/ApiError"; +import { ErrorCode } from "@common/ErrorCode"; +import * as httpApi from "@common/httpApi"; +import * as authentication from "@server/authentication"; +import { User } from "@server/entities"; +import { verifyAuthorization } from "@server/express/verifyAuthorization"; +import { ServerState } from "@server/state"; + +export function token(state: ServerState) { + const router = PromiseRouter(); + + async function passwordGrant(body: httpApi.TokenGrantPasswordRequest, res: Express.Response): Promise { + const { username, password } = body; + if (!body || !username || !password) { + throw new ApiError("Must specify username and password"); + } + const user = await state.database.users.findByUsername(username); + if (!user) { + throw new ApiError("User does not exist"); + } + const passwordMatches = await user.comparePassword(password); + if (passwordMatches) { + return user; + } else { + throw new ApiError("Invalid user credentials"); + } + } + + async function refreshGrant(body: httpApi.TokenGrantRefreshRequest, res: Express.Response): Promise { + const { refresh_token } = body; + if (!body || !refresh_token) { + throw new ApiError("Must specify a refresh_token", ErrorCode.BadToken); + } + const claims = await authentication.verifyToken(refresh_token); + if (claims.type !== "refresh") { + throw new ApiError("Not a refresh token", ErrorCode.BadToken); + } + const user = await state.database.users.findOne(claims.aud); + if (!user) { + throw new ApiError("User no longer exists", ErrorCode.BadToken); + } + return user; + } + + router.post("/grant", async (req, res) => { + const body: httpApi.TokenGrantRequest = req.body; + let user: User; + if (body.grant_type === "password") { + user = await passwordGrant(body, res); + } else if (body.grant_type === "refresh") { + user = await refreshGrant(body, res); + } else { + throw new ApiError("Invalid grant_type"); + } + const [access_token, refresh_token] = await Promise.all([ + await authentication.generateAccessToken(user), + await authentication.generateRefreshToken(user), + ]); + const response: httpApi.TokenGrantResponse = { + access_token, refresh_token, + }; + res.json(response); + }); + + router.post("/grant_device_reg", verifyAuthorization(), async (req, res) => { + // tslint:disable-next-line:no-shadowed-variable + const token = await authentication.generateDeviceRegistrationToken(); + res.json({ token }); + }); + + router.post("/verify", verifyAuthorization(), async (req, res) => { + res.json({ + ok: true, + token: req.token, + }); + }); + + return router; +} diff --git a/server/express/api/users.ts b/server/express/api/users.ts index 62dc06e..36d0ae6 100644 --- a/server/express/api/users.ts +++ b/server/express/api/users.ts @@ -3,7 +3,7 @@ import PromiseRouter from "express-promise-router"; import ApiError from "@common/ApiError"; import { ErrorCode } from "@common/ErrorCode"; import { User } from "@server/entities"; -import { verifyAuthorization } from "@server/express/authentication"; +import { verifyAuthorization } from "@server/express/verifyAuthorization"; import { ServerState } from "@server/state"; export function users(state: ServerState) { diff --git a/server/express/authentication.ts b/server/express/authentication.ts deleted file mode 100644 index f8acd0f..0000000 --- a/server/express/authentication.ts +++ /dev/null @@ -1,227 +0,0 @@ -import * as Express from "express"; -import Router from "express-promise-router"; -import * as jwt from "jsonwebtoken"; - -import ApiError from "@common/ApiError"; -import { ErrorCode } from "@common/ErrorCode"; -import { - TokenGrantPasswordRequest, - TokenGrantRefreshRequest, - TokenGrantRequest, - TokenGrantResponse, -} from "@common/httpApi"; -import * as tok from "@common/TokenClaims"; -import { User } from "../entities"; -import { ServerState } from "../state"; - -declare global { - namespace Express { - interface Request { - token?: tok.AccessToken; - } - } -} - -const JWT_SECRET = process.env.JWT_SECRET!; -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 - -/** - * @param {number} lifetime in seconds - */ -function getExpTime(lifetime: number) { - return Math.floor(Date.now() / 1000) + lifetime; -} - -function signToken(claims: tok.TokenClaims): Promise { - return new Promise((resolve, reject) => { - jwt.sign(claims, JWT_SECRET, (err: Error, encoded: string) => { - if (err) { - reject(err); - } else { - resolve(encoded); - } - }); - }); -} - -export function verifyToken( - token: string, type?: TClaims["type"], -): Promise { - return new Promise((resolve, reject) => { - 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)); - } else if (err.name === "JsonWebTokenError") { - reject(new ApiError("Invalid token", ErrorCode.BadToken, err)); - } else { - reject(err); - } - } else { - const claims: tok.TokenClaims = decoded as any; - if (type != null && claims.type !== type) { - reject(new ApiError(`Expected a "${type}" token, received a "${claims.type}" token`, - ErrorCode.BadToken)); - } - resolve(claims as TClaims); - } - }); - }); -} - -function generateAccessToken(user: User, secret: string): Promise { - const access_token_claims: tok.AccessToken = { - iss: ISSUER, - aud: user.id, - name: user.name, - type: "access", - exp: getExpTime(ACCESS_TOKEN_LIFETIME), - }; - - return signToken(access_token_claims); -} - -function generateRefreshToken(user: User, secret: string): Promise { - const refresh_token_claims: tok.RefreshToken = { - iss: ISSUER, - aud: user.id, - name: user.name, - type: "refresh", - exp: getExpTime(REFRESH_TOKEN_LIFETIME), - }; - - return signToken(refresh_token_claims); -} - -function generateDeviceRegistrationToken(secret: string): Promise { - const device_reg_token_claims: tok.DeviceRegistrationToken = { - iss: ISSUER, - type: "device_reg", - }; - return signToken(device_reg_token_claims); -} - -export function generateDeviceToken(id: number, deviceId: string): Promise { - const device_token_claims: tok.DeviceToken = { - iss: ISSUER, - type: "device", - aud: deviceId, - id, - }; - return signToken(device_token_claims); -} - -export function generateSuperuserToken(): Promise { - const superuser_claims: tok.SuperuserToken = { - iss: ISSUER, - type: "superuser", - }; - return signToken(superuser_claims); -} - -export function authentication(state: ServerState) { - - const router = Router(); - - async function passwordGrant(body: TokenGrantPasswordRequest, res: Express.Response): Promise { - const { username, password } = body; - if (!body || !username || !password) { - throw new ApiError("Must specify username and password"); - } - const user = await state.database.users.findByUsername(username); - if (!user) { - throw new ApiError("User does not exist"); - } - const passwordMatches = await user.comparePassword(password); - if (passwordMatches) { - return user; - } else { - throw new ApiError("Invalid user credentials"); - } - } - - async function refreshGrant(body: TokenGrantRefreshRequest, res: Express.Response): Promise { - const { refresh_token } = body; - if (!body || !refresh_token) { - throw new ApiError("Must specify a refresh_token", ErrorCode.BadToken); - } - const claims = await verifyToken(refresh_token); - if (claims.type !== "refresh") { - throw new ApiError("Not a refresh token", ErrorCode.BadToken); - } - const user = await state.database.users.findOne(claims.aud); - if (!user) { - throw new ApiError("User no longer exists", ErrorCode.BadToken); - } - return user; - } - - router.post("/grant", async (req, res) => { - const body: TokenGrantRequest = req.body; - let user: User; - if (body.grant_type === "password") { - user = await passwordGrant(body, res); - } else if (body.grant_type === "refresh") { - user = await refreshGrant(body, res); - } else { - throw new ApiError("Invalid grant_type"); - } - const [access_token, refresh_token] = await Promise.all( - [await generateAccessToken(user, JWT_SECRET), - await generateRefreshToken(user, JWT_SECRET)]); - const response: TokenGrantResponse = { - access_token, refresh_token, - }; - res.json(response); - }); - - router.post("/grant_device_reg", verifyAuthorization(), async (req, res) => { - const token = await generateDeviceRegistrationToken(JWT_SECRET); - res.json({ token }); - }); - - router.post("/verify", verifyAuthorization(), async (req, res) => { - res.json({ - ok: true, - token: req.token, - }); - }); - - return router; -} - -export interface VerifyAuthorizationOpts { - type: tok.TokenClaims["type"]; -} - -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, opts.type) as any; - }; - fun().then(() => next(null), (err) => next(err)); - }; -} diff --git a/server/express/verifyAuthorization.ts b/server/express/verifyAuthorization.ts new file mode 100644 index 0000000..7b7173b --- /dev/null +++ b/server/express/verifyAuthorization.ts @@ -0,0 +1,41 @@ +import * as Express from "express"; + +import ApiError from "@common/ApiError"; +import { ErrorCode } from "@common/ErrorCode"; +import * as tok from "@common/TokenClaims"; +import { verifyToken } from "@server/authentication"; + +declare global { + namespace Express { + interface Request { + token?: tok.AccessToken; + } + } +} + +export interface VerifyAuthorizationOpts { + type: tok.TokenClaims["type"]; +} + +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, opts.type) as any; + }; + fun().then(() => next(null), (err) => next(err)); + }; +} diff --git a/server/sprinklersRpc/WebSocketConnection.ts b/server/sprinklersRpc/WebSocketConnection.ts index 5897332..307e30a 100644 --- a/server/sprinklersRpc/WebSocketConnection.ts +++ b/server/sprinklersRpc/WebSocketConnection.ts @@ -10,8 +10,8 @@ import * as deviceRequests from "@common/sprinklersRpc/deviceRequests"; import * as schema from "@common/sprinklersRpc/schema"; import * as ws from "@common/sprinklersRpc/websocketData"; import { AccessToken } from "@common/TokenClaims"; +import { verifyToken } from "@server/authentication"; import { User } from "@server/entities"; -import { verifyToken } from "@server/express/authentication"; import { WebSocketApi } from "./WebSocketApi"; diff --git a/server/state.ts b/server/state.ts index 6439166..8a2b6ee 100644 --- a/server/state.ts +++ b/server/state.ts @@ -1,7 +1,7 @@ import logger from "@common/logger"; import * as mqtt from "@common/sprinklersRpc/mqtt"; +import { generateSuperuserToken } from "@server/authentication"; import { SUPERUSER } from "@server/express/api/mosquitto"; -import { generateSuperuserToken } from "@server/express/authentication"; import { Database } from "./Database"; export class ServerState {