diff --git a/backend/package.json b/backend/package.json index 1a209022..c1f89121 100644 --- a/backend/package.json +++ b/backend/package.json @@ -8,7 +8,9 @@ "start": "npm run-script build && node ./build/backend/src/index.js", "dev": "nodemon ./src/index.ts", "build": "tsc --project ./", - "migrate:latest": "node ./build/backend/src/migrate-to-latest.js" + "migrate:latest": "node ./build/backend/src/Migrators/migrate-to-latest.js", + "migrate:up": "node ./build/backend/src/Migrators/migrate-up.js", + "migrate:down": "node ./build/backend/src/Migrators/migrate-down.js" }, "keywords": [], "author": "", diff --git a/backend/src/Controllers/controllerUtils.ts b/backend/src/Controllers/controllerUtils.ts index e9ab989a..198664b3 100644 --- a/backend/src/Controllers/controllerUtils.ts +++ b/backend/src/Controllers/controllerUtils.ts @@ -5,14 +5,14 @@ import { BadRequest, InternalServerError, Unauthorized } from "@curveball/http-e import { Request, Response } from 'express'; import { roles } from "../../../domain_model/roles"; import { hasPermission, permission, permissions } from '../../../domain_model/permissions'; -import { randomUUID } from "crypto"; +import { randomUUID, createHash } from "crypto"; import ServiceLocator from "../ServiceLocator"; - const accountService = ServiceLocator.accountService(); export function expectValidElectionFromRequest(req:IRequest):Election { const inputElection = req.body.Election; inputElection.election_id = randomUUID(); + inputElection.create_date = new Date().toISOString() const validationErr = electionValidation(inputElection); if (validationErr) { Logger.info(req, "Invalid Election: " + validationErr, inputElection); @@ -38,4 +38,8 @@ export function expectPermission(roles:roles[],permission:permission):any { if (!roles.some( (role) => permission.includes(role))){ throw new Unauthorized("Does not have permission") } +} + +export function hashString(inputString: string) { + return createHash('sha256').update(inputString).digest('hex') } \ No newline at end of file diff --git a/backend/src/Controllers/registerVoterController.ts b/backend/src/Controllers/registerVoterController.ts index be6d467b..47bc8664 100644 --- a/backend/src/Controllers/registerVoterController.ts +++ b/backend/src/Controllers/registerVoterController.ts @@ -10,6 +10,7 @@ import { Election } from "../../../domain_model/Election"; import { randomUUID } from "crypto"; import { IElectionRequest } from "../IRequest"; import { Response, NextFunction } from 'express'; +import { hashString } from "./controllerUtils"; const registerVoter = async (req: IElectionRequest, res: Response, next: NextFunction) => { Logger.info(req, `${className}.registerVoter ${req.election.election_id}`); @@ -43,7 +44,7 @@ const registerVoter = async (req: IElectionRequest, res: Response, next: NextFun election_id: req.election.election_id, email: req.user?.email, submitted: false, - ip_address: targetElection.settings.voter_authentication.ip_address ? req.ip : undefined, + ip_hash: targetElection.settings.voter_authentication.ip_address ? hashString(req.ip) : undefined, state: ElectionRollState.registered, history: [], registration: req.body.registration, diff --git a/backend/src/Controllers/voterRollUtils.ts b/backend/src/Controllers/voterRollUtils.ts index 755e985c..1b33f828 100644 --- a/backend/src/Controllers/voterRollUtils.ts +++ b/backend/src/Controllers/voterRollUtils.ts @@ -6,15 +6,16 @@ import Logger from "../Services/Logging/Logger"; import { BadRequest, InternalServerError, Unauthorized } from "@curveball/http-errors"; import { ILoggingContext } from "../Services/Logging/ILogger"; import { randomUUID } from "crypto"; +import { hashString } from "./controllerUtils"; const ElectionRollModel = ServiceLocator.electionRollDb(); export async function getOrCreateElectionRoll(req: IRequest, election: Election, ctx: ILoggingContext): Promise { // Checks for existing election roll for user Logger.info(req, `getOrCreateElectionRoll`) - + const ip_hash = hashString(req.ip) // Get data that is used for voter authentication - const ip_address = election.settings.voter_authentication.ip_address ? req.ip : null + const require_ip_hash = election.settings.voter_authentication.ip_address ? ip_hash : null const email = election.settings.voter_authentication.email ? req.user?.email : null let voter_id = election.settings.voter_authentication.voter_id ? req.cookies?.voter_id : null @@ -22,8 +23,8 @@ export async function getOrCreateElectionRoll(req: IRequest, election: Election, // This is an odd way of going about this, rather than getting a roll that matches all three we get all that match any of the fields and // check the output for a number of edge cases. var electionRollEntries = null - if ((ip_address || email || voter_id)) { - electionRollEntries = await ElectionRollModel.getElectionRoll(String(election.election_id), voter_id, email, ip_address, ctx); + if ((require_ip_hash || email || voter_id)) { + electionRollEntries = await ElectionRollModel.getElectionRoll(String(election.election_id), voter_id, email, require_ip_hash, ctx); } @@ -46,12 +47,12 @@ export async function getOrCreateElectionRoll(req: IRequest, election: Election, election_id: String(election.election_id), email: req.user?.email ? req.user.email : undefined, voter_id: new_voter_id, - ip_address: req.ip, + ip_hash: ip_hash, submitted: false, state: ElectionRollState.approved, history: history, }] - if ((ip_address || email || voter_id)) { + if ((require_ip_hash || email || voter_id)) { const newElectionRoll = await ElectionRollModel.submitElectionRoll(roll, ctx, `User requesting Roll and is authorized`) if (!newElectionRoll) { Logger.error(ctx, "Failed to update ElectionRoll"); @@ -71,8 +72,8 @@ export async function getOrCreateElectionRoll(req: IRequest, election: Election, Logger.error(req, "Multiple election roll entries found", electionRollEntries); throw new InternalServerError('Multiple election roll entries found'); } - if (election.settings.voter_authentication.ip_address && electionRollEntries[0].ip_address) { - if (electionRollEntries[0].ip_address !== ip_address) { + if (election.settings.voter_authentication.ip_address && electionRollEntries[0].ip_hash) { + if (electionRollEntries[0].ip_hash !== ip_hash) { Logger.error(req, "IP Address does not match saved voter roll", electionRollEntries); throw new Unauthorized('IP Address does not match saved voter roll'); } diff --git a/backend/src/Migrations/2024_01_27_Create_Date.ts b/backend/src/Migrations/2024_01_27_Create_Date.ts new file mode 100644 index 00000000..3ef392b8 --- /dev/null +++ b/backend/src/Migrations/2024_01_27_Create_Date.ts @@ -0,0 +1,47 @@ +import { Kysely } from 'kysely' + +export async function up(db: Kysely): Promise { + await db.schema.alterTable('electionDB') + .addColumn('claim_key_hash', 'varchar') + .addColumn('is_public', 'boolean') + .addColumn('create_date', 'varchar') + .execute() + + await db.updateTable('electionDB') + .set({ create_date: new Date().toISOString() }) + .execute() + + await db.schema.alterTable('electionDB') + .alterColumn('create_date', (col) => col.setNotNull()) + .execute() + + await db.schema.alterTable('electionRollDB') + .dropColumn('ip_address') + .addColumn('ip_hash', 'varchar') + .execute() + + + await db.schema.alterTable('ballotDB') + .dropColumn('ip_address') + .addColumn('ip_hash', 'varchar') + .execute() +} + +export async function down(db: Kysely): Promise { + await db.schema.alterTable('electionDB') + .dropColumn('claim_key_hash') + .dropColumn('is_public') + .dropColumn('create_date') + .execute() + + await db.schema.alterTable('electionRollDB') + .dropColumn('ip_hash') + .addColumn('ip_address', 'varchar') + .execute() + + + await db.schema.alterTable('ballotDB') + .dropColumn('ip_hash') + .addColumn('ip_address', 'varchar') + .execute() +} \ No newline at end of file diff --git a/backend/src/Migrators/migrate-down.ts b/backend/src/Migrators/migrate-down.ts new file mode 100644 index 00000000..6661edd3 --- /dev/null +++ b/backend/src/Migrators/migrate-down.ts @@ -0,0 +1,11 @@ +require('dotenv').config() +import { createMigrator, handleMigration } from "./migration-utils" + +async function migrateDown() { + const { migrator, db } = createMigrator() + handleMigration(await migrator.migrateDown()) + + await db.destroy() + } + + migrateDown() \ No newline at end of file diff --git a/backend/src/Migrators/migrate-to-latest.ts b/backend/src/Migrators/migrate-to-latest.ts new file mode 100644 index 00000000..5fff456f --- /dev/null +++ b/backend/src/Migrators/migrate-to-latest.ts @@ -0,0 +1,11 @@ +require('dotenv').config() +import { createMigrator, handleMigration } from "./migration-utils" + +async function migrateToLatest() { + const { migrator, db } = createMigrator() + handleMigration(await migrator.migrateToLatest()) + + await db.destroy() +} + +migrateToLatest() \ No newline at end of file diff --git a/backend/src/Migrators/migrate-up.ts b/backend/src/Migrators/migrate-up.ts new file mode 100644 index 00000000..b713a78a --- /dev/null +++ b/backend/src/Migrators/migrate-up.ts @@ -0,0 +1,11 @@ +require('dotenv').config() +import { createMigrator, handleMigration } from "./migration-utils" + +async function migrateUp() { + const { migrator, db } = createMigrator() + handleMigration(await migrator.migrateUp()) + + await db.destroy() + } + + migrateUp() \ No newline at end of file diff --git a/backend/src/Migrators/migration-utils.ts b/backend/src/Migrators/migration-utils.ts new file mode 100644 index 00000000..6d3f0b93 --- /dev/null +++ b/backend/src/Migrators/migration-utils.ts @@ -0,0 +1,42 @@ +import * as path from 'path' +import { promises as fs } from 'fs' +import { + Migrator, + FileMigrationProvider, + MigrationResultSet, +} from 'kysely' + +import servicelocator from '../ServiceLocator' + +export function createMigrator() { + const db = servicelocator.database() + const migrator = new Migrator({ + db, + provider: new FileMigrationProvider({ + fs, + path, + // This needs to be an absolute path. + migrationFolder: path.join(__dirname, '../Migrations'), + }), + }) + return { + migrator, + db + } +} + +export async function handleMigration(migrationResult: MigrationResultSet) { + migrationResult.results?.forEach((it) => { + if (it.status === 'Success') { + console.log(`migration "${it.migrationName}" was executed successfully`) + } else if (it.status === 'Error') { + console.error(`failed to execute migration "${it.migrationName}"`) + } + }) + + if (migrationResult.error) { + console.error('failed to migrate') + console.error(migrationResult.error) + process.exit(1) + } +} \ No newline at end of file diff --git a/backend/src/Models/CastVoteStore.ts b/backend/src/Models/CastVoteStore.ts index 6da45288..dd16c0e3 100644 --- a/backend/src/Models/CastVoteStore.ts +++ b/backend/src/Models/CastVoteStore.ts @@ -27,14 +27,13 @@ export default class CastVoteStore { ballot.user_id, ballot.status, ballot.date_submitted, - ballot.ip_address, JSON.stringify(ballot.votes), JSON.stringify(ballot.history), ballot.precinct, ]; const ballotSQL = pgFormat( - `INSERT INTO ${this._ballotTableName} (ballot_id,election_id,user_id,status,date_submitted,ip_address,votes,history,precinct) + `INSERT INTO ${this._ballotTableName} (ballot_id,election_id,user_id,status,date_submitted,ip_hash,votes,history,precinct) VALUES (%L);`, ballotValues ); diff --git a/backend/src/Models/ElectionRolls.ts b/backend/src/Models/ElectionRolls.ts index aff336d4..9244633c 100644 --- a/backend/src/Models/ElectionRolls.ts +++ b/backend/src/Models/ElectionRolls.ts @@ -104,7 +104,7 @@ export default class ElectionRollDB implements IElectionRollStore { })) } - getElectionRoll(election_id: string, voter_id: string | null, email: string | null, ip_address: string | null, ctx: ILoggingContext): Promise { + getElectionRoll(election_id: string, voter_id: string | null, email: string | null, ip_hash: string | null, ctx: ILoggingContext): Promise { Logger.debug(ctx, `${tableName}.get election:${election_id}, voter:${voter_id}`); return this._postgresClient @@ -121,8 +121,8 @@ export default class ElectionRollDB implements IElectionRollStore { ors.push(eb.cmpr('email', '=', email)) } - if (ip_address) { - ors.push(eb.cmpr('ip_address', '=', ip_address)) + if (ip_hash) { + ors.push(eb.cmpr('ip_hash', '=', ip_hash)) } return eb.or(ors) diff --git a/backend/src/Models/Elections.ts b/backend/src/Models/Elections.ts index 3cec2b10..88acf3a0 100644 --- a/backend/src/Models/Elections.ts +++ b/backend/src/Models/Elections.ts @@ -28,7 +28,6 @@ export default class ElectionsDB { createElection(election: Election, ctx: ILoggingContext, reason: string): Promise { Logger.debug(ctx, `${tableName}.createElection`, election); - const newElection = this._postgresClient .insertInto(tableName) .values(election) diff --git a/backend/src/Models/IElectionRollStore.ts b/backend/src/Models/IElectionRollStore.ts index 5e2e7dd3..af2cd731 100644 --- a/backend/src/Models/IElectionRollStore.ts +++ b/backend/src/Models/IElectionRollStore.ts @@ -20,7 +20,7 @@ export interface IElectionRollStore { election_id: string, voter_id: string|null, email: string|null, - ip_address: string|null, + ip_hash: string|null, ctx:ILoggingContext ) => Promise; update: ( diff --git a/backend/src/Models/__mocks__/ElectionRolls.ts b/backend/src/Models/__mocks__/ElectionRolls.ts index f07f7704..53228d3c 100644 --- a/backend/src/Models/__mocks__/ElectionRolls.ts +++ b/backend/src/Models/__mocks__/ElectionRolls.ts @@ -48,11 +48,11 @@ export default class ElectionRollDB implements IElectionRollStore{ return Promise.resolve(res) } - getElectionRoll(election_id: string, voter_id: string|null, email: string|null, ip_address: string|null, ctx:ILoggingContext): Promise<[ElectionRoll] | null> { - Logger.debug(ctx, `MockElectionRolls get election:${election_id}, voter_id:${voter_id}, email: ${email}, ip_address: ${ip_address}`); + getElectionRoll(election_id: string, voter_id: string|null, email: string|null, ip_hash: string|null, ctx:ILoggingContext): Promise<[ElectionRoll] | null> { + Logger.debug(ctx, `MockElectionRolls get election:${election_id}, voter_id:${voter_id}, email: ${email}, ip_hash: ${ip_hash}`); let roll = this._electionRolls.filter(electionRolls => { if (electionRolls.election_id!==election_id) return false - if (ip_address && electionRolls.ip_address===ip_address) return true + if (ip_hash && electionRolls.ip_hash===ip_hash) return true if (voter_id && electionRolls.voter_id ===voter_id) return true if (email && electionRolls.email === email) return true return false diff --git a/backend/src/migrate-to-latest.ts b/backend/src/migrate-to-latest.ts deleted file mode 100644 index a4ec0abd..00000000 --- a/backend/src/migrate-to-latest.ts +++ /dev/null @@ -1,46 +0,0 @@ -require('dotenv').config() -import * as path from 'path' -import { Pool } from 'pg' -import { promises as fs } from 'fs' -import { - Kysely, - Migrator, - PostgresDialect, - FileMigrationProvider, -} from 'kysely' - -import servicelocator from './ServiceLocator' - -async function migrateToLatest() { - const db = servicelocator.database() - - const migrator = new Migrator({ - db, - provider: new FileMigrationProvider({ - fs, - path, - // This needs to be an absolute path. - migrationFolder: path.join(__dirname, './Migrations'), - }), - }) - - const { error, results } = await migrator.migrateToLatest() - - results?.forEach((it) => { - if (it.status === 'Success') { - console.log(`migration "${it.migrationName}" was executed successfully`) - } else if (it.status === 'Error') { - console.error(`failed to execute migration "${it.migrationName}"`) - } - }) - - if (error) { - console.error('failed to migrate') - console.error(error) - process.exit(1) - } - - await db.destroy() -} - -migrateToLatest() \ No newline at end of file diff --git a/domain_model/Ballot.ts b/domain_model/Ballot.ts index e7136614..5d05b030 100644 --- a/domain_model/Ballot.ts +++ b/domain_model/Ballot.ts @@ -10,7 +10,7 @@ export interface Ballot { user_id?: Uid; //ID of user who cast ballot TODO: replace with voter ID status: string; //Status of string (saved, submitted) date_submitted: number; //time ballot was submitted, represented as unix timestamp (Date.now()) - ip_address?: string; // ip address if once_per_ip is enabled + ip_hash?: string; // ip address if once_per_ip is enabled votes: Vote[]; // One per poll history?: BallotAction[]; precinct?: string; // Precint of voter diff --git a/domain_model/Election.ts b/domain_model/Election.ts index 340cf473..177b8538 100644 --- a/domain_model/Election.ts +++ b/domain_model/Election.ts @@ -19,8 +19,14 @@ export interface Election { races: Race[]; // one or more race definitions settings: ElectionSettings; auth_key?: string; + claim_key_hash?: string; + is_public?: Boolean; + create_date: Date | string; } +type Omit = Pick> +type PartialBy = Omit & Partial> +export interface NewElection extends PartialBy {} export function electionValidation(obj:Election): string | null { if (!obj){ diff --git a/domain_model/ElectionRoll.ts b/domain_model/ElectionRoll.ts index 3ba737a3..48c5d030 100644 --- a/domain_model/ElectionRoll.ts +++ b/domain_model/ElectionRoll.ts @@ -7,7 +7,7 @@ export interface ElectionRoll { email?: string; // Email address of voter submitted: boolean; //has ballot been submitted ballot_id?: Uid; //ID of ballot, unsure if this is needed - ip_address?: string; //IP Address of voter + ip_hash?: string; //IP Address of voter address?: string; // Address of voter state: ElectionRollState; //state of election roll history?: ElectionRollAction[];// history of changes to election roll diff --git a/frontend/src/components/Election/Admin/ViewElectionRolls.tsx b/frontend/src/components/Election/Admin/ViewElectionRolls.tsx index 8c0225bf..a12acaa7 100644 --- a/frontend/src/components/Election/Admin/ViewElectionRolls.tsx +++ b/frontend/src/components/Election/Admin/ViewElectionRolls.tsx @@ -138,7 +138,7 @@ const ViewElectionRolls = () => { voter_id: roll.voter_id, email: roll.email || '', invite_status: invite_status, - ip: roll.ip_address || '', + ip: roll.ip_hash || '', precinct: roll.precinct || '', has_voted: roll.submitted.toString(), state: roll.state.toString() diff --git a/frontend/src/components/ElectionForm/CreateElectionTemplates.tsx b/frontend/src/components/ElectionForm/CreateElectionTemplates.tsx index 861e5827..095ada85 100644 --- a/frontend/src/components/ElectionForm/CreateElectionTemplates.tsx +++ b/frontend/src/components/ElectionForm/CreateElectionTemplates.tsx @@ -1,6 +1,6 @@ import React from 'react' import { useNavigate } from "react-router" -import { Election } from '@domain_model/Election'; +import { Election, NewElection } from '@domain_model/Election'; import { usePostElection } from '../../hooks/useAPI'; import { DateTime } from 'luxon' import { Card, CardActionArea, CardMedia, CardContent, Typography, Box, Grid } from '@mui/material'; @@ -14,15 +14,14 @@ const CreateElectionTemplates = () => { const themeSelector = useThemeSelector() const navigate = useNavigate() const { error, isPending, makeRequest: postElection } = usePostElection() - const [quickPoll, setQuickPoll] = useLocalStorage('QuickPoll', null) + const [quickPoll, setQuickPoll] = useLocalStorage('QuickPoll', null) const cardColor = themeSelector.mode === 'darkMode' ? 'brand.gray5' : 'brand.gray1' - const defaultElection: Election = { + const defaultElection: NewElection = { title: '', - election_id: '0', + owner_id: '', description: '', state: 'draft', frontend_url: '', - owner_id: '', races: [], settings: { voter_access: 'open', @@ -37,7 +36,7 @@ const CreateElectionTemplates = () => { } } - const onAddElection = async (updateFunc: (election: Election) => any) => { + const onAddElection = async (updateFunc: (election: NewElection) => any) => { const election = defaultElection updateFunc(election) // calls post election api, throws error if response not ok diff --git a/frontend/src/components/ElectionForm/ElectionForm.tsx b/frontend/src/components/ElectionForm/ElectionForm.tsx index 23aaaa7a..7aa30de5 100644 --- a/frontend/src/components/ElectionForm/ElectionForm.tsx +++ b/frontend/src/components/ElectionForm/ElectionForm.tsx @@ -172,6 +172,7 @@ const ElectionForm = ({ authSession, onSubmitElection, prevElectionData, submitT state: 'draft', frontend_url: '', owner_id: '', + create_date: '', races: [ { race_id: '0', diff --git a/frontend/src/components/ElectionForm/QuickPoll.tsx b/frontend/src/components/ElectionForm/QuickPoll.tsx index 72fe8d9e..38e94cbe 100644 --- a/frontend/src/components/ElectionForm/QuickPoll.tsx +++ b/frontend/src/components/ElectionForm/QuickPoll.tsx @@ -11,16 +11,15 @@ import { useLocalStorage } from '../../hooks/useLocalStorage'; import Typography from '@mui/material/Typography'; import { usePostElection } from '../../hooks/useAPI'; import { useCookie } from '../../hooks/useCookie'; -import { Election } from '@domain_model/Election.js'; +import { Election, NewElection } from '@domain_model/Election.js'; const QuickPoll = ({ authSession }) => { const [tempID, setTempID] = useCookie('temp_id', '0') const navigate = useNavigate() const { error, isPending, makeRequest: postElection } = usePostElection() - const QuickPollTemplate: Election = { + const QuickPollTemplate: NewElection = { title: '', - election_id: '0', state: 'open', frontend_url: '', owner_id: '0', @@ -60,7 +59,7 @@ const QuickPoll = ({ authSession }) => { } - const [election, setElectionData] = useLocalStorage('QuickPoll', QuickPollTemplate) + const [election, setElectionData] = useLocalStorage('QuickPoll', QuickPollTemplate) const [titleError, setTitleError] = useState(false) const onSubmitElection = async (election) => { // calls post election api, throws error if response not ok diff --git a/frontend/src/hooks/useAPI.ts b/frontend/src/hooks/useAPI.ts index 3b443fd9..ff39ffd2 100644 --- a/frontend/src/hooks/useAPI.ts +++ b/frontend/src/hooks/useAPI.ts @@ -1,4 +1,4 @@ -import { Election } from "@domain_model/Election"; +import { Election, NewElection } from "@domain_model/Election"; import { VoterAuth } from '@domain_model/VoterAuth'; import { ElectionRoll } from "@domain_model/ElectionRoll"; import useFetch from "./useFetch"; @@ -20,7 +20,7 @@ export const useGetElections = () => { } export const usePostElection = () => { - return useFetch<{ Election: Election }, { election: Election }>('/API/Elections', 'post') + return useFetch<{ Election: NewElection }, { election: Election }>('/API/Elections', 'post') } export const useEditElection = (election_id: string | undefined) => {