2017-11-22 23:23:24 +11:00
# Eos - Verifiable elections
2018-01-04 14:36:07 +11:00
# Copyright © 2017-18 RunasSudo (Yingtong Li)
2017-11-22 23:23:24 +11:00
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import click
import flask
2017-12-12 22:44:24 +11:00
import timeago
2017-11-22 23:23:24 +11:00
2017-11-24 20:26:18 +11:00
from eos . core . objects import *
2017-12-12 20:32:02 +11:00
from eos . core . tasks import *
2017-11-22 23:23:24 +11:00
from eos . base . election import *
2017-12-16 21:47:17 +11:00
from eos . base . tasks import *
2017-12-12 20:32:02 +11:00
from eos . base . workflow import *
2017-11-22 23:23:24 +11:00
from eos . psr . crypto import *
from eos . psr . election import *
from eos . psr . mixnet import *
from eos . psr . workflow import *
2018-01-04 14:36:07 +11:00
from eosweb . core . tasks import *
2018-01-07 23:21:37 +11:00
from . import emails
2017-11-22 23:23:24 +11:00
import eos . core . hashing
2017-11-23 18:18:01 +11:00
import eosweb
2017-11-22 23:23:24 +11:00
2017-11-23 23:10:57 +11:00
from datetime import datetime
2017-11-22 23:35:46 +11:00
import functools
2017-11-25 22:37:20 +11:00
import importlib
2017-12-15 23:05:49 +11:00
import io
2017-11-23 23:10:57 +11:00
import json
2017-11-25 22:37:20 +11:00
import os
2017-12-13 15:21:22 +11:00
import pytz
2017-12-07 15:33:11 +11:00
import subprocess
2018-01-12 00:12:27 +11:00
import uuid
2017-11-22 23:35:46 +11:00
2017-11-28 13:58:22 +11:00
app = flask . Flask ( __name__ , static_folder = None )
2017-11-22 23:23:24 +11:00
2017-11-25 22:37:20 +11:00
# Load config
app . config . from_object ( ' eosweb.core.settings ' )
if ' EOSWEB_SETTINGS ' in os . environ :
app . config . from_envvar ( ' EOSWEB_SETTINGS ' )
# Load app config
for app_name in app . config [ ' APPS ' ] :
app . config . from_object ( app_name + ' .settings ' )
if ' EOSWEB_SETTINGS ' in os . environ :
app . config . from_envvar ( ' EOSWEB_SETTINGS ' )
# Connect to database
2017-11-27 19:40:01 +11:00
db_connect ( app . config [ ' DB_NAME ' ] , app . config [ ' DB_URI ' ] , app . config [ ' DB_TYPE ' ] )
2017-11-25 22:37:20 +11:00
2017-12-07 15:33:11 +11:00
# Set configs
User . admins = app . config [ ' ADMINS ' ]
2017-11-25 22:37:54 +11:00
# Make Flask's serialisation, e.g. for sessions, EosObject aware
class EosObjectJSONEncoder ( flask . json . JSONEncoder ) :
def default ( self , obj ) :
if isinstance ( obj , EosObject ) :
return EosObject . serialise_and_wrap ( obj )
return super ( ) . default ( obj )
class EosObjectJSONDecoder ( flask . json . JSONDecoder ) :
def __init__ ( self , * args , * * kwargs ) :
self . super_object_hook = kwargs . get ( ' object_hook ' , None )
kwargs [ ' object_hook ' ] = self . my_object_hook
super ( ) . __init__ ( * args , * * kwargs )
def my_object_hook ( self , val ) :
if ' type ' in val :
if val [ ' type ' ] in EosObject . objects :
return EosObject . deserialise_and_unwrap ( val )
if self . super_object_hook :
return self . super_object_hook ( val )
return val
app . json_encoder = EosObjectJSONEncoder
app . json_decoder = EosObjectJSONDecoder
2017-11-28 13:58:22 +11:00
# Patch Flask's static file sending to add no-cache
# Allow "caching", but require revalidation via 304 Not Modified
@app.route ( ' /static/<path:filename> ' )
def static ( filename ) :
cache_timeout = app . get_send_file_max_age ( filename )
val = flask . send_from_directory ( ' static ' , filename , cache_timeout = cache_timeout )
val . headers [ ' Cache-Control ' ] = val . headers [ ' Cache-Control ' ] . replace ( ' public ' , ' no-cache ' )
#import pdb; pdb.set_trace()
return val
2017-11-22 23:23:24 +11:00
@app.cli.command ( ' test ' )
@click.option ( ' --prefix ' , default = None )
@click.option ( ' --lang ' , default = None )
def run_tests ( prefix , lang ) :
import eos . tests
eos . tests . run_tests ( prefix , lang )
# TODO: Will remove this once we have a web UI
@app.cli.command ( ' drop_db_and_setup ' )
def setup_test_election ( ) :
# DANGER!
2017-11-27 19:40:01 +11:00
dbinfo . provider . reset_db ( )
2017-11-22 23:23:24 +11:00
# Set up election
election = PSRElection ( )
election . workflow = PSRWorkflow ( )
# Set election details
election . name = ' Test Election '
2017-11-26 11:17:28 +11:00
from eos . redditauth . election import RedditUser
2017-11-26 20:48:15 +11:00
election . voters . append ( UserVoter ( user = EmailUser ( name = ' Alice ' , email = ' alice@localhost ' ) ) )
election . voters . append ( UserVoter ( user = EmailUser ( name = ' Bob ' , email = ' bob@localhost ' ) ) )
election . voters . append ( UserVoter ( user = EmailUser ( name = ' Carol ' , email = ' carol@localhost ' ) ) )
2017-11-26 11:17:28 +11:00
election . voters . append ( UserVoter ( user = RedditUser ( username = ' RunasSudo ' ) ) )
2017-11-22 23:23:24 +11:00
2017-11-26 20:48:15 +11:00
for voter in election . voters :
if isinstance ( voter , UserVoter ) :
if isinstance ( voter . user , EmailUser ) :
2018-01-07 23:21:37 +11:00
emails . voter_email_password ( election , voter )
2017-11-26 20:48:15 +11:00
2017-11-24 19:37:48 +11:00
election . mixing_trustees . append ( InternalMixingTrustee ( name = ' Eos Voting ' ) )
election . mixing_trustees . append ( InternalMixingTrustee ( name = ' Eos Voting ' ) )
2017-11-22 23:23:24 +11:00
election . sk = EGPrivateKey . generate ( )
election . public_key = election . sk . public_key
2017-12-11 11:25:01 +11:00
question = PreferentialQuestion ( prompt = ' President ' , choices = [
Ticket ( name = ' ACME Party ' , choices = [
Choice ( name = ' John Smith ' ) ,
Choice ( name = ' Joe Bloggs ' , party = ' Independent ACME ' )
] ) ,
Choice ( name = ' John Q. Public ' )
2017-12-11 13:00:41 +11:00
] , min_choices = 0 , max_choices = 3 , randomise_choices = True )
2017-11-22 23:23:24 +11:00
election . questions . append ( question )
2017-12-11 11:25:01 +11:00
question = ApprovalQuestion ( prompt = ' Chairman ' , choices = [ Choice ( name = ' John Doe ' ) , Choice ( name = ' Andrew Citizen ' ) ] , min_choices = 0 , max_choices = 1 )
2017-11-22 23:23:24 +11:00
election . questions . append ( question )
election . save ( )
2017-11-29 18:29:42 +11:00
@app.cli.command ( ' verify_election ' )
@click.option ( ' --electionid ' , default = None )
def verify_election ( electionid ) :
if electionid is None :
election = Election . get_all ( ) [ 0 ]
else :
election = Election . get_by_id ( electionid )
election . verify ( )
print ( ' The election has passed validation ' )
2017-12-16 21:47:17 +11:00
@app.cli.command ( ' tally_stv ' )
@click.option ( ' --electionid ' , default = None )
@click.option ( ' --qnum ' , default = 0 )
@click.option ( ' --randfile ' , default = None )
def tally_stv_election ( electionid , qnum , randfile ) :
election = Election . get_by_id ( electionid )
with open ( randfile , ' r ' ) as f :
dat = json . load ( f )
task = TaskTallySTV (
election_id = election . _id ,
q_num = qnum ,
random = dat ,
num_seats = 7 ,
2018-01-11 22:06:21 +11:00
status = TaskStatus . READY ,
2017-12-16 21:47:17 +11:00
run_strategy = EosObject . lookup ( app . config [ ' TASK_RUN_STRATEGY ' ] ) ( )
)
task . save ( )
task . run ( )
2017-11-22 23:23:24 +11:00
@app.context_processor
def inject_globals ( ) :
2017-11-23 18:18:01 +11:00
return { ' eos ' : eos , ' eosweb ' : eosweb , ' SHA256 ' : eos . core . hashing . SHA256 }
2017-11-22 23:23:24 +11:00
2017-12-12 22:44:24 +11:00
@app.template_filter ( ' pretty_date ' )
def pretty_date ( dt ) :
2017-12-13 15:21:22 +11:00
dt_local = dt . astimezone ( pytz . timezone ( app . config [ ' TIMEZONE ' ] ) )
return flask . Markup ( ' <time datetime= " {} " title= " {} " > {} </time> ' . format ( dt_local . strftime ( ' % Y- % m- %d T % H: % M: % S % z ' ) , dt_local . strftime ( ' % Y- % m- %d % H: % M: % S % Z ' ) , timeago . format ( dt , DateTimeField . now ( ) ) ) )
2017-12-12 22:44:24 +11:00
2017-12-12 20:32:02 +11:00
# Tickle the plumbus every request
@app.before_request
def tick_scheduler ( ) :
# Process pending tasks
TaskScheduler . tick ( )
2017-11-25 22:37:54 +11:00
# === Views ===
2017-11-22 23:23:24 +11:00
@app.route ( ' / ' )
def index ( ) :
return flask . render_template ( ' index.html ' )
def using_election ( func ) :
2017-11-22 23:35:46 +11:00
@functools.wraps ( func )
2017-12-07 16:04:24 +11:00
def wrapped ( election_id , * * kwargs ) :
2017-11-22 23:23:24 +11:00
election = Election . get_by_id ( election_id )
2017-12-07 16:04:24 +11:00
return func ( election , * * kwargs )
2017-11-22 23:23:24 +11:00
return wrapped
2017-12-07 15:33:11 +11:00
def election_admin ( func ) :
@functools.wraps ( func )
2018-01-11 23:25:49 +11:00
def wrapped ( * args , * * kwargs ) :
2017-12-07 15:33:11 +11:00
if ' user ' in flask . session and flask . session [ ' user ' ] . is_admin ( ) :
2018-01-11 23:25:49 +11:00
return func ( * args , * * kwargs )
2017-12-07 15:33:11 +11:00
else :
return flask . Response ( ' Administrator credentials required ' , 403 )
return wrapped
2017-11-23 21:07:16 +11:00
@app.route ( ' /election/<election_id>/ ' )
@using_election
def election_api_json ( election ) :
2017-12-15 20:21:03 +11:00
is_full = ' full ' in flask . request . args
return flask . Response ( EosObject . to_json ( EosObject . serialise_and_wrap ( election , None , SerialiseOptions ( should_protect = True , for_hash = ( not is_full ) , combine_related = True ) ) ) , mimetype = ' application/json ' )
2017-11-23 21:07:16 +11:00
2017-11-22 23:23:24 +11:00
@app.route ( ' /election/<election_id>/view ' )
@using_election
def election_view ( election ) :
2017-12-07 15:33:11 +11:00
return flask . render_template ( ' election/view/view.html ' , election = election )
2017-11-23 18:18:01 +11:00
@app.route ( ' /election/<election_id>/booth ' )
@using_election
def election_booth ( election ) :
2017-11-23 21:07:16 +11:00
selection_model_view_map = EosObject . to_json ( { key . _name : val for key , val in model_view_map . items ( ) } ) # ewww
2017-11-25 23:16:29 +11:00
auth_methods = EosObject . to_json ( app . config [ ' AUTH_METHODS ' ] )
2017-11-23 21:07:16 +11:00
2017-12-07 15:33:11 +11:00
return flask . render_template ( ' election/view/booth.html ' , election = election , selection_model_view_map = selection_model_view_map , auth_methods = auth_methods )
2017-11-23 18:18:01 +11:00
@app.route ( ' /election/<election_id>/view/questions ' )
@using_election
def election_view_questions ( election ) :
2017-12-07 15:33:11 +11:00
return flask . render_template ( ' election/view/questions.html ' , election = election )
2017-11-23 18:18:01 +11:00
@app.route ( ' /election/<election_id>/view/ballots ' )
@using_election
def election_view_ballots ( election ) :
2017-12-07 15:33:11 +11:00
return flask . render_template ( ' election/view/ballots.html ' , election = election )
2017-11-23 18:18:01 +11:00
2018-01-12 00:12:27 +11:00
@app.route ( ' /election/<election_id>/voter/<voter_id> ' )
@using_election
def election_voter_view ( election , voter_id ) :
voter_id = uuid . UUID ( voter_id )
voter = next ( voter for voter in election . voters if voter . _id == voter_id )
return flask . render_template ( ' election/voter/view.html ' , election = election , voter = voter )
2017-11-23 18:18:01 +11:00
@app.route ( ' /election/<election_id>/view/trustees ' )
@using_election
def election_view_trustees ( election ) :
2017-12-07 15:33:11 +11:00
return flask . render_template ( ' election/view/trustees.html ' , election = election )
@app.route ( ' /election/<election_id>/admin ' )
@using_election
@election_admin
def election_admin_summary ( election ) :
return flask . render_template ( ' election/admin/admin.html ' , election = election )
2017-11-23 18:18:01 +11:00
2017-12-07 16:04:24 +11:00
@app.route ( ' /election/<election_id>/admin/enter_task ' )
@using_election
@election_admin
def election_admin_enter_task ( election ) :
2017-12-12 20:32:02 +11:00
workflow_task = election . workflow . get_task ( flask . request . args [ ' task_name ' ] )
2018-01-11 22:06:21 +11:00
if workflow_task . status != WorkflowTaskStatus . READY :
2017-12-07 16:04:24 +11:00
return flask . Response ( ' Task is not yet ready or has already exited ' , 409 )
2018-01-04 14:36:07 +11:00
task = WorkflowTaskEntryWebTask (
2017-12-12 21:19:02 +11:00
election_id = election . _id ,
workflow_task = workflow_task . _name ,
2018-01-11 22:06:21 +11:00
status = TaskStatus . READY ,
2017-12-12 21:19:02 +11:00
run_strategy = EosObject . lookup ( app . config [ ' TASK_RUN_STRATEGY ' ] ) ( )
)
2017-12-12 20:32:02 +11:00
task . run ( )
2017-12-07 16:04:24 +11:00
return flask . redirect ( flask . url_for ( ' election_admin_summary ' , election_id = election . _id ) )
2017-12-12 21:19:02 +11:00
@app.route ( ' /election/<election_id>/admin/schedule_task ' , methods = [ ' POST ' ] )
@using_election
@election_admin
def election_admin_schedule_task ( election ) :
workflow_task = election . workflow . get_task ( flask . request . form [ ' task_name ' ] )
2018-01-04 14:36:07 +11:00
task = WorkflowTaskEntryWebTask (
2017-12-12 21:19:02 +11:00
election_id = election . _id ,
workflow_task = workflow_task . _name ,
run_at = DateTimeField ( ) . deserialise ( flask . request . form [ ' datetime ' ] ) ,
2018-01-11 22:06:21 +11:00
status = TaskStatus . READY ,
2017-12-12 21:19:02 +11:00
run_strategy = EosObject . lookup ( app . config [ ' TASK_RUN_STRATEGY ' ] ) ( )
)
task . save ( )
return flask . redirect ( flask . url_for ( ' election_admin_summary ' , election_id = election . _id ) )
2017-11-23 23:10:57 +11:00
@app.route ( ' /election/<election_id>/cast_ballot ' , methods = [ ' POST ' ] )
@using_election
def election_api_cast_vote ( election ) :
2018-01-11 22:06:21 +11:00
if election . workflow . get_task ( ' eos.base.workflow.TaskOpenVoting ' ) . status < WorkflowTaskStatus . EXITED or election . workflow . get_task ( ' eos.base.workflow.TaskCloseVoting ' ) . status > WorkflowTaskStatus . READY :
2017-11-24 19:37:48 +11:00
# Voting is not yet open or has closed
2017-11-25 23:16:29 +11:00
return flask . Response ( ' Voting is not yet open or has closed ' , 409 )
2017-11-24 19:37:48 +11:00
2017-11-23 23:10:57 +11:00
data = json . loads ( flask . request . data )
2017-11-25 23:16:29 +11:00
if ' user ' not in flask . session :
# User is not authenticated
return flask . Response ( ' Not authenticated ' , 403 )
2017-11-23 23:10:57 +11:00
voter = None
for election_voter in election . voters :
2017-11-26 11:17:28 +11:00
if election_voter . user . matched_by ( flask . session [ ' user ' ] ) :
2017-11-23 23:10:57 +11:00
voter = election_voter
break
if voter is None :
2017-11-25 23:16:29 +11:00
# Invalid user
2017-11-23 23:10:57 +11:00
return flask . Response ( ' Invalid credentials ' , 403 )
# Cast the vote
ballot = EosObject . deserialise_and_unwrap ( data [ ' ballot ' ] )
2017-12-15 20:51:57 +11:00
vote = Vote ( voter_id = voter . _id , ballot = ballot , cast_at = DateTimeField . now ( ) )
2017-12-11 13:53:25 +11:00
# Store data
if app . config [ ' CAST_FINGERPRINT ' ] :
vote . cast_fingerprint = data [ ' fingerprint ' ]
if app . config [ ' CAST_IP ' ] :
if os . path . exists ( ' /app/.heroku ' ) :
vote . cast_ip = flask . request . headers [ ' X-Forwarded-For ' ] . split ( ' , ' ) [ - 1 ]
else :
vote . cast_ip = flask . request . remote_addr
2017-12-15 20:51:57 +11:00
vote . save ( )
2017-11-23 23:10:57 +11:00
return flask . Response ( json . dumps ( {
2017-12-15 20:21:03 +11:00
' voter ' : EosObject . serialise_and_wrap ( voter , None , SerialiseOptions ( should_protect = True ) ) ,
' vote ' : EosObject . serialise_and_wrap ( vote , None , SerialiseOptions ( should_protect = True ) )
2017-11-23 23:10:57 +11:00
} ) , mimetype = ' application/json ' )
2017-11-23 21:07:16 +11:00
2017-12-15 23:05:49 +11:00
@app.route ( ' /election/<election_id>/export/question/<int:q_num>/<format> ' )
@using_election
def election_api_export_question ( election , q_num , format ) :
import eos . base . util . blt
2017-12-16 21:47:17 +11:00
resp = flask . send_file ( io . BytesIO ( ' \n ' . join ( eos . base . util . blt . writeBLT ( election , q_num , 2 ) ) . encode ( ' utf-8 ' ) ) , mimetype = ' text/plain; charset=utf-8 ' , attachment_filename = ' {} .blt ' . format ( q_num ) , as_attachment = True )
2017-12-15 23:05:49 +11:00
resp . headers [ ' Cache-Control ' ] = ' no-cache, no-store, must-revalidate '
return resp
2018-01-11 23:25:49 +11:00
@app.route ( ' /task/<task_id> ' )
@election_admin
def task_view ( task_id ) :
task = Task . get_by_id ( task_id )
return flask . render_template ( ' task/view.html ' , task = task )
2017-11-28 22:43:32 +11:00
@app.route ( ' /auditor ' )
def auditor ( ) :
return flask . render_template ( ' election/auditor.html ' )
2017-11-25 22:37:54 +11:00
@app.route ( ' /debug ' )
def debug ( ) :
assert False
@app.route ( ' /auth/login ' )
def login ( ) :
return flask . render_template ( ' auth/login.html ' )
@app.route ( ' /auth/logout ' )
def logout ( ) :
flask . session [ ' user ' ] = None
#return flask.redirect(flask.request.args['next'] if 'next' in flask.request.args else '/')
# I feel like there's some kind of exploit here, so we'll leave this for now
return flask . redirect ( ' / ' )
@app.route ( ' /auth/login_complete ' )
def login_complete ( ) :
return flask . render_template ( ' auth/login_complete.html ' )
@app.route ( ' /auth/login_cancelled ' )
def login_cancelled ( ) :
return flask . render_template ( ' auth/login_cancelled.html ' )
2017-11-26 20:48:15 +11:00
@app.route ( ' /auth/email/login ' )
def email_login ( ) :
return flask . render_template ( ' auth/email/login.html ' )
@app.route ( ' /auth/email/authenticate ' , methods = [ ' POST ' ] )
def email_authenticate ( ) :
user = None
for election in Election . get_all ( ) :
for voter in election . voters :
if isinstance ( voter . user , EmailUser ) :
2017-11-28 00:26:13 +11:00
if voter . user . email . lower ( ) == flask . request . form [ ' email ' ] . lower ( ) :
2017-11-26 20:48:15 +11:00
if voter . user . password == flask . request . form [ ' password ' ] :
user = voter . user
break
if user is None :
return flask . render_template ( ' auth/email/login.html ' , error = ' The email or password you entered was invalid. Please check your details and try again. If the issue persists, contact the election administrator. ' )
flask . session [ ' user ' ] = user
return flask . redirect ( flask . url_for ( ' login_complete ' ) )
2017-11-25 22:37:54 +11:00
# === Apps ===
for app_name in app . config [ ' APPS ' ] :
app_main = importlib . import_module ( app_name + ' .main ' )
app_main . main ( app )
2017-11-23 18:18:01 +11:00
# === Model-Views ===
model_view_map = { }
# TODO: Make more modular
from . import modelview
model_view_map . update ( modelview . model_view_map )