diff --git a/backend/backend.iml b/backend/backend.iml index 8021953..c3e779f 100644 --- a/backend/backend.iml +++ b/backend/backend.iml @@ -2,7 +2,9 @@ - + + + diff --git a/backend/package-lock.json b/backend/package-lock.json index 438a07b..bdba558 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1082,6 +1082,11 @@ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "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": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", diff --git a/backend/package.json b/backend/package.json index b57d9fd..bb55b66 100644 --- a/backend/package.json +++ b/backend/package.json @@ -20,6 +20,8 @@ "@types/url-join": "^4.0.0", "@wulkanowy/sdk": "^0.1.1", "apollo-server-fastify": "^2.19.2", + "base64url": "^3.0.1", + "date-fns": "^2.16.1", "dotenv": "^8.2.0", "fastify": "^3.10.1", "fastify-cookie": "^5.1.0", diff --git a/backend/src/codes.ts b/backend/src/codes.ts new file mode 100644 index 0000000..fb53f7c --- /dev/null +++ b/backend/src/codes.ts @@ -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(); + +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`); + } + }); +} diff --git a/backend/src/index.ts b/backend/src/index.ts index dec51d2..fd7bb8d 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -8,6 +8,7 @@ import FastifyCookie from 'fastify-cookie'; import FastifyHttpProxy from 'fastify-http-proxy'; import FastifySensible from 'fastify-sensible'; import FastifySession from 'fastify-session'; +import { cleanUpCodes } from './codes'; import { websitePrefix } from './constants'; import database from './database/database'; import registerOAuth from './routes/oauth2'; @@ -48,6 +49,10 @@ async function start() { const port = parseIntStrict(requireEnv('PORT')); await server.listen(port); server.log.info(`Listening on port ${port}`); + + setInterval(() => { + cleanUpCodes(server.log); + }, 15000); } start() diff --git a/backend/src/routes/oauth2/authorize.ts b/backend/src/routes/oauth2/authorize.ts index a37b0d5..79552bf 100644 --- a/backend/src/routes/oauth2/authorize.ts +++ b/backend/src/routes/oauth2/authorize.ts @@ -8,7 +8,7 @@ import type { MyFastifyInstance, StudentsMode } from '../../types'; import { createKey, - getSessionData, isObject, parseScopeParam, validateOptionalParam, validateParam, + getSessionData, isObject, parseArrayParam, validateOptionalParam, validateParam, } from '../../utils'; 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 requestedScopes = _.uniq(parseScopeParam('scope', request.query.scope)); + const requestedScopes = _.uniq(parseArrayParam('scope', request.query.scope)); requestedScopes.forEach((scope) => { if (!scopes.includes(scope)) { throw new ScopeError(`Unknown scope ${scope}`); diff --git a/backend/src/routes/website-api/allow.ts b/backend/src/routes/website-api/allow.ts new file mode 100644 index 0000000..3ceefc2 --- /dev/null +++ b/backend/src/routes/website-api/allow.ts @@ -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); + }); +} diff --git a/backend/src/routes/website-api/deny.ts b/backend/src/routes/website-api/deny.ts new file mode 100644 index 0000000..75762fc --- /dev/null +++ b/backend/src/routes/website-api/deny.ts @@ -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); + }); +} diff --git a/backend/src/routes/website-api/index.ts b/backend/src/routes/website-api/index.ts index 415dcee..d39a050 100644 --- a/backend/src/routes/website-api/index.ts +++ b/backend/src/routes/website-api/index.ts @@ -1,8 +1,9 @@ import { ApolloServer } from 'apollo-server-fastify'; import { buildSchema } from 'type-graphql'; -import { ParamError } from '../../errors'; 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 PromptInfoResolver from './resolvers/prompt-info-resolver'; import type { WebsiteAPIContext } from './types'; @@ -28,33 +29,6 @@ export default async function registerWebsiteApi(server: MyFastifyInstance): Pro }, })); - 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); - }); + registerDeny(server); + registerAllow(server); } diff --git a/backend/src/routes/website-api/resolvers/login-resolver.ts b/backend/src/routes/website-api/resolvers/login-resolver.ts index 13caac5..93f4435 100644 --- a/backend/src/routes/website-api/resolvers/login-resolver.ts +++ b/backend/src/routes/website-api/resolvers/login-resolver.ts @@ -7,6 +7,7 @@ import { } from 'type-graphql'; import type { SerializedSDK } from '../../../types'; import { + createKey, encryptSymmetrical, encryptWithPublicKey, generatePrivatePublicPair, isObject, verifyCaptchaResponse, } from '../../../utils'; import { CaptchaError, InvalidVulcanCredentialsError, UnknownPromptError } from '../errors'; @@ -39,12 +40,6 @@ export default class LoginResolver { throw error; } 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 students = _.toPairs(diaryStudents) .map(([, diaryInfoList]: [string, DiaryInfo[]]) => diaryInfoList[0]) @@ -56,9 +51,17 @@ export default class LoginResolver { client: client.serialize(), 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 = { encryptedPassword, + encryptedPrivateKey, encryptedSDK, publicKey, host, @@ -66,7 +69,7 @@ export default class LoginResolver { availableStudentIds: students.map(({ studentId }) => studentId), }; // TODO: Find why the promise never resolves - reply.setCookie(`epk-${promptId}`, encryptedPrivateKey, { + reply.setCookie(`etk-${promptId}`, encryptedTokenKey, { sameSite: 'strict', httpOnly: true, path: '/', diff --git a/backend/src/types.ts b/backend/src/types.ts index 8101df6..a062a45 100644 --- a/backend/src/types.ts +++ b/backend/src/types.ts @@ -21,22 +21,25 @@ registerEnumType(StudentsMode, { name: 'StudentsMode', }); +export interface CodeChallenge { + value: string; + method: 'plain' | 'S256'; +} + export interface Prompt { clientId: string; redirectUri: string; scopes: string[], state?: string; - codeChallenge?: { - value: string; - method: 'plain' | 'S256'; - }; + codeChallenge?: CodeChallenge; studentsMode: StudentsMode; - promptSecret: Buffer; + promptSecret: string; loginInfo?: { host: string; username: string; encryptedPassword: string; encryptedSDK: string; + encryptedPrivateKey: string; publicKey: string; availableStudentIds: number[]; }; @@ -61,3 +64,21 @@ export interface ApolloContext { request: FastifyRequest; 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; +} diff --git a/backend/src/utils/crypto.ts b/backend/src/utils/crypto.ts index a9beab3..783dac5 100644 --- a/backend/src/utils/crypto.ts +++ b/backend/src/utils/crypto.ts @@ -1,12 +1,13 @@ import * as crypto from 'crypto'; import * as util from 'util'; +import base64url from 'base64url'; export function generatePrivatePublicPair(): Promise<{ privateKey: string; publicKey: string; }> { return util.promisify(crypto.generateKeyPair)('rsa', { - modulusLength: 1024, + modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem', @@ -18,30 +19,30 @@ export function generatePrivatePublicPair(): Promise<{ }); } -export function createKey(): Buffer { - return crypto.randomBytes(32); +export function createKey(): string { + 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 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()]); - return `${encrypted.toString('base64')}@${ivBuffer.toString('base64')}`; + return `${base64url.encode(encrypted)}@${base64url.encode(ivBuffer)}`; } -export function decryptSymmetrical(encrypted: string, key: Buffer): string { - const [iv, encryptedData] = encrypted.split('@'); - const ivBuffer = Buffer.from(iv, 'base64'); - const encryptedBuffer = Buffer.from(encryptedData, 'base64'); - const decipher = crypto.createDecipheriv('aes-256-cbc', key, ivBuffer); +export function decryptSymmetrical(encrypted: string, key: string): string { + const [encryptedData, iv] = encrypted.split('@'); + const ivBuffer = base64url.toBuffer(iv); + const encryptedBuffer = base64url.toBuffer(encryptedData); + const decipher = crypto.createDecipheriv('aes-256-cbc', base64url.toBuffer(key), ivBuffer); const decrypted = Buffer.concat([decipher.update(encryptedBuffer), decipher.final()]); return decrypted.toString(); } 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 { - return crypto.privateDecrypt(privateKey, Buffer.from(encrypted, 'base64')).toString(); + return crypto.privateDecrypt(privateKey, base64url.toBuffer(encrypted)).toString(); } diff --git a/backend/src/utils/index.ts b/backend/src/utils/index.ts index 541915f..e912437 100644 --- a/backend/src/utils/index.ts +++ b/backend/src/utils/index.ts @@ -18,8 +18,8 @@ export function requireEnvBase64(name: string): Buffer { return Buffer.from(requireEnv(name), 'base64'); } -export function parseIntStrict(value: string, radix = 10): number { - const number = parseInt(value, radix); +export function parseIntStrict(value: string): number { + const number = parseInt(value, 10); if (_.isNaN(number)) throw new Error(`Cannot parse ${value} to int`); 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`); } -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 (typeof value === 'string') { 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`); } @@ -75,7 +75,6 @@ export async function verifyCaptchaResponse(response: string): Promise response, }, }); - console.log(body); return body.success; } diff --git a/website/src/compontents/authenticate-prompt-windows/students-window.vue b/website/src/compontents/authenticate-prompt-windows/students-window.vue index 70e681f..c9823b3 100644 --- a/website/src/compontents/authenticate-prompt-windows/students-window.vue +++ b/website/src/compontents/authenticate-prompt-windows/students-window.vue @@ -1,65 +1,63 @@ @@ -113,7 +111,7 @@ export default class StudentsWindow extends Vue { get allowUrl() { 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('+'); return `/api/website/allow?prompt_id=${this.promptInfo.id}&student_ids=${studentIds}`; }