Browse Source

Improvements to error handling and authentication

update-deps
Alex Mikhalev 7 years ago
parent
commit
466cad7893
  1. 1
      app/components/App.tsx
  2. 26
      app/components/NavBar.tsx
  3. 2
      app/pages/LoginPage.tsx
  4. 3
      app/sprinklersRpc/websocketClient.ts
  5. 11
      app/state/AppState.ts
  6. 4
      app/state/Token.ts
  7. 25
      app/state/TokenStore.ts
  8. 24
      common/sprinklersRpc/ErrorCode.ts
  9. 1
      package.json
  10. 31
      server/express/authentication.ts
  11. 23
      server/express/errors.ts
  12. 4
      server/express/index.ts
  13. 0
      server/express/requestLogger.ts
  14. 7724
      yarn-error.log
  15. 6
      yarn.lock

1
app/components/App.tsx

@ -30,6 +30,7 @@ function NavContainer() {
export default function App() { export default function App() {
return ( return (
<Switch> <Switch>
<Route path="/login" component={p.LoginPage}/>
<Route path="/login" component={p.LoginPage}/> <Route path="/login" component={p.LoginPage}/>
<NavContainer/> <NavContainer/>
</Switch> </Switch>

26
app/components/NavBar.tsx

@ -1,28 +1,42 @@
import { Location } from "history"; import * as History from "history";
import * as React from "react"; import * as React from "react";
import { withRouter } from "react-router";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { Menu } from "semantic-ui-react"; import { Menu } from "semantic-ui-react";
import { AppState, injectState } from "@app/state";
import { observer } from "mobx-react";
interface NavItemProps { interface NavItemProps {
to: string; to: string;
children: React.ReactNode; children: React.ReactNode;
location: History.Location;
} }
function NavItem({ to, children }: NavItemProps) { @observer
function NavItem({ to, children, location }: NavItemProps) {
return <Menu.Item as={Link} to={to} active={location.pathname.startsWith(to)}>{children}</Menu.Item>; return <Menu.Item as={Link} to={to} active={location.pathname.startsWith(to)}>{children}</Menu.Item>;
} }
function NavBar({ location }: { location: Location }) { function NavBar({ appState }: { appState: AppState }) {
let loginMenu;
if (appState.isLoggedIn) {
loginMenu = (
<NavItem to="/logout">Logout</NavItem>
);
} else {
loginMenu = (
<NavItem to="/login">Login</NavItem>
);
}
return ( return (
<Menu> <Menu>
<NavItem to="/devices/grinklers">Device grinklers</NavItem> <NavItem to="/devices/grinklers">Device grinklers</NavItem>
<NavItem to="/messagesTest">Messages test</NavItem> <NavItem to="/messagesTest">Messages test</NavItem>
<Menu.Menu position="right"> <Menu.Menu position="right">
<NavItem to="/login">Login</NavItem> {loginMenu}
</Menu.Menu> </Menu.Menu>
</Menu> </Menu>
); );
} }
export default withRouter(NavBar); export default observer(injectState(NavBar));

2
app/pages/LoginPage.tsx

@ -25,7 +25,7 @@ class LoginPageState {
login(appState: AppState) { login(appState: AppState) {
this.loading = true; this.loading = true;
appState.httpApi.tokenStore.grantPassword(this.username, this.password) appState.tokenStore.grantPassword(this.username, this.password)
.then(() => { .then(() => {
this.loading = false; this.loading = false;
log.info("logged in"); log.info("logged in");

3
app/sprinklersRpc/websocketClient.ts

@ -121,7 +121,8 @@ export class WebSocketRpcClient implements s.SprinklersRPC {
} }
async tryAuthenticate() { async tryAuthenticate() {
when(() => this.tokenStore.accessToken.isValid, () => { when(() => this.connectionState.clientToServer === true
&& this.tokenStore.accessToken.isValid, () => {
return this.authenticate(this.tokenStore.accessToken.token!); return this.authenticate(this.tokenStore.accessToken.token!);
}); });
} }

11
app/state/AppState.ts

@ -2,19 +2,30 @@ import { WebSocketRpcClient } from "@app/sprinklersRpc/websocketClient";
import HttpApi from "@app/state/HttpApi"; import HttpApi from "@app/state/HttpApi";
import { UiStore } from "@app/state/UiStore"; import { UiStore } from "@app/state/UiStore";
import { createBrowserHistory, History } from "history"; import { createBrowserHistory, History } from "history";
import { RouterStore, syncHistoryWithStore } from "mobx-react-router";
import { computed } from "mobx";
const isDev = process.env.NODE_ENV === "development"; const isDev = process.env.NODE_ENV === "development";
const websocketPort = isDev ? 8080 : location.port; const websocketPort = isDev ? 8080 : location.port;
export default class AppState { export default class AppState {
history: History = createBrowserHistory(); history: History = createBrowserHistory();
routerStore = new RouterStore();
uiStore = new UiStore(); uiStore = new UiStore();
httpApi = new HttpApi(); httpApi = new HttpApi();
tokenStore = this.httpApi.tokenStore; tokenStore = this.httpApi.tokenStore;
sprinklersRpc = new WebSocketRpcClient(`ws://${location.hostname}:${websocketPort}`, sprinklersRpc = new WebSocketRpcClient(`ws://${location.hostname}:${websocketPort}`,
this.tokenStore); this.tokenStore);
@computed get isLoggedIn() {
return this.tokenStore.accessToken.isValid;
}
async start() { async start() {
syncHistoryWithStore(this.history, this.routerStore);
this.tokenStore.loadLocalStorage();
if (!this.httpApi.tokenStore.accessToken.isValid) { if (!this.httpApi.tokenStore.accessToken.isValid) {
if (this.httpApi.tokenStore.refreshToken.isValid) { if (this.httpApi.tokenStore.refreshToken.isValid) {
await this.httpApi.tokenStore.grantRefresh(); await this.httpApi.tokenStore.grantRefresh();

4
app/state/Token.ts

@ -23,6 +23,10 @@ export class Token {
this.updateCurrentTime(); this.updateCurrentTime();
} }
toJSON() {
return this.token;
}
private updateCurrentTime = (reportChanged: boolean = true) => { private updateCurrentTime = (reportChanged: boolean = true) => {
if (reportChanged) { if (reportChanged) {
this.isExpiredAtom.reportChanged(); this.isExpiredAtom.reportChanged();

25
app/state/TokenStore.ts

@ -7,6 +7,8 @@ import logger from "@common/logger";
const log = logger.child({ source: "TokenStore"}); const log = logger.child({ source: "TokenStore"});
const LOCAL_STORAGE_KEY = "TokenStore";
export class TokenStore { export class TokenStore {
@observable accessToken: Token = new Token(); @observable accessToken: Token = new Token();
@observable refreshToken: Token = new Token(); @observable refreshToken: Token = new Token();
@ -17,6 +19,18 @@ export class TokenStore {
this.api = api; this.api = api;
} }
saveLocalStorage() {
window.localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(this.toJSON()));
}
loadLocalStorage() {
const data = window.localStorage.getItem(LOCAL_STORAGE_KEY);
if (data) {
const data2 = JSON.parse(data);
this.updateFromJson(data2);
}
}
async grantPassword(username: string, password: string) { async grantPassword(username: string, password: string) {
const request: TokenGrantPasswordRequest = { const request: TokenGrantPasswordRequest = {
grant_type: "password", username, password, grant_type: "password", username, password,
@ -26,6 +40,7 @@ export class TokenStore {
}, request); }, request);
this.accessToken.token = response.access_token; this.accessToken.token = response.access_token;
this.refreshToken.token = response.refresh_token; this.refreshToken.token = response.refresh_token;
this.saveLocalStorage();
log.debug({ aud: this.accessToken.claims!.aud }, "got password grant tokens"); log.debug({ aud: this.accessToken.claims!.aud }, "got password grant tokens");
} }
@ -41,6 +56,16 @@ export class TokenStore {
}, request); }, request);
this.accessToken.token = response.access_token; this.accessToken.token = response.access_token;
this.refreshToken.token = response.refresh_token; this.refreshToken.token = response.refresh_token;
this.saveLocalStorage();
log.debug({ aud: this.accessToken.claims!.aud }, "got refresh grant tokens"); log.debug({ aud: this.accessToken.claims!.aud }, "got refresh grant tokens");
} }
toJSON() {
return { accessToken: this.accessToken.toJSON(), refreshToken: this.refreshToken.toJSON() }
}
updateFromJson(json: any) {
this.accessToken.token = json.accessToken;
this.refreshToken.token = json.refreshToken;
}
} }

24
common/sprinklersRpc/ErrorCode.ts

@ -7,8 +7,32 @@ export enum ErrorCode {
BadToken = 105, BadToken = 105,
Unauthorized = 106, Unauthorized = 106,
NoPermission = 107, NoPermission = 107,
NotImplemented = 108,
Internal = 200, Internal = 200,
Timeout = 300, Timeout = 300,
ServerDisconnected = 301, ServerDisconnected = 301,
BrokerDisconnected = 302, BrokerDisconnected = 302,
} }
export function toHttpStatus(errorCode: ErrorCode): number {
switch (errorCode) {
case ErrorCode.BadRequest:
case ErrorCode.NotSpecified:
case ErrorCode.Parse:
case ErrorCode.Range:
case ErrorCode.InvalidData:
return 400; // Bad request
case ErrorCode.Unauthorized:
case ErrorCode.BadToken:
return 401; // Unauthorized
case ErrorCode.NoPermission:
return 403; // Forbidden
case ErrorCode.NotImplemented:
return 501;
case ErrorCode.Internal:
case ErrorCode.ServerDisconnected:
case ErrorCode.BrokerDisconnected:
default:
return 500;
}
}

1
package.json

@ -87,6 +87,7 @@
"mini-css-extract-plugin": "^0.4.0", "mini-css-extract-plugin": "^0.4.0",
"mobx-react": "^5.2.3", "mobx-react": "^5.2.3",
"mobx-react-devtools": "^5.0.1", "mobx-react-devtools": "^5.0.1",
"mobx-react-router": "^4.0.4",
"node-sass": "^4.8.3", "node-sass": "^4.8.3",
"nodemon": "^1.17.5", "nodemon": "^1.17.5",
"npm-run-all": "^4.1.3", "npm-run-all": "^4.1.3",

31
server/express/authentication.ts

@ -4,15 +4,16 @@ import * as jwt from "jsonwebtoken";
import TokenClaims from "@common/TokenClaims"; import TokenClaims from "@common/TokenClaims";
import { User } from "../models/User";
import { ServerState } from "../state";
import { ApiError } from "./errors";
import { import {
TokenGrantPasswordRequest, TokenGrantPasswordRequest,
TokenGrantRefreshRequest, TokenGrantRefreshRequest,
TokenGrantRequest, TokenGrantRequest,
TokenGrantResponse TokenGrantResponse,
} from "@common/http"; } from "@common/http";
import { ErrorCode } from "@common/sprinklersRpc/ErrorCode";
import { User } from "../models/User";
import { ServerState } from "../state";
import { ApiError } from "./errors";
export { TokenClaims }; export { TokenClaims };
@ -56,9 +57,9 @@ export function verifyToken(token: string): Promise<TokenClaims> {
jwt.verify(token, JWT_SECRET, (err, decoded) => { jwt.verify(token, JWT_SECRET, (err, decoded) => {
if (err) { if (err) {
if (err.name === "TokenExpiredError") { if (err.name === "TokenExpiredError") {
reject(new ApiError(401, "The specified token is expired", err)); reject(new ApiError("The specified token is expired", ErrorCode.BadToken, err));
} else if (err.name === "JsonWebTokenError") { } else if (err.name === "JsonWebTokenError") {
reject(new ApiError(400, "Invalid token", err)); reject(new ApiError("Invalid token", ErrorCode.BadToken, err));
} else { } else {
reject(err); reject(err);
} }
@ -100,32 +101,32 @@ export function authentication(state: ServerState) {
async function passwordGrant(body: TokenGrantPasswordRequest, res: Express.Response): Promise<User> { async function passwordGrant(body: TokenGrantPasswordRequest, res: Express.Response): Promise<User> {
const { username, password } = body; const { username, password } = body;
if (!body || !username || !password) { if (!body || !username || !password) {
throw new ApiError(400, "Must specify username and password"); throw new ApiError("Must specify username and password");
} }
const user = await User.loadByUsername(state.database, username); const user = await User.loadByUsername(state.database, username);
if (!user) { if (!user) {
throw new ApiError(400, "User does not exist"); throw new ApiError("User does not exist");
} }
const passwordMatches = await user.comparePassword(password); const passwordMatches = await user.comparePassword(password);
if (passwordMatches) { if (passwordMatches) {
return user; return user;
} else { } else {
throw new ApiError(401, "Invalid user credentials"); throw new ApiError("Invalid user credentials");
} }
} }
async function refreshGrant(body: TokenGrantRefreshRequest, res: Express.Response): Promise<User> { async function refreshGrant(body: TokenGrantRefreshRequest, res: Express.Response): Promise<User> {
const { refresh_token } = body; const { refresh_token } = body;
if (!body || !refresh_token) { if (!body || !refresh_token) {
throw new ApiError(400, "Must specify a refresh_token"); throw new ApiError("Must specify a refresh_token");
} }
const claims = await verifyToken(refresh_token); const claims = await verifyToken(refresh_token);
if (claims.type !== "refresh") { if (claims.type !== "refresh") {
throw new ApiError(400, "Not a refresh token"); throw new ApiError("Not a refresh token");
} }
const user = await User.load(state.database, claims.aud); const user = await User.load(state.database, claims.aud);
if (!user) { if (!user) {
throw new ApiError(400, "User does not exist"); throw new ApiError("User no longer exists");
} }
return user; return user;
} }
@ -138,7 +139,7 @@ export function authentication(state: ServerState) {
} else if (body.grant_type === "refresh") { } else if (body.grant_type === "refresh") {
user = await refreshGrant(body, res); user = await refreshGrant(body, res);
} else { } else {
throw new ApiError(400, "Invalid grant_type"); throw new ApiError("Invalid grant_type");
} }
const [access_token, refresh_token] = await Promise.all( const [access_token, refresh_token] = await Promise.all(
[await generateAccessToken(user, JWT_SECRET), [await generateAccessToken(user, JWT_SECRET),
@ -162,11 +163,11 @@ export function authentication(state: ServerState) {
export async function authorizeAccess(req: Express.Request, res: Express.Response) { export async function authorizeAccess(req: Express.Request, res: Express.Response) {
const bearer = req.headers.authorization; const bearer = req.headers.authorization;
if (!bearer) { if (!bearer) {
throw new ApiError(401, "No bearer token specified"); throw new ApiError("No Authorization header specified", ErrorCode.BadToken);
} }
const matches = /^Bearer (.*)$/.exec(bearer); const matches = /^Bearer (.*)$/.exec(bearer);
if (!matches || !matches[1]) { if (!matches || !matches[1]) {
throw new ApiError(400, "Invalid bearer token specified"); throw new ApiError("Invalid Authorization header, must be Bearer", ErrorCode.BadToken);
} }
const token = matches[1]; const token = matches[1];

23
server/express/errors.ts

@ -1,11 +1,26 @@
import { ErrorCode, toHttpStatus } from "@common/sprinklersRpc/ErrorCode";
export class ApiError extends Error { export class ApiError extends Error {
name = "ApiError"; name = "ApiError";
statusCode: number; statusCode: number;
cause?: Error; code: ErrorCode;
data: any;
constructor(statusCode: number, message: string, cause?: Error) { constructor(message: string, code: ErrorCode = ErrorCode.BadRequest, data: any = {}) {
super(message); super(message);
this.statusCode = statusCode; this.statusCode = toHttpStatus(code);
this.cause = cause; this.code = code;
// tslint:disable-next-line:prefer-conditional-expression
if (data instanceof Error) {
this.data = data.toString();
} else {
this.data = data;
}
}
toJSON() {
return {
message: this.message, code: this.code, data: this.data,
};
} }
} }

4
server/express/index.ts

@ -4,7 +4,7 @@ import { serialize} from "serializr";
import * as schema from "@common/sprinklersRpc/schema"; import * as schema from "@common/sprinklersRpc/schema";
import { ServerState } from "../state"; import { ServerState } from "../state";
import logger from "./logger"; import requestLogger from "./requestLogger";
import serveApp from "./serveApp"; import serveApp from "./serveApp";
import { User } from "../models/User"; import { User } from "../models/User";
@ -13,7 +13,7 @@ import { authentication } from "./authentication";
export function createApp(state: ServerState) { export function createApp(state: ServerState) {
const app = express(); const app = express();
app.use(logger); app.use(requestLogger);
app.use(bodyParser.json()); app.use(bodyParser.json());
app.get("/api/devices/:deviceId", (req, res) => { app.get("/api/devices/:deviceId", (req, res) => {

0
server/express/expressLogger.ts → server/express/requestLogger.ts

7724
yarn-error.log

File diff suppressed because it is too large Load Diff

6
yarn.lock

@ -2133,7 +2133,7 @@ expand-tilde@^2.0.0, expand-tilde@^2.0.2:
express-pino-logger@^3.0.2: express-pino-logger@^3.0.2:
version "3.0.2" version "3.0.2"
resolved "https://registry.yarnpkg.com/express-pino-logger/-/express-pino-logger-3.0.2.tgz#6384763e492a246dfbe795f9380a7234f300acc1" resolved "https://registry.yarnpkg.com/express-pino-requestLogger/-/express-pino-requestLogger-3.0.2.tgz#6384763e492a246dfbe795f9380a7234f300acc1"
dependencies: dependencies:
pino-http "^3.0.1" pino-http "^3.0.1"
@ -4131,6 +4131,10 @@ mobx-react-devtools@^5.0.1:
version "5.0.1" version "5.0.1"
resolved "https://registry.yarnpkg.com/mobx-react-devtools/-/mobx-react-devtools-5.0.1.tgz#9473c85929b2fc0c95086430419028cebd96fb3b" resolved "https://registry.yarnpkg.com/mobx-react-devtools/-/mobx-react-devtools-5.0.1.tgz#9473c85929b2fc0c95086430419028cebd96fb3b"
mobx-react-router@^4.0.4:
version "4.0.4"
resolved "https://registry.yarnpkg.com/mobx-react-router/-/mobx-react-router-4.0.4.tgz#b778618c84f16057f5ae9c09a4d364deade95fd8"
mobx-react@^5.2.3: mobx-react@^5.2.3:
version "5.2.3" version "5.2.3"
resolved "https://registry.yarnpkg.com/mobx-react/-/mobx-react-5.2.3.tgz#cdf6141c2fe63377c5813cbd254e8ce0d4676631" resolved "https://registry.yarnpkg.com/mobx-react/-/mobx-react-5.2.3.tgz#cdf6141c2fe63377c5813cbd254e8ce0d4676631"

Loading…
Cancel
Save