Add /api/website/allow endpoint

This commit is contained in:
Dominik Korsa 2021-01-23 13:59:57 +01:00
parent 9aa8ee6105
commit ca78ddc078
No known key found for this signature in database
GPG key ID: 546F986F71A6FE6E
14 changed files with 305 additions and 126 deletions

View file

@ -2,7 +2,9 @@
<module type="WEB_MODULE" version="4"> <module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true"> <component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output /> <exclude-output />
<content url="file://$MODULE_DIR$" /> <content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/dist" />
</content>
<orderEntry type="inheritedJdk" /> <orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" /> <orderEntry type="sourceFolder" forTests="false" />
</component> </component>

View file

@ -1082,6 +1082,11 @@
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="
}, },
"base64url": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz",
"integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A=="
},
"binary-extensions": { "binary-extensions": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz",

View file

@ -20,6 +20,8 @@
"@types/url-join": "^4.0.0", "@types/url-join": "^4.0.0",
"@wulkanowy/sdk": "^0.1.1", "@wulkanowy/sdk": "^0.1.1",
"apollo-server-fastify": "^2.19.2", "apollo-server-fastify": "^2.19.2",
"base64url": "^3.0.1",
"date-fns": "^2.16.1",
"dotenv": "^8.2.0", "dotenv": "^8.2.0",
"fastify": "^3.10.1", "fastify": "^3.10.1",
"fastify-cookie": "^5.1.0", "fastify-cookie": "^5.1.0",

52
backend/src/codes.ts Normal file
View file

@ -0,0 +1,52 @@
import { addSeconds, isAfter } from 'date-fns';
import type { FastifyLoggerInstance } from 'fastify';
import { nanoid } from 'nanoid';
import type { CodeChallenge, CodeContent, CodeInfo } from './types';
import { createKey, encryptSymmetrical } from './utils';
const codes = new Map<string, CodeInfo>();
export function createCode(options: {
clientId: string;
scopes: string[];
studentIds: number[];
publicKey: string;
tokenKey: string;
encryptedPrivateKey: string;
encryptedPassword: string;
encryptedSDK: string;
codeChallenge?: CodeChallenge
}): string {
const expires = addSeconds(new Date(), 60);
const tokenSecret = createKey();
let id: string;
do {
id = nanoid();
} while (codes.has(id));
codes.set(id, {
expires,
id,
clientId: options.clientId,
scopes: options.scopes,
studentIds: options.studentIds,
publicKey: options.publicKey,
encryptedPrivateKey: options.encryptedPrivateKey,
encryptedSDK: options.encryptedSDK,
encryptedPassword: options.encryptedPassword,
tokenSecret,
codeChallenge: options.codeChallenge,
});
const content: CodeContent = {
tk: options.tokenKey,
};
return `${id}~${encryptSymmetrical(JSON.stringify(content), tokenSecret)}`;
}
export function cleanUpCodes(logger: FastifyLoggerInstance): void {
codes.forEach((code) => {
if (isAfter(new Date(), code.expires)) {
codes.delete(code.id);
logger.info(`Code ${code.id} expired`);
}
});
}

View file

@ -8,6 +8,7 @@ import FastifyCookie from 'fastify-cookie';
import FastifyHttpProxy from 'fastify-http-proxy'; import FastifyHttpProxy from 'fastify-http-proxy';
import FastifySensible from 'fastify-sensible'; import FastifySensible from 'fastify-sensible';
import FastifySession from 'fastify-session'; import FastifySession from 'fastify-session';
import { cleanUpCodes } from './codes';
import { websitePrefix } from './constants'; import { websitePrefix } from './constants';
import database from './database/database'; import database from './database/database';
import registerOAuth from './routes/oauth2'; import registerOAuth from './routes/oauth2';
@ -48,6 +49,10 @@ async function start() {
const port = parseIntStrict(requireEnv('PORT')); const port = parseIntStrict(requireEnv('PORT'));
await server.listen(port); await server.listen(port);
server.log.info(`Listening on port ${port}`); server.log.info(`Listening on port ${port}`);
setInterval(() => {
cleanUpCodes(server.log);
}, 15000);
} }
start() start()

View file

@ -8,7 +8,7 @@ import type { MyFastifyInstance, StudentsMode } from '../../types';
import { import {
createKey, createKey,
getSessionData, isObject, parseScopeParam, validateOptionalParam, validateParam, getSessionData, isObject, parseArrayParam, validateOptionalParam, validateParam,
} from '../../utils'; } from '../../utils';
export default function registerAuthorize(server: MyFastifyInstance): void { export default function registerAuthorize(server: MyFastifyInstance): void {
@ -76,7 +76,7 @@ export default function registerAuthorize(server: MyFastifyInstance): void {
} }
const studentsMode = request.query.students_mode as StudentsMode; const studentsMode = request.query.students_mode as StudentsMode;
const requestedScopes = _.uniq(parseScopeParam('scope', request.query.scope)); const requestedScopes = _.uniq(parseArrayParam('scope', request.query.scope));
requestedScopes.forEach((scope) => { requestedScopes.forEach((scope) => {
if (!scopes.includes(scope)) { if (!scopes.includes(scope)) {
throw new ScopeError(`Unknown scope ${scope}`); throw new ScopeError(`Unknown scope ${scope}`);

View file

@ -0,0 +1,82 @@
import { URL } from 'url';
import { createCode } from '../../codes';
import { ParamError } from '../../errors';
import type { MyFastifyInstance, SerializedSDK } from '../../types';
import { StudentsMode } from '../../types';
import {
decryptSymmetrical, encryptSymmetrical,
getSessionData, isObject, parseArrayParam, parseIntStrict, validateParam,
} from '../../utils';
export default function registerAllow(server: MyFastifyInstance): void {
server.get('/allow', async (
request,
reply,
) => {
if (!isObject(request.query)) {
server.log.warn('Request query is not an object');
throw server.httpErrors.badRequest();
}
try {
validateParam('prompt_id', request.query.prompt_id);
} catch (error) {
if (error instanceof ParamError) {
throw server.httpErrors.badRequest(error.message);
}
server.log.error(error);
throw server.httpErrors.internalServerError();
}
const encryptedTokenKey: string | undefined = request.cookies[`etk-${request.query.prompt_id}`];
if (!encryptedTokenKey) throw server.httpErrors.badRequest('Missing etk cookie');
const sessionData = getSessionData(request.session);
const prompt = sessionData.prompts.get(request.query.prompt_id);
if (!prompt) throw server.httpErrors.badRequest('Prompt data not found');
if (!prompt.loginInfo) throw server.httpErrors.badRequest('Login data not provided');
const tokenKey = decryptSymmetrical(encryptedTokenKey, prompt.promptSecret);
const serializedSDK = JSON.parse(decryptSymmetrical(prompt.loginInfo.encryptedSDK, tokenKey)) as SerializedSDK;
let studentIds: number[] = [];
if (prompt.studentsMode !== StudentsMode.None) {
try {
studentIds = parseArrayParam('student_ids', request.query.student_ids).map(parseIntStrict);
if (studentIds.length === 0) throw new ParamError('student_ids should not be empty');
} catch (error) {
if (error instanceof ParamError) {
throw server.httpErrors.badRequest(error.message);
}
server.log.error(error);
throw server.httpErrors.internalServerError();
}
}
const newSerializedSDK: SerializedSDK = {
client: serializedSDK.client,
diaries: serializedSDK.diaries.filter((diary) => studentIds.includes(diary.info.studentId)),
};
const newEncryptedSDK = encryptSymmetrical(JSON.stringify(newSerializedSDK), tokenKey);
const code = createCode({
studentIds,
scopes: prompt.scopes,
clientId: prompt.clientId,
publicKey: prompt.loginInfo.publicKey,
encryptedSDK: newEncryptedSDK,
encryptedPassword: prompt.loginInfo.encryptedPassword,
encryptedPrivateKey: prompt.loginInfo.encryptedPrivateKey,
tokenKey,
});
// TODO: Find why the promise never resolves
reply.clearCookie(`etk-${request.query.prompt_id}`);
// In case execution of setCookie takes some time
// TODO: Remove
await new Promise((resolve) => setTimeout(resolve, 100));
const redirectUri = new URL(prompt.redirectUri);
redirectUri.searchParams.set('code', code);
if (prompt.state) redirectUri.searchParams.set('state', prompt.state);
await reply.redirect(redirectUri.toString());
sessionData.prompts.delete(request.query.prompt_id);
});
}

View file

@ -0,0 +1,35 @@
import { ParamError } from '../../errors';
import type { MyFastifyInstance } from '../../types';
import { getSessionData, isObject, validateParam } from '../../utils';
export default function registerDeny(server: MyFastifyInstance): void {
server.get('/deny', async (
request,
reply,
) => {
if (!isObject(request.query)) {
server.log.warn('Request query is not an object');
throw server.httpErrors.badRequest();
}
try {
validateParam('prompt_id', request.query.prompt_id);
} catch (error) {
if (error instanceof ParamError) {
throw server.httpErrors.badRequest(error.message);
}
server.log.error(error);
throw server.httpErrors.internalServerError();
}
// TODO: Find why the promise never resolves
reply.clearCookie(`epk-${request.query.prompt_id}`);
// In case execution of setCookie takes some time
// TODO: Remove
await new Promise((resolve) => setTimeout(resolve, 100));
const sessionData = getSessionData(request.session);
const prompt = sessionData.prompts.get(request.query.prompt_id);
if (!prompt) throw server.httpErrors.badRequest('Prompt data not found');
await reply.redirect(`${prompt.redirectUri}?error=access_denied&error_description=${encodeURIComponent('User denied')}`);
sessionData.prompts.delete(request.query.prompt_id);
});
}

View file

@ -1,8 +1,9 @@
import { ApolloServer } from 'apollo-server-fastify'; import { ApolloServer } from 'apollo-server-fastify';
import { buildSchema } from 'type-graphql'; import { buildSchema } from 'type-graphql';
import { ParamError } from '../../errors';
import type { ApolloContext, MyFastifyInstance } from '../../types'; import type { ApolloContext, MyFastifyInstance } from '../../types';
import { getSessionData, isObject, validateParam } from '../../utils'; import { getSessionData } from '../../utils';
import registerAllow from './allow';
import registerDeny from './deny';
import LoginResolver from './resolvers/login-resolver'; import LoginResolver from './resolvers/login-resolver';
import PromptInfoResolver from './resolvers/prompt-info-resolver'; import PromptInfoResolver from './resolvers/prompt-info-resolver';
import type { WebsiteAPIContext } from './types'; import type { WebsiteAPIContext } from './types';
@ -28,33 +29,6 @@ export default async function registerWebsiteApi(server: MyFastifyInstance): Pro
}, },
})); }));
server.get('/deny', async ( registerDeny(server);
request, registerAllow(server);
reply,
) => {
if (!isObject(request.query)) {
server.log.warn('Request query is not an object');
throw server.httpErrors.badRequest();
}
try {
validateParam('prompt_id', request.query.prompt_id);
} catch (error) {
if (error instanceof ParamError) {
throw server.httpErrors.badRequest(error.message);
}
server.log.error(error);
throw server.httpErrors.internalServerError();
}
// TODO: Find why the promise never resolves
reply.clearCookie(`epk-${request.query.prompt_id}`);
// In case execution of setCookie takes some time
// TODO: Remove
await new Promise((resolve) => setTimeout(resolve, 100));
const sessionData = getSessionData(request.session);
const prompt = sessionData.prompts.get(request.query.prompt_id);
if (!prompt) throw server.httpErrors.badRequest('Prompt data not found');
await reply.redirect(`${prompt.redirectUri}?error=access_denied&error_description=${encodeURIComponent('User denied')}`);
sessionData.prompts.delete(request.query.prompt_id);
});
} }

View file

@ -7,6 +7,7 @@ import {
} from 'type-graphql'; } from 'type-graphql';
import type { SerializedSDK } from '../../../types'; import type { SerializedSDK } from '../../../types';
import { import {
createKey,
encryptSymmetrical, encryptWithPublicKey, generatePrivatePublicPair, isObject, verifyCaptchaResponse, encryptSymmetrical, encryptWithPublicKey, generatePrivatePublicPair, isObject, verifyCaptchaResponse,
} from '../../../utils'; } from '../../../utils';
import { CaptchaError, InvalidVulcanCredentialsError, UnknownPromptError } from '../errors'; import { CaptchaError, InvalidVulcanCredentialsError, UnknownPromptError } from '../errors';
@ -39,12 +40,6 @@ export default class LoginResolver {
throw error; throw error;
} }
const diaryList = await client.getDiaryList(); const diaryList = await client.getDiaryList();
const { privateKey, publicKey } = await generatePrivatePublicPair();
const encryptedPrivateKey = encryptSymmetrical(
privateKey,
prompt.promptSecret,
);
const encryptedPassword = encryptWithPublicKey(password, publicKey);
const diaryStudents = _.groupBy(diaryList.map((e) => e.serialized.info), 'studentId'); const diaryStudents = _.groupBy(diaryList.map((e) => e.serialized.info), 'studentId');
const students = _.toPairs(diaryStudents) const students = _.toPairs(diaryStudents)
.map(([, diaryInfoList]: [string, DiaryInfo[]]) => diaryInfoList[0]) .map(([, diaryInfoList]: [string, DiaryInfo[]]) => diaryInfoList[0])
@ -56,9 +51,17 @@ export default class LoginResolver {
client: client.serialize(), client: client.serialize(),
diaries: diaryList.map(({ serialized }) => serialized), diaries: diaryList.map(({ serialized }) => serialized),
}; };
const encryptedSDK = encryptWithPublicKey(JSON.stringify(serializedSDK), publicKey);
const { privateKey, publicKey } = await generatePrivatePublicPair();
const tokenKey = createKey();
const encryptedSDK = encryptSymmetrical(JSON.stringify(serializedSDK), tokenKey);
const encryptedPassword = encryptWithPublicKey(password, publicKey);
const encryptedPrivateKey = encryptSymmetrical(privateKey, tokenKey);
const encryptedTokenKey = encryptSymmetrical(tokenKey, prompt.promptSecret);
prompt.loginInfo = { prompt.loginInfo = {
encryptedPassword, encryptedPassword,
encryptedPrivateKey,
encryptedSDK, encryptedSDK,
publicKey, publicKey,
host, host,
@ -66,7 +69,7 @@ export default class LoginResolver {
availableStudentIds: students.map(({ studentId }) => studentId), availableStudentIds: students.map(({ studentId }) => studentId),
}; };
// TODO: Find why the promise never resolves // TODO: Find why the promise never resolves
reply.setCookie(`epk-${promptId}`, encryptedPrivateKey, { reply.setCookie(`etk-${promptId}`, encryptedTokenKey, {
sameSite: 'strict', sameSite: 'strict',
httpOnly: true, httpOnly: true,
path: '/', path: '/',

View file

@ -21,22 +21,25 @@ registerEnumType(StudentsMode, {
name: 'StudentsMode', name: 'StudentsMode',
}); });
export interface CodeChallenge {
value: string;
method: 'plain' | 'S256';
}
export interface Prompt { export interface Prompt {
clientId: string; clientId: string;
redirectUri: string; redirectUri: string;
scopes: string[], scopes: string[],
state?: string; state?: string;
codeChallenge?: { codeChallenge?: CodeChallenge;
value: string;
method: 'plain' | 'S256';
};
studentsMode: StudentsMode; studentsMode: StudentsMode;
promptSecret: Buffer; promptSecret: string;
loginInfo?: { loginInfo?: {
host: string; host: string;
username: string; username: string;
encryptedPassword: string; encryptedPassword: string;
encryptedSDK: string; encryptedSDK: string;
encryptedPrivateKey: string;
publicKey: string; publicKey: string;
availableStudentIds: number[]; availableStudentIds: number[];
}; };
@ -61,3 +64,21 @@ export interface ApolloContext {
request: FastifyRequest; request: FastifyRequest;
reply: FastifyReply, reply: FastifyReply,
} }
export interface CodeInfo {
id: string;
expires: Date;
scopes: string[];
clientId: string;
studentIds: number[];
tokenSecret: string;
publicKey: string;
encryptedPassword: string;
encryptedSDK: string;
encryptedPrivateKey: string;
codeChallenge?: CodeChallenge;
}
export interface CodeContent {
tk: string;
}

View file

@ -1,12 +1,13 @@
import * as crypto from 'crypto'; import * as crypto from 'crypto';
import * as util from 'util'; import * as util from 'util';
import base64url from 'base64url';
export function generatePrivatePublicPair(): Promise<{ export function generatePrivatePublicPair(): Promise<{
privateKey: string; privateKey: string;
publicKey: string; publicKey: string;
}> { }> {
return util.promisify(crypto.generateKeyPair)('rsa', { return util.promisify(crypto.generateKeyPair)('rsa', {
modulusLength: 1024, modulusLength: 2048,
publicKeyEncoding: { publicKeyEncoding: {
type: 'spki', type: 'spki',
format: 'pem', format: 'pem',
@ -18,30 +19,30 @@ export function generatePrivatePublicPair(): Promise<{
}); });
} }
export function createKey(): Buffer { export function createKey(): string {
return crypto.randomBytes(32); return base64url.encode(crypto.randomBytes(32));
} }
export function encryptSymmetrical(value: string, key: Buffer): string { export function encryptSymmetrical(value: string, key: string): string {
const ivBuffer = crypto.randomBytes(16); const ivBuffer = crypto.randomBytes(16);
const cipher = crypto.createCipheriv('aes-256-cbc', key, ivBuffer); const cipher = crypto.createCipheriv('aes-256-cbc', base64url.toBuffer(key), ivBuffer);
const encrypted = Buffer.concat([cipher.update(value), cipher.final()]); const encrypted = Buffer.concat([cipher.update(value), cipher.final()]);
return `${encrypted.toString('base64')}@${ivBuffer.toString('base64')}`; return `${base64url.encode(encrypted)}@${base64url.encode(ivBuffer)}`;
} }
export function decryptSymmetrical(encrypted: string, key: Buffer): string { export function decryptSymmetrical(encrypted: string, key: string): string {
const [iv, encryptedData] = encrypted.split('@'); const [encryptedData, iv] = encrypted.split('@');
const ivBuffer = Buffer.from(iv, 'base64'); const ivBuffer = base64url.toBuffer(iv);
const encryptedBuffer = Buffer.from(encryptedData, 'base64'); const encryptedBuffer = base64url.toBuffer(encryptedData);
const decipher = crypto.createDecipheriv('aes-256-cbc', key, ivBuffer); const decipher = crypto.createDecipheriv('aes-256-cbc', base64url.toBuffer(key), ivBuffer);
const decrypted = Buffer.concat([decipher.update(encryptedBuffer), decipher.final()]); const decrypted = Buffer.concat([decipher.update(encryptedBuffer), decipher.final()]);
return decrypted.toString(); return decrypted.toString();
} }
export function encryptWithPublicKey(value: string, publicKey: string): string { export function encryptWithPublicKey(value: string, publicKey: string): string {
return crypto.publicEncrypt(publicKey, Buffer.from(value)).toString('base64'); return base64url.encode(crypto.publicEncrypt(publicKey, Buffer.from(value)));
} }
export function decryptWithPrivateKey(encrypted: string, privateKey: string): string { export function decryptWithPrivateKey(encrypted: string, privateKey: string): string {
return crypto.privateDecrypt(privateKey, Buffer.from(encrypted, 'base64')).toString(); return crypto.privateDecrypt(privateKey, base64url.toBuffer(encrypted)).toString();
} }

View file

@ -18,8 +18,8 @@ export function requireEnvBase64(name: string): Buffer {
return Buffer.from(requireEnv(name), 'base64'); return Buffer.from(requireEnv(name), 'base64');
} }
export function parseIntStrict(value: string, radix = 10): number { export function parseIntStrict(value: string): number {
const number = parseInt(value, radix); const number = parseInt(value, 10);
if (_.isNaN(number)) throw new Error(`Cannot parse ${value} to int`); if (_.isNaN(number)) throw new Error(`Cannot parse ${value} to int`);
return number; return number;
} }
@ -44,13 +44,13 @@ export function validateOptionalParam(key: string, value: unknown): asserts valu
if (typeof value !== 'string') throw new ParamError(`${key} param should be a string`); if (typeof value !== 'string') throw new ParamError(`${key} param should be a string`);
} }
export function parseScopeParam(key: string, value: unknown): string[] { export function parseArrayParam(key: string, value: unknown): string[] {
if (value === undefined) throw new ParamError(`${key} param is missing`); if (value === undefined) throw new ParamError(`${key} param is missing`);
if (typeof value === 'string') { if (typeof value === 'string') {
if (value === '') return []; if (value === '') return [];
return value.split('+').map((scope) => scope.trim()); return value.split(/[+ ]/g).map((item) => item.trim());
} }
if (_.isArray(value)) return value.flatMap((e) => parseScopeParam(key, e)); if (_.isArray(value)) return value.flatMap((e) => parseArrayParam(key, e));
throw new ParamError(`${key} param should be a string or an array of strings`); throw new ParamError(`${key} param should be a string or an array of strings`);
} }
@ -75,7 +75,6 @@ export async function verifyCaptchaResponse(response: string): Promise<boolean>
response, response,
}, },
}); });
console.log(body);
return body.success; return body.success;
} }

View file

@ -1,6 +1,5 @@
<template> <template>
<div> <div>
<v-form @submit.prevent="submit">
<v-card-title <v-card-title
v-if="mode === StudentsMode.One" v-if="mode === StudentsMode.One"
> >
@ -59,7 +58,6 @@
Przydziel dostęp Przydziel dostęp
</v-btn> </v-btn>
</v-card-actions> </v-card-actions>
</v-form>
</div> </div>
</template> </template>
@ -113,7 +111,7 @@ export default class StudentsWindow extends Vue {
get allowUrl() { get allowUrl() {
if (!this.valid) return null; if (!this.valid) return null;
if (this.mode === StudentsMode.One) return `/api/website/allow?prompt_id=${this.promptInfo.id}`; if (this.mode === StudentsMode.None) return `/api/website/allow?prompt_id=${this.promptInfo.id}`;
const studentIds = this.pickedStudents.map(encodeURIComponent).join('+'); const studentIds = this.pickedStudents.map(encodeURIComponent).join('+');
return `/api/website/allow?prompt_id=${this.promptInfo.id}&student_ids=${studentIds}`; return `/api/website/allow?prompt_id=${this.promptInfo.id}&student_ids=${studentIds}`;
} }