Browse Source

Better handling of expired access tokens

update-deps
Alex Mikhalev 7 years ago
parent
commit
f8a1dd0a8c
  1. 72
      client/sprinklersRpc/WebSocketRpcClient.ts
  2. 41
      client/state/AppState.ts
  3. 63
      client/state/HttpApi.ts
  4. 2
      client/state/Token.ts
  5. 2
      client/state/UserStore.ts
  6. 48
      common/TypedEventEmitter.ts
  7. 1
      server/Database.ts

72
client/sprinklersRpc/WebSocketRpcClient.ts

@ -2,8 +2,8 @@ import { action, autorun, observable, runInAction, when } from "mobx"; @@ -2,8 +2,8 @@ import { action, autorun, observable, runInAction, when } from "mobx";
import { update } from "serializr";
import { TokenStore } from "@client/state/TokenStore";
import { UserStore } from "@client/state/UserStore";
import { ErrorCode } from "@common/ErrorCode";
import { IUser } from "@common/httpApi";
import * as rpc from "@common/jsonRpc";
import logger from "@common/logger";
import * as deviceRequests from "@common/sprinklersRpc/deviceRequests";
@ -11,6 +11,7 @@ import * as s from "@common/sprinklersRpc/index"; @@ -11,6 +11,7 @@ import * as s from "@common/sprinklersRpc/index";
import * as schema from "@common/sprinklersRpc/schema/index";
import { seralizeRequest } from "@common/sprinklersRpc/schema/requests";
import * as ws from "@common/sprinklersRpc/websocketData";
import { DefaultEvents, TypedEventEmitter } from "@common/TypedEventEmitter";
const log = logger.child({ source: "websocket" });
@ -83,7 +84,13 @@ export class WSSprinklersDevice extends s.SprinklersDevice { @@ -83,7 +84,13 @@ export class WSSprinklersDevice extends s.SprinklersDevice {
}
}
export class WebSocketRpcClient implements s.SprinklersRPC {
export interface WebSocketRpcClientEvents extends DefaultEvents {
newUserData(userData: IUser): void;
rpcError(error: ws.RpcError): void;
tokenError(error: ws.RpcError): void;
}
export class WebSocketRpcClient extends TypedEventEmitter<WebSocketRpcClientEvents> implements s.SprinklersRPC {
readonly webSocketUrl: string;
devices: Map<string, WSSprinklersDevice> = new Map();
@ -94,7 +101,6 @@ export class WebSocketRpcClient implements s.SprinklersRPC { @@ -94,7 +101,6 @@ export class WebSocketRpcClient implements s.SprinklersRPC {
authenticated: boolean = false;
tokenStore: TokenStore;
userStore: UserStore;
private nextRequestId = Math.round(Math.random() * 1000000);
private responseCallbacks: ws.ServerResponseHandlers = {};
@ -104,12 +110,18 @@ export class WebSocketRpcClient implements s.SprinklersRPC { @@ -104,12 +110,18 @@ export class WebSocketRpcClient implements s.SprinklersRPC {
return this.connectionState.isServerConnected || false;
}
constructor(tokenStore: TokenStore, userStore: UserStore, webSocketUrl: string = DEFAULT_URL) {
constructor(tokenStore: TokenStore, webSocketUrl: string = DEFAULT_URL) {
super();
this.webSocketUrl = webSocketUrl;
this.tokenStore = tokenStore;
this.userStore = userStore;
this.connectionState.clientToServer = false;
this.connectionState.serverToBroker = false;
this.on("rpcError", (err: ws.RpcError) => {
if (err.code === ErrorCode.BadToken) {
this.emit("tokenError", err);
}
});
}
start() {
@ -149,13 +161,17 @@ export class WebSocketRpcClient implements s.SprinklersRPC { @@ -149,13 +161,17 @@ export class WebSocketRpcClient implements s.SprinklersRPC {
&& this.tokenStore.accessToken.isValid, async () => {
try {
const res = await this.authenticate(this.tokenStore.accessToken.token!);
this.authenticated = res.authenticated;
runInAction("authenticateSuccess", () => {
this.authenticated = res.authenticated;
});
logger.info({ user: res.user }, "authenticated websocket connection");
this.userStore.receiveUserData(res.user);
this.emit("newUserData", res.user);
} catch (err) {
logger.error({ err }, "error authenticating websocket connection");
// TODO message?
this.authenticated = false;
runInAction("authenticateSuccess", () => {
this.authenticated = false;
});
}
});
}
@ -167,17 +183,13 @@ export class WebSocketRpcClient implements s.SprinklersRPC { @@ -167,17 +183,13 @@ export class WebSocketRpcClient implements s.SprinklersRPC {
code: ErrorCode.ServerDisconnected,
message: "the server is not connected",
};
throw error;
throw new ws.RpcError("the server is not connected", ErrorCode.ServerDisconnected);
}
const requestData = seralizeRequest(request);
const data: ws.IDeviceCallRequest = { deviceId, data: requestData };
const resData = await this.makeRequest("deviceCall", data);
if (resData.data.result === "error") {
throw {
code: resData.data.code,
message: resData.data.message,
data: resData.data,
};
throw new ws.RpcError(resData.data.message, resData.data.code, resData.data);
} else {
return resData.data;
}
@ -194,21 +206,22 @@ export class WebSocketRpcClient implements s.SprinklersRPC { @@ -194,21 +206,22 @@ export class WebSocketRpcClient implements s.SprinklersRPC {
if (response.result === "success") {
resolve(response.data);
} else {
reject(response.error);
const { error } = response;
reject(new ws.RpcError(error.message, error.code, error.data));
}
};
timeoutHandle = window.setTimeout(() => {
delete this.responseCallbacks[id];
const res: ws.ErrorData = {
result: "error", error: {
code: ErrorCode.Timeout,
message: "the request timed out",
},
};
reject(res);
reject(new ws.RpcError("the request timed out", ErrorCode.Timeout));
}, TIMEOUT_MS);
this.sendRequest(id, method, params);
});
})
.catch((err) => {
if (err instanceof ws.RpcError) {
this.emit("rpcError", err);
}
throw err;
});
}
private sendMessage(data: ws.ClientMessage) {
@ -230,7 +243,7 @@ export class WebSocketRpcClient implements s.SprinklersRPC { @@ -230,7 +243,7 @@ export class WebSocketRpcClient implements s.SprinklersRPC {
private _connect() {
if (this.socket != null &&
(this.socket.readyState === WebSocket.CLOSED)) {
(this.socket.readyState === WebSocket.OPEN)) {
this.tryAuthenticate();
return;
}
@ -242,6 +255,7 @@ export class WebSocketRpcClient implements s.SprinklersRPC { @@ -242,6 +255,7 @@ export class WebSocketRpcClient implements s.SprinklersRPC {
this.socket.onmessage = this.onMessage.bind(this);
}
@action
private onOpen() {
log.info("established websocket connection");
this.connectionState.clientToServer = true;
@ -250,6 +264,7 @@ export class WebSocketRpcClient implements s.SprinklersRPC { @@ -250,6 +264,7 @@ export class WebSocketRpcClient implements s.SprinklersRPC {
}
/* tslint:disable-next-line:member-ordering */
@action
private onDisconnect = action(() => {
this.connectionState.serverToBroker = null;
this.connectionState.clientToServer = false;
@ -263,12 +278,11 @@ export class WebSocketRpcClient implements s.SprinklersRPC { @@ -263,12 +278,11 @@ export class WebSocketRpcClient implements s.SprinklersRPC {
this.reconnectTimer = window.setTimeout(this._reconnect, RECONNECT_TIMEOUT_MS);
}
@action
private onError(event: Event) {
log.error({ event }, "websocket error");
action(() => {
this.connectionState.serverToBroker = null;
this.connectionState.clientToServer = false;
});
this.connectionState.serverToBroker = null;
this.connectionState.clientToServer = false;
this.onDisconnect();
}
@ -335,4 +349,4 @@ class WSClientNotificationHandlers implements ws.ServerNotificationHandlers { @@ -335,4 +349,4 @@ class WSClientNotificationHandlers implements ws.ServerNotificationHandlers {
error(data: ws.IError) {
log.warn({ err: data }, "server error");
}
};
}

41
client/state/AppState.ts

@ -1,5 +1,5 @@ @@ -1,5 +1,5 @@
import { createBrowserHistory, History } from "history";
import { computed, configure } from "mobx";
import { computed, configure, when } from "mobx";
import { RouterStore, syncHistoryWithStore } from "mobx-react-router";
import { WebSocketRpcClient } from "@client/sprinklersRpc/WebSocketRpcClient";
@ -8,16 +8,37 @@ import { UiStore } from "@client/state/UiStore"; @@ -8,16 +8,37 @@ import { UiStore } from "@client/state/UiStore";
import { UserStore } from "@client/state/UserStore";
import ApiError from "@common/ApiError";
import { ErrorCode } from "@common/ErrorCode";
import { IUser } from "@common/httpApi";
import log from "@common/logger";
import { TypedEventEmitter, DefaultEvents } from "@common/TypedEventEmitter";
export default class AppState {
interface AppEvents extends DefaultEvents {
checkToken(): void;
hasToken(): void;
}
export default class AppState extends TypedEventEmitter<AppEvents> {
history: History = createBrowserHistory();
routerStore = new RouterStore();
uiStore = new UiStore();
userStore = new UserStore();
httpApi = new HttpApi();
tokenStore = this.httpApi.tokenStore;
sprinklersRpc = new WebSocketRpcClient(this.tokenStore, this.userStore);
sprinklersRpc = new WebSocketRpcClient(this.tokenStore);
constructor() {
super();
this.sprinklersRpc.on("newUserData", this.userStore.receiveUserData);
this.sprinklersRpc.on("tokenError", this.checkToken);
this.httpApi.on("tokenError", this.checkToken);
this.on("checkToken", this.doCheckToken);
this.on("hasToken", () => {
when(() => !this.tokenStore.accessToken.isValid, this.checkToken);
this.sprinklersRpc.start();
});
}
@computed get isLoggedIn() {
return this.tokenStore.accessToken.isValid;
@ -29,15 +50,20 @@ export default class AppState { @@ -29,15 +50,20 @@ export default class AppState {
});
syncHistoryWithStore(this.history, this.routerStore);
this.tokenStore.loadLocalStorage();
await this.tokenStore.loadLocalStorage();
await this.checkToken();
await this.sprinklersRpc.start();
}
async checkToken() {
const { tokenStore: { accessToken, refreshToken } } = this.httpApi;
checkToken = () => {
this.emit("checkToken");
}
private doCheckToken = async () => {
const { accessToken, refreshToken } = this.tokenStore;
accessToken.updateCurrentTime();
if (accessToken.isValid) { // if the access token is valid, we are good
this.emit("hasToken");
return;
}
if (!refreshToken.isValid) { // if the refresh token is not valid, need to login again
@ -46,6 +72,7 @@ export default class AppState { @@ -46,6 +72,7 @@ export default class AppState {
}
try {
await this.httpApi.grantRefresh();
this.emit("hasToken");
} catch (err) {
if (err instanceof ApiError && err.code === ErrorCode.BadToken) {
log.warn({ err }, "refresh is bad for some reason, erasing");

63
client/state/HttpApi.ts

@ -3,11 +3,17 @@ import ApiError from "@common/ApiError"; @@ -3,11 +3,17 @@ import ApiError from "@common/ApiError";
import { ErrorCode } from "@common/ErrorCode";
import { TokenGrantPasswordRequest, TokenGrantRefreshRequest, TokenGrantResponse } from "@common/httpApi";
import log from "@common/logger";
import { DefaultEvents, TypedEventEmitter } from "@common/TypedEventEmitter";
import { runInAction } from "mobx";
export { ApiError };
export default class HttpApi {
interface HttpApiEvents extends DefaultEvents {
error(err: ApiError): void;
tokenError(err: ApiError): void;
}
export default class HttpApi extends TypedEventEmitter<HttpApiEvents> {
baseUrl: string;
tokenStore: TokenStore;
@ -20,36 +26,53 @@ export default class HttpApi { @@ -20,36 +26,53 @@ export default class HttpApi {
}
constructor(baseUrl: string = `${location.protocol}//${location.hostname}:${location.port}/api`) {
super();
while (baseUrl.charAt(baseUrl.length - 1) === "/") {
baseUrl = baseUrl.substring(0, baseUrl.length - 1);
}
this.baseUrl = baseUrl;
this.tokenStore = new TokenStore();
this.on("error", (err: ApiError) => {
if (err.code === ErrorCode.BadToken) {
this.emit("tokenError", err);
}
});
}
async makeRequest(url: string, options?: RequestInit, body?: any): Promise<any> {
options = options || {};
options = {
headers: {
"Content-Type": "application/json",
...this.authorizationHeader,
...options.headers || {},
},
body: JSON.stringify(body),
...options,
};
const response = await fetch(this.baseUrl + url, options);
let responseBody: any;
try {
responseBody = await response.json() || {};
} catch (e) {
throw new ApiError("Invalid JSON response", ErrorCode.Internal, e);
}
if (!response.ok) {
throw new ApiError(responseBody.message || response.statusText, responseBody.code, responseBody.data);
options = options || {};
options = {
headers: {
"Content-Type": "application/json",
...this.authorizationHeader,
...options.headers || {},
},
body: JSON.stringify(body),
...options,
};
let response: Response;
try {
response = await fetch(this.baseUrl + url, options);
} catch (err) {
throw new ApiError("Http request error", ErrorCode.Internal, err);
}
let responseBody: any;
try {
responseBody = await response.json() || {};
} catch (e) {
throw new ApiError("Invalid JSON response", ErrorCode.Internal, e);
}
if (!response.ok) {
throw new ApiError(responseBody.message || response.statusText, responseBody.code, responseBody.data);
}
return responseBody;
} catch (err) {
this.emit("error", err);
throw err;
}
return responseBody;
}
async grantPassword(username: string, password: string) {

2
client/state/Token.ts

@ -27,7 +27,7 @@ export class Token<TClaims extends TokenClaims = TokenClaims> { @@ -27,7 +27,7 @@ export class Token<TClaims extends TokenClaims = TokenClaims> {
return this.token;
}
private updateCurrentTime = (reportChanged: boolean = true) => {
updateCurrentTime = (reportChanged: boolean = true) => {
if (reportChanged) {
this.isExpiredAtom.reportChanged();
}

2
client/state/UserStore.ts

@ -4,7 +4,7 @@ import { action, observable } from "mobx"; @@ -4,7 +4,7 @@ import { action, observable } from "mobx";
export class UserStore {
@observable userData: IUser | null = null;
@action
@action.bound
receiveUserData(userData: IUser) {
this.userData = userData;
}

48
common/TypedEventEmitter.ts

@ -0,0 +1,48 @@ @@ -0,0 +1,48 @@
import { EventEmitter } from "events";
type TEventName = string | symbol;
type AnyListener = (...args: any[]) => void;
type Arguments<TListener> = TListener extends (...args: infer TArgs) => any ? TArgs : any[];
type Listener<TEvents, TEvent extends keyof TEvents> = TEvents[TEvent] extends (...args: infer TArgs) => any ?
(...args: TArgs) => void : AnyListener;
export interface DefaultEvents {
newListener: (event: TEventName, listener: AnyListener) => void;
removeListener: (event: TEventName, listener: AnyListener) => void;
}
export type AnyEvents = DefaultEvents & {
[event in TEventName]: any[];
};
type IEventSubscriber<TEvents extends DefaultEvents, This> =
<TEvent extends keyof TEvents & TEventName>(event: TEvent, listener: Listener<TEvents, TEvent>) => This;
// tslint:disable:ban-types
interface ITypedEventEmitter<TEvents extends DefaultEvents = AnyEvents> {
on: IEventSubscriber<TEvents, this>;
off: IEventSubscriber<TEvents, this>;
once: IEventSubscriber<TEvents, this>;
addListener: IEventSubscriber<TEvents, this>;
removeListener: IEventSubscriber<TEvents, this>;
prependListener: IEventSubscriber<TEvents, this>;
prependOnceListener: IEventSubscriber<TEvents, this>;
emit<TEvent extends keyof TEvents & TEventName>(event: TEvent, ...args: Arguments<TEvents[TEvent]>): boolean;
listeners<TEvent extends keyof TEvents & TEventName>(event: TEvent): Function[];
rawListeners<TEvent extends keyof TEvents & TEventName>(event: TEvent): Function[];
eventNames(): Array<keyof TEvents | TEventName>;
setMaxListeners(maxListeners: number): this;
getMaxListeners(): number;
listenerCount<TEvent extends keyof TEvents & TEventName>(event: TEvent): number;
}
const TypedEventEmitter = EventEmitter as {
new<TEvents extends DefaultEvents = AnyEvents>(): TypedEventEmitter<TEvents>,
};
type TypedEventEmitter<TEvents extends DefaultEvents = AnyEvents> = ITypedEventEmitter<TEvents>;
export { TypedEventEmitter };

1
server/Database.ts

@ -45,6 +45,7 @@ export class Database { @@ -45,6 +45,7 @@ export class Database {
}
async insertData() {
this.conn.subscribers
const NUM = 100;
const users: User[] = [];
for (let i = 0; i < NUM; i++) {

Loading…
Cancel
Save