diff --git a/runtime/backend/src/common/gateways/AuthGateway.ts b/runtime/backend/src/common/gateways/AuthGateway.ts index 784bf1fb..23e7bd5a 100644 --- a/runtime/backend/src/common/gateways/AuthGateway.ts +++ b/runtime/backend/src/common/gateways/AuthGateway.ts @@ -16,10 +16,27 @@ import { EventEmitter2, OnEvent } from "@nestjs/event-emitter"; import { ValidateChallengeScheduler } from "../schedulers/ValidateChallengeScheduler"; import { BaseGateway } from "./BaseGateway"; +/** + * @label COMMON + * @class AuthGateway + * @description This class extends baseGateway. It's + * responsible for handling authentication requests, + * running validation scheduler, permitting to frontend to call get /token. + *

+ * This class can be used by adding different + * @SubscribeMessage handlers. + * + * @since v0.2.0 + */ @Injectable() -export class AuthGateway - extends BaseGateway -{ +export class AuthGateway extends BaseGateway { + /** + * Construct an instance of class. + * + * @access public + * @param {ValidateChallengeScheduler} validateChallengeScheduler start validation of the challenge + * @param {EventEmitter2} clients Required by base gateway dependency. + */ constructor( private readonly validateChallengeScheduler: ValidateChallengeScheduler, protected readonly emitter: EventEmitter2, @@ -27,19 +44,39 @@ export class AuthGateway super(emitter); } + /** + * This method handles starting of challenge validation. + * Gets trigged by "auth.open" emit from handleConnection(). + * Calls .startCronJob from validateChallengeScheduler. + * + * @param {any} payload Contains challenge string + * @returns {void} Emits "auth.open" event which triggers validating of the received challenge + */ @OnEvent("auth.open") handleEvent(payload: any) { this.validateChallengeScheduler.startCronJob(payload.challenge); - return { msg: "You're connected" }; } + /** + * This method handles auth.close event triggered by client. + * + * @returns {void} + */ @SubscribeMessage("auth.close") close() { - console.log("AUTHGATEWAY: Connection closed"); + this.logger.log("AUTHGATEWAY: Client disconnected"); } - @SubscribeMessage("auth.complete") + /** + * This method handles auth.close event, + * which is getting triggered by validateChallengeScheduler when challenge on chain. + * Sends auth.complete message to the client. + * + * @returns {void} Emits "auth.complete" event which informs client that token may be queried. + */ + @OnEvent("auth.complete") complete() { - console.log("AUTHGATEWAY: Complete"); + this.ws.send("auth.complete"); + this.logger.log("AUTHGATEWAY: Complete"); } } diff --git a/runtime/backend/src/common/gateways/BaseGateway.ts b/runtime/backend/src/common/gateways/BaseGateway.ts index 435858ff..26016440 100644 --- a/runtime/backend/src/common/gateways/BaseGateway.ts +++ b/runtime/backend/src/common/gateways/BaseGateway.ts @@ -15,12 +15,13 @@ import { OnGatewayConnection, OnGatewayDisconnect, OnGatewayInit, - MessageBody, } from "@nestjs/websockets"; import { Server } from "https"; import cookie from "cookie"; import cookieParser from "cookie-parser"; import { EventEmitter2 } from "@nestjs/event-emitter"; +import { Socket } from "dgram"; +import { HttpException, HttpStatus } from "@nestjs/common"; // internal dependencies import dappConfigLoader from "../../../config/dapp"; @@ -28,6 +29,17 @@ import { LogService } from "../services"; const dappConfig = dappConfigLoader(); +/** + * @label COMMON + * @class BaseGateway + * @description This class serves as the *base class* for + * custom gateways which are connecting with the client through the websocket. + *

+ * This class can be used by extending it and adding different + * @SubscribeMessage handlers. + * + * @since v0.2.0 + */ @WebSocketGateway(80, { path: "/ws", cors: { @@ -37,18 +49,69 @@ const dappConfig = dappConfigLoader(); export abstract class BaseGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect { + /** + * Construct an instance of the base gateway, + * initialize clients, logger and emitter properties. + * + * @access public + * @param {EventEmitter2} emitter Emitting events once connections/updates appear. + * @param {Array} clients Store connected client challenges. + * @param {LogService} logger Log important data to console or files based on configuration. + */ constructor(protected readonly emitter: EventEmitter2) { this.clients = []; this.logger = new LogService(`${dappConfig.dappName}/gateway`); } + /** + * This property permits to log information to the console or in files + * depending on the configuration. This logger instance can be accessed + * by extending listeners to use a common log process. + * + * @access protected + * @var {LogService} + */ protected logger: LogService; + /** + * This property implements gateway server + * which is broadcasts different messages to single or multiple clients. + * + * @access protected + * @var {Server} + */ @WebSocketServer() - server: any; + server: Server; + /** + * This property implements list of currently connected + * clients by storing their challenges. Challenge gets removed from list once client disconnects. + * + * @access protected + * @var {string[]} + */ protected clients: string[]; + /** + * This property stores socket instance which + * is getting assigned in it on connection. Used for sending messages/emitting events from child classes. + * + * @access protected + * @var {Socket} + */ + protected ws: Socket; + + /** + * This method handles connection via websocket with the client. + * It also extracts challenge cookie from request, decodes it and stores in clients list. + *

+ * In case of a *successful* challenge decoding "auth.open" event will be fired. + * + * @param {any} ws Websocket connection param, holds server and client info. + * @param {any} req Request param which allows to access cookies + * @returns {Promise} Emits "auth.open" event which triggers validating of the received challenge + * @throws {HttpException} Challenge wasn't attached to request cookies + */ async handleConnection(ws: any, req: any) { // parse challenge from cookie const c: any = cookie.parse(req.headers.cookie); @@ -57,6 +120,13 @@ export abstract class BaseGateway process.env.SECURITY_AUTH_TOKEN_SECRET, ) as string; + // if challenge couldn't be parsed or parsed incorrectly - throw an error + if (!decoded) + throw new HttpException("Unauthorized", HttpStatus.UNAUTHORIZED); + + // store ws connection to allow send messages to the client in child classes + this.ws = ws; + // add cookie to ws object ws.challenge = decoded; // push challenge to client list @@ -64,11 +134,21 @@ export abstract class BaseGateway // trigger auth.open event with challenge passed this.emitter.emit("auth.open", { challenge: decoded }); - ws.emit("connection_test", { msg: "its a test" }); this.logger.log("client connected", this.clients); } + /** + * This method handles closing connection with the client. + * After client was disconnected - remove his challenge from list. + *

+ * In case of a *successful* validation attempts, i.e. when the `challenge` + * parameter **has been found** in a recent transfer transaction's message, + * a document will be *insert* in the collection `authChallenges`. + * + * @param {any} ws Websocket connection param, holds server and client info. + * @returns {void} Removes client challenge from list + */ handleDisconnect(ws: any) { const str = ws.challenge; this.clients = this.clients.filter((c) => c !== str); @@ -76,6 +156,12 @@ export abstract class BaseGateway this.logger.log("Client disconnected", this.clients); } + /** + * This method handles gateway initialize hook. + * + * @param {Server} server Websocket connection param, holds server and client info. + * @returns {void} Removes client challenge from list + */ afterInit(server: Server) { this.logger.log("Gateway initialized"); } diff --git a/runtime/backend/src/common/schedulers/ValidateChallengeScheduler.ts b/runtime/backend/src/common/schedulers/ValidateChallengeScheduler.ts index 86e11a08..030a7b6e 100644 --- a/runtime/backend/src/common/schedulers/ValidateChallengeScheduler.ts +++ b/runtime/backend/src/common/schedulers/ValidateChallengeScheduler.ts @@ -8,18 +8,46 @@ * @license LGPL-3.0 */ +// external dependencies import { Injectable } from "@nestjs/common"; import { CronJob } from "cron"; import { SchedulerRegistry } from "@nestjs/schedule"; +import { EventEmitter2 } from "@nestjs/event-emitter"; +// internal dependencies import { AuthService } from "../services"; +import { LogService } from "../services"; +import dappConfigLoader from "../../../config/dapp"; +const dappConfig = dappConfigLoader(); + +/** + * @label COMMON + * @class ValidateChallengeScheduler + * @description This class implements dynamic scheduler + * which can be started dynamically based on + * available connection. While it's running + * it validate received challenge. Stops once challenge becomes + * valid or 30 minutes have been passed. + * + * @since v0.2.0 + */ @Injectable() export class ValidateChallengeScheduler { + /** + * Construct an instance of the scheduler. + * + * @access public + * @param {SchedulerRegistry} schedulerRegistry Add scheduler to Nest.js schedulers registry. + * @param {AuthService} authService Contains .validateChallenge method. + * @param {EventEmitter2} emitter Emitting of successfully validated challenge to proper handler. + */ constructor( private readonly schedulerRegistry: SchedulerRegistry, protected readonly authService: AuthService, + protected readonly emitter: EventEmitter2, ) { + // initialize cronJob with provided params this.job = new CronJob( this.cronExpression, // cronTime this.validate.bind(this), // onTick @@ -35,35 +63,113 @@ export class ValidateChallengeScheduler { `statistics:cronjobs:leaderboards:D`, this.job, ); + + // initialize logger + this.logger = new LogService( + `${dappConfig.dappName}/ValidateChallengeScheduler`, + ); } + /** + * This property permits to log information to the console or in files + * depending on the configuration. This logger instance can be accessed + * by extending listeners to use a common log process. + * + * @access protected + * @var {LogService} + */ + protected logger: LogService; + + /** + * This property contains scheduler time, + * marks tick time when provided function will be called. + * Example: call validate() each 10 seconds + * + * @access protected + * @var {string} + */ protected cronExpression = "*/10 * * * * *"; // each 10 seconds + /** + * This property contains created and stored CronJob. + * + * @access protected + * @var {CronJob} + */ protected job: CronJob; + /** + * This property stores received + * challenge. Gets cleared once cron stops. + * + * @access protected + * @var {string} + */ protected challenge: string; + /** + * This property stores stopCronJobTimeout, + * inside of it setTimeout() is getting set, + * when cronJob starts. + * + * @access protected + * @var {string} + */ protected stopCronJobTimeout: any; + /** + * This property stores amount of time, + * after which cronJob should be stopped. + * + * @access protected + * @var {number} + */ protected stopTimeoutAmount = 1800000; + /** + * This method implements validation process + * which runs by scheduler each period of time. + * + * @param {any} payload Contains challenge string + * @returns {void} Emits "auth.open" event which triggers validating of the received challenge + */ protected async validate() { try { - const payload = await this.authService.validateChallenge(this.challenge); - // after challenge validated successfully - stop running cron - this.stopCronJob(); - console.log({ payload }); + const payload = await this.authService.validateChallenge( + this.challenge, + false, + ); + + if (null !== payload) { + // after challenge validated successfully - stop running cron + this.stopCronJob(); + this.emitter.emit("auth.complete"); + this.logger.log("successfully validated challenge", this.challenge); + } } catch (err) { - console.log("Error validate()", err); + // if challenge isn't on chain - print info to the console + this.logger.error("failed to validate challenge", err); } } + /** + * This method stops scheduler cronJob, + * clears timeout as scheduler has been stopped. + * + * @returns {void} Stops cronJob, clears challenge, clears timeout. + */ protected stopCronJob() { this.job.stop(); this.challenge = ""; clearTimeout(this.stopCronJobTimeout); } + /** + * This method starts scheduler cronJob, + * sets received challenge and sets cronJob timeout. + * + * @returns {void} Starts cronJob, sets challenge, sets timeout. + */ public startCronJob(challenge: string) { this.challenge = challenge; diff --git a/runtime/backend/src/common/services/AuthService.ts b/runtime/backend/src/common/services/AuthService.ts index 881f32fb..ec64f478 100644 --- a/runtime/backend/src/common/services/AuthService.ts +++ b/runtime/backend/src/common/services/AuthService.ts @@ -219,7 +219,6 @@ export class AuthService { private readonly accountSessionsService: AccountSessionsService, private readonly challengesService: ChallengesService, private jwtService: JwtService, - protected readonly authGateWay: AuthGateway, ) { const name = this.configService.get("dappName"); const domain = this.configService.get("frontendApp.host"); @@ -303,15 +302,19 @@ export class AuthService { * parameter **has been found** in a recent transfer transaction's message, * a document will be *insert* in the collection `authChallenges`. * - * @param {string} challenge An authentication challenge, as created with {@link getChallenge}. + * @param {AccessTokenRequest} param0 An authentication challenge, as created with {@link getChallenge}. + * @param {boolean} enableStorage Flag that defines if challenge should be stored in database for the case when need to check validity of challenge. Defaults to true. * @returns {Promise} An authenticated account session described with {@link AuthenticationPayload}. * @throws {HttpException} Given challenge could not be found in recent transactions. */ - public async validateChallenge({ - challenge, - sub, - registry, - }: AccessTokenRequest): Promise { + public async validateChallenge( + { + challenge, + sub, + registry, + }: AccessTokenRequest, + enableStorage: boolean = true, + ): Promise { // does not permit multiple usage of challenges const challengeUsed: boolean = await this.challengesService.exists( new AuthChallengeQuery({ @@ -350,19 +353,21 @@ export class AuthService { const logger = new LogService(AppConfiguration.dappName); logger.log(`Authorizing log-in challenge for "${authorizedUser.address}"`); - // stores a validated authentication challenge - // in the database collection `authChallenges` - await this.challengesService.createOrUpdate( - new AuthChallengeQuery({ - challenge, - } as AuthChallengeDocument), - { - usedBy: authorizedAddr.plain(), - usedAt: new Date().valueOf(), - }, - ); + if (enableStorage === true) { + // stores a validated authentication challenge + // in the database collection `authChallenges` + await this.challengesService.createOrUpdate( + new AuthChallengeQuery({ + challenge: challenge, + } as AuthChallengeDocument), + { + usedBy: authorizedAddr.plain(), + usedAt: new Date().valueOf(), + }, + ); + } - this.authGateWay.server.emit("auth.complete"); + // this.authGateWay.server.emit("auth.complete"); // returns the authorized user details return authorizedUser; diff --git a/runtime/dapp-frontend-vue/src/views/LoginScreen/LoginScreen.ts b/runtime/dapp-frontend-vue/src/views/LoginScreen/LoginScreen.ts index be5bb409..20c54a73 100644 --- a/runtime/dapp-frontend-vue/src/views/LoginScreen/LoginScreen.ts +++ b/runtime/dapp-frontend-vue/src/views/LoginScreen/LoginScreen.ts @@ -276,6 +276,14 @@ export default class LoginScreen extends MetaView { ); } + /** + * This property contains + * client side websocket connection + * which is getting initialized on mounted() hook. + * + * @access public + * @returns {any} + */ public wsConnection: any = null; /** @@ -328,15 +336,11 @@ export default class LoginScreen extends MetaView { const handler = this.fetchToken; this.wsConnection.onmessage = function (evt: any) { - if (evt.data === "auth.open") { + if (evt.data === "auth.complete") { handler(); } }; - // this.wsConnection.emit("auth.open", { data: "test msg" }, (res: any) => { - // console.log({ res }); - // }); - try { // make sure referral code is saved if (this.$route.params.refCode) { @@ -390,6 +394,7 @@ export default class LoginScreen extends MetaView { clearTimeout(this.globalIntervalTimer); } + // close connection when route left this.wsConnection.close(); }