Skip to content

Commit

Permalink
Merge branch 'feature/plugin-auth' into develop
Browse files Browse the repository at this point in the history
  • Loading branch information
dotFionn committed Mar 15, 2024
2 parents 717f97d + f590cfa commit 701ad17
Show file tree
Hide file tree
Showing 12 changed files with 355 additions and 30 deletions.
54 changes: 27 additions & 27 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"module-alias": "^2.2.2",
"mongoose": "^6.5.0",
"morgan": "1.10.0",
"ms": "2.1.3",
"nest-winston": "1.9.4",
"nestjs-joi": "1.10.0",
"point-in-polygon": "^1.1.0",
Expand Down
2 changes: 2 additions & 0 deletions src/backend/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { FrontendProxyMiddleware } from './frontend-proxy/frontend-proxy.middlew
import { FrontendProxyModule } from './frontend-proxy/frontend-proxy.module';
import { MessageModule } from './message/message.module';
import { PilotModule } from './pilot/pilot.module';
import { PluginTokenModule } from './plugin-token/plugin-token.module';
import { agendaProviders } from './schedule.module';
import { UserModule } from './user/user.module';
import { UtilsModule } from './utils/utils.module';
Expand Down Expand Up @@ -50,6 +51,7 @@ const { frontendProxy } = getAppConfig();
EcfmpModule,
AuthModule,
ConfigModule,
PluginTokenModule,
],
providers: [...databaseProviders, ...agendaProviders],
exports: [...databaseProviders],
Expand Down
7 changes: 4 additions & 3 deletions src/backend/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,10 @@ export class AuthController {

res.cookie(COOKIE_NAME_VACDM_TOKEN, token);

// if (state) {
// TODO: redirect to url set in state
// }
if (state) {
// TODO: we need some sort of validation here - it mustn't be an external url
return res.redirect(state);
}

res.redirect('/');
}
Expand Down
99 changes: 99 additions & 0 deletions src/backend/plugin-token/plugin-token.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { BadRequestException, Body, Controller, Get, HttpCode, NotFoundException, Param, Post, Res, UnauthorizedException } from '@nestjs/common';
import { ApiExcludeController } from '@nestjs/swagger';
import { Response } from 'express';

import { User } from '../auth/auth.decorator';
import getAppConfig from '../config';
import logger from '../logger';
import { UserDocument } from '../user/user.model';

import { PluginTokenService } from './plugin-token.service';

const { publicUrl } = getAppConfig();

@ApiExcludeController()
@Controller('/api/plugin-token')
export class PluginTokenController {
constructor(
private pluginTokenService: PluginTokenService,
) {}

@Post('start')
async startPluginAuthFlow() {
const token = await this.pluginTokenService.startPluginAuthFlow();

return {
userRedirectUrl: `${publicUrl}/api/plugin-token/authorize/${token._id}`,
pollingUrl: `${publicUrl}/api/plugin-token/poll/${token._id}`,
pollingSecret: token.pollingSecret,
};
}

/**
* Endpoint for user to actually authorize plugin token
* @param tokenId
* @param user
* @returns
*/
@HttpCode(200)
@Post('/authorize/:id')
async authorizePluginToken(@Param('id') tokenId: string, @User() user: UserDocument, @Body('confirm') confirmation: string, @Body('label') label: string) {
if (!user) {
throw new UnauthorizedException();
}

if (confirmation !== 'yes') {
throw new BadRequestException();
}

const wasAuthorized = await this.pluginTokenService.userAuthorizeToken(user._id, tokenId, label);

if (!wasAuthorized) {
throw new NotFoundException();
}

return {
tokenId: tokenId,
userId: user._id,
};
}

/**
* Endpoint for plugin to redirect user to, redirects to either frontend or authentication endpoints
* @param id
* @param res
* @param user
* @returns
*/
@Get('/authorize/:id')
async startAuthorizePluginToken(@Param('id') id: string, @Res() res: Response, @User() user: UserDocument) {
logger.debug('id: %s', id);

const isFlowValid = await this.pluginTokenService.isFlowIdValid(id);

if (!isFlowValid) {
return res.redirect('/authorize-plugin-forbidden');
}

const url = `/authorize-plugin/${id}`;

if (user) {
return res.redirect(url);
} else {
return res.redirect(`/api/auth?state=${encodeURI(url)}`);
}
}

@HttpCode(200)
@Post('/poll/:id')
async pollingPluginToken(@Param('id') id: string, @Body('secret') pollingSecret: string) {
logger.debug('id: %s, pollingSecret: %s', id, pollingSecret);

const token = await this.pluginTokenService.exchangePollingSecretForToken(id, pollingSecret);

return {
ready: !!token,
token,
};
}
}
26 changes: 26 additions & 0 deletions src/backend/plugin-token/plugin-token.model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import mongoose, { HydratedDocument, Model } from 'mongoose';
import ms from 'ms';

import { DB_PROVIDER } from '../database.module';

import PluginToken from '@/shared/interfaces/plugin-token.interface';

export const PLUGINTOKEN_MODEL = 'PLUGINTOKEN_MODEL';
export type PluginTokenModel = Model<PluginToken>;
export type PluginTokenDocument = HydratedDocument<PluginToken>;

const PluginTokenSchema = new mongoose.Schema<PluginToken>({
user: { type: String },
label: { type: String, default: 'New token' },
pollingSecret: { type: String, required: true },
token: { type: String, required: true },
lastUsed: { type: Date, default: Date.now },
}, { timestamps: true });

PluginTokenSchema.index({ lastUsed: 1 }, { expireAfterSeconds: ms('30d') / 1000 });

export const PluginTokenProvider = {
provide: PLUGINTOKEN_MODEL,
useFactory: (connection: typeof mongoose) => connection.model('PluginToken', PluginTokenSchema),
inject: [DB_PROVIDER],
};
16 changes: 16 additions & 0 deletions src/backend/plugin-token/plugin-token.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';

import { DatabaseModule } from '../database.module';
import { UtilsModule } from '../utils/utils.module';

import { PluginTokenController } from './plugin-token.controller';
import { PluginTokenProvider } from './plugin-token.model';
import { PluginTokenService } from './plugin-token.service';

@Module({
imports: [DatabaseModule, UtilsModule],
providers: [PluginTokenService, PluginTokenProvider],
exports: [PluginTokenService],
controllers: [PluginTokenController],
})
export class PluginTokenModule {}
83 changes: 83 additions & 0 deletions src/backend/plugin-token/plugin-token.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { Inject, Injectable } from '@nestjs/common';
import mongoose from 'mongoose';

import { UtilsService } from '../utils/utils.service';

import { PLUGINTOKEN_MODEL, PluginTokenDocument, PluginTokenModel } from './plugin-token.model';

@Injectable()
export class PluginTokenService {
constructor(
private utilsService: UtilsService,
@Inject(PLUGINTOKEN_MODEL) private pluginTokenModel: PluginTokenModel,
) {}

/**
* start new authentication flow for plugin auth
* @returns new plugin token document
*/
async startPluginAuthFlow(): Promise<PluginTokenDocument> {
const token = await this.pluginTokenModel.create({
token: this.utilsService.generateRandomBytes(),
pollingSecret: this.utilsService.generateRandomBytes(),
});

return token;
}

/**
* exchange pollingsecret for token, once user has finished authentication and consent
* @param pollingSecret pollingsecret provided by plugin
* @returns token or undefined if pollingsecret not valid
*/
async exchangePollingSecretForToken(id: string, pollingSecret: string): Promise<string | undefined> {
const pluginToken = await this.pluginTokenModel.findOneAndUpdate({
_id: id,
pollingSecret,
user: { $not: { $eq: null } },
}, {
$set: {
pollingSecret: null,
},
});

return pluginToken?.token;
}

/**
* get user id from token
* @param token token for plugin authentication
* @returns user id if token is valid, undefined otherwise
*/
async attemptPluginAuthentication(token: string): Promise<string | undefined> {
const pluginToken = await this.pluginTokenModel.findOneAndUpdate({
token,
pollingSecret: null,
}, {
$set: {
lastUsed: Date.now(),
},
});

return pluginToken?.user;
}

async userAuthorizeToken(userId: string | mongoose.Types.ObjectId, tokenId: string, label = 'Token'): Promise<boolean> {
const pluginToken = await this.pluginTokenModel.findOneAndUpdate({
_id: tokenId,
}, {
$set: {
user: userId,
label,
},
});

return !!pluginToken;
}

async isFlowIdValid(id: string): Promise<boolean> {
const count = await this.pluginTokenModel.count({ _id: id });

return count > 0;
}
}
6 changes: 6 additions & 0 deletions src/backend/utils/utils.service.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import crypto from 'node:crypto';

import { Injectable } from '@nestjs/common';

import logger from '../logger';
Expand Down Expand Up @@ -107,4 +109,8 @@ export class UtilsService {

return plausibleDate;
}

generateRandomBytes(length = 32, encoding: BufferEncoding = 'base64') {
return crypto.randomBytes(length).toString(encoding);
}
}
2 changes: 2 additions & 0 deletions src/frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { DarkModeProvider } from './contexts/DarkModeProvider';
import AirportDetails from './pages/AirportDetails';
import Airports from './pages/Airports';
import AuthFailurePage from './pages/AuthFailure';
import AuthorizePluginPage from './pages/AuthorizePlugin';
import Debug from './pages/Debug';
import Delivery from './pages/Delivery';
import FlowManagement from './pages/FlowManagement';
Expand Down Expand Up @@ -83,6 +84,7 @@ function App() {
<Route path="/landingpage" element={<Landingpage />} />
<Route path="/delivery" element={<Delivery />} />
<Route path="/auth-failure" element={<AuthFailurePage />} />
<Route path='/authorize-plugin/:id' element={<AuthorizePluginPage />} />
<Route path="/" element={<Landingpage />} />
<Route path="*" element={<Navigate to="/" />} />
</Routes>
Expand Down
Loading

0 comments on commit 701ad17

Please sign in to comment.