Skip to content

Commit 01f044b

Browse files
authored
Merge pull request #66 from elekto-io/migration
Security updates and migration code for 0.6
2 parents 4fff346 + 8954104 commit 01f044b

File tree

8 files changed

+258
-10
lines changed

8 files changed

+258
-10
lines changed

.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ APP_URL=http://localhost
66
APP_PORT=5000
77
APP_HOST=localhost
88
APP_CONNECT=http
9+
MIN_PASSCODE_LENGTH=
910

1011
DB_CONNECTION=mysql
1112
DB_HOST=localhost
@@ -24,3 +25,5 @@ META_SECRET=
2425
GITHUB_REDIRECT=/oauth/github/callback
2526
GITHUB_CLIENT_ID=
2627
GITHUB_CLIENT_SECRET=
28+
29+

config.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,3 +104,5 @@
104104
'redirect': env('GITHUB_REDIRECT', '/oauth/github/callback'),
105105
'scope': 'user:login,name',
106106
}
107+
108+
PASSCODE_LENGTH = env('MIN_PASSCODE_LENGTH', 6)

elekto/controllers/elections.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
from elekto.models import meta
3030
from elekto.core.election import Election as CoreElection
3131
from elekto.models.sql import Election, Ballot, Voter, Request
32-
from elekto.middlewares.auth import auth_guard
32+
from elekto.middlewares.auth import auth_guard, len_guard
3333
from elekto.core.encryption import encrypt, decrypt
3434
from elekto.middlewares.election import * # noqa
3535

@@ -92,6 +92,7 @@ def elections_candidate(eid, cid):
9292
@APP.route("/app/elections/<eid>/vote", methods=["GET", "POST"])
9393
@auth_guard
9494
@voter_guard
95+
@len_guard
9596
def elections_voting_page(eid):
9697
election = meta.Election(eid)
9798
candidates = election.candidates()
@@ -136,9 +137,37 @@ def elections_voting_page(eid):
136137
election=election.get(),
137138
candidates=candidates,
138139
voters=voters,
140+
min_passcode_len=APP.config.get('PASSCODE_LENGTH')
139141
)
140142

141143

144+
@APP.route("/app/elections/<eid>/vote/view", methods=["POST"])
145+
@auth_guard
146+
@voter_guard
147+
@has_voted_condition
148+
def elections_view(eid):
149+
election = meta.Election(eid)
150+
voters = election.voters()
151+
e = SESSION.query(Election).filter_by(key=eid).first()
152+
voter = SESSION.query(Voter).filter_by(user_id=F.g.user.id).first()
153+
154+
passcode = F.request.form["password"]
155+
156+
try:
157+
# decrypt ballot_id if passcode is correct
158+
ballot_voter = decrypt(voter.salt, passcode, voter.ballot_id)
159+
ballots = SESSION.query(Ballot).filter_by(voter=ballot_voter)
160+
return F.render_template("views/elections/view_ballots.html", election=election.get(), voters=voters, voted=[v.user_id for v in e.voters], ballots=ballots)
161+
162+
# if passcode is wrong
163+
except Exception:
164+
F.flash(
165+
"Incorrect password, the password must match with the one used\
166+
before"
167+
)
168+
return F.redirect(F.url_for("elections_single", eid=eid))
169+
170+
142171
@APP.route("/app/elections/<eid>/vote/edit", methods=["POST"])
143172
@auth_guard
144173
@voter_guard

elekto/middlewares/auth.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
import flask as F
1818
import base64
1919
from functools import wraps
20-
from elekto import constants
20+
from elekto import APP, constants
2121

2222

2323
def authenticated():
@@ -53,3 +53,18 @@ def decorated_function(*args, **kwargs):
5353

5454
return f(*args, **kwargs)
5555
return decorated_function
56+
57+
58+
def len_guard(f):
59+
@wraps(f)
60+
def decorated_function(*args, **kwargs):
61+
if F.request.method == "POST":
62+
passcode = F.request.form["password"]
63+
min_passcode_len = int(APP.config.get('PASSCODE_LENGTH'))
64+
if 0 < len(passcode) < min_passcode_len:
65+
F.flash(f"Please enter a passphrase with minimum {min_passcode_len} characters")
66+
return F.redirect(F.request.url)
67+
return f(*args, **kwargs)
68+
return decorated_function
69+
70+

elekto/models/sql.py

Lines changed: 88 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,19 +11,26 @@
1111
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
14+
1415
#
1516
# Author(s): Manish Sahani <[email protected]>
16-
1717
import uuid
1818
import sqlalchemy as S
1919

2020
from sqlalchemy.orm import sessionmaker, scoped_session
2121
from sqlalchemy.ext.declarative import declarative_base
2222
from sqlalchemy.types import TypeDecorator, CHAR
23+
from sqlalchemy import event
2324

2425

2526
BASE = declarative_base()
2627

28+
"""
29+
schema version, remember to update this
30+
whenever you make changes to the schema
31+
"""
32+
schema_version = 2
33+
2734

2835
def create_session(url):
2936
"""
@@ -46,6 +53,7 @@ def create_session(url):
4653
def migrate(url):
4754
"""
4855
Create the tables in the database using the url
56+
Check if we need to upgrade the schema, and do that as well
4957
5058
Args:
5159
url (string): the URL used to connect the application to the
@@ -54,12 +62,74 @@ def migrate(url):
5462
ie: <engine>://<user>:<password>@<host>/<dbname>
5563
"""
5664
engine = S.create_engine(url)
65+
update_schema(engine, schema_version)
5766
BASE.metadata.create_all(bind=engine)
67+
5868

5969
session = scoped_session(
6070
sessionmaker(bind=engine, autocommit=False, autoflush=False)
6171
)
6272
return session
73+
74+
75+
def update_schema(engine, schema_version):
76+
"""
77+
Primitive database schema upgrade facility, designed to work
78+
with production Elekto databases
79+
80+
Currently only works with PostgreSQL due to requiring transaction
81+
support for DDL statements. MySQL, SQLite backends will error.
82+
83+
Start by figuring out our schema version, and then upgrade
84+
stepwise until we match
85+
"""
86+
db_version = 1
87+
db_schema = S.inspect(engine)
88+
89+
if db_schema.has_table("election"):
90+
if db_schema.has_table("schema_version"):
91+
db_version = engine.execute('select version from schema_version').scalar()
92+
if db_version is None:
93+
""" intialize the table, if necessary """
94+
engine.execute('insert into schema_version ( version ) values ( 2 )')
95+
else:
96+
""" new, empty db """
97+
return schema_version
98+
99+
while db_version < schema_version:
100+
if engine.dialect.name != "postgresql":
101+
raise RuntimeError('Upgrading the schema is required, but the database is not PostgreSQL. You will need to upgrade manually.')
102+
103+
if db_version < 2:
104+
db_version = update_schema_2(engine)
105+
continue
106+
107+
return db_version;
108+
109+
110+
def update_schema_2(engine):
111+
"""
112+
update from schema version 1 to schema version 2
113+
as a set of raw SQL statements
114+
currently only works for PostgreSQL
115+
written this way because SQLalchemy can't handle the
116+
steps involved without data loss
117+
"""
118+
session = scoped_session(sessionmaker(bind=engine))
119+
120+
session.execute('CREATE TABLE schema_version ( version INT PRIMARY KEY);')
121+
session.execute('INSERT INTO schema_version VALUES ( 2 );')
122+
session.execute('ALTER TABLE voter ADD COLUMN salt BYTEA, ADD COLUMN ballot_id BYTEA;')
123+
session.execute('CREATE INDEX voter_election_id ON voter(election_id);')
124+
session.execute('ALTER TABLE ballot DROP COLUMN created_at, DROP COLUMN updated_at;')
125+
session.execute('ALTER TABLE ballot DROP CONSTRAINT ballot_pkey;')
126+
session.execute("ALTER TABLE ballot ALTER COLUMN id TYPE CHAR(32) USING to_char(id , 'FM00000000000000000000000000000000');")
127+
session.execute('ALTER TABLE ballot ALTER COLUMN id DROP DEFAULT;')
128+
session.execute('ALTER TABLE ballot ADD CONSTRAINT ballot_pkey PRIMARY KEY ( id );')
129+
session.execute('CREATE INDEX ballot_election_id ON ballot(election_id);')
130+
session.commit()
131+
132+
return 2
63133

64134

65135
class UUID(TypeDecorator):
@@ -94,6 +164,19 @@ def process_result_value(self, value, dialect):
94164
return value
95165

96166

167+
class Version(BASE):
168+
"""
169+
Stores Elekto schema version in the database for ad-hoc upgrades
170+
"""
171+
__tablename__ = "schema_version"
172+
173+
# Attributes
174+
version = S.Column(S.Integer, default=schema_version, primary_key=True)
175+
176+
@event.listens_for(Version.__table__, 'after_create')
177+
def create_version(target, connection, **kwargs):
178+
connection.execute(f"INSERT INTO schema_version ( version ) VALUES ( {schema_version} )")
179+
97180
class User(BASE):
98181
"""
99182
User Schema - registered from the oauth external application - github
@@ -185,11 +268,11 @@ class Voter(BASE):
185268

186269
id = S.Column(S.Integer, primary_key=True)
187270
user_id = S.Column(S.Integer, S.ForeignKey("user.id", ondelete="CASCADE"))
188-
election_id = S.Column(S.Integer, S.ForeignKey("election.id", ondelete="CASCADE"))
271+
election_id = S.Column(S.Integer, S.ForeignKey("election.id", ondelete="CASCADE"), index=True)
189272
created_at = S.Column(S.DateTime, default=S.func.now())
190273
updated_at = S.Column(S.DateTime, default=S.func.now())
191-
salt = S.Column(S.LargeBinary, nullable=False)
192-
ballot_id = S.Column(S.LargeBinary, nullable=False) # encrypted
274+
salt = S.Column(S.LargeBinary)
275+
ballot_id = S.Column(S.LargeBinary) # encrypted
193276

194277
# Relationships
195278

@@ -227,7 +310,7 @@ class Ballot(BASE):
227310

228311
# Attributes
229312
id = S.Column(UUID(), primary_key=True, default=uuid.uuid4)
230-
election_id = S.Column(S.Integer, S.ForeignKey("election.id", ondelete="CASCADE"))
313+
election_id = S.Column(S.Integer, S.ForeignKey("election.id", ondelete="CASCADE"), index=True)
231314
rank = S.Column(S.Integer, default=100000000)
232315
candidate = S.Column(S.String(255), nullable=False)
233316
voter = S.Column(S.String(255), nullable=False) # uuid

elekto/templates/views/elections/single.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,12 +83,12 @@ <h2 class="title pb-0 mb-0">
8383
</div>
8484
{% else %}
8585
<div class="col-md-6">
86-
<form action="{{ url_for('elections_edit', eid=election['key']) }}" method="post">
86+
<form action="{{ url_for('elections_view', eid=election['key']) }}" method="post">
8787
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
8888
<div class="input-group">
8989
<input type="password" name="password" class="form-control" placeholder="Enter the passphrase" id="">
9090
<div class="input-group-append">
91-
<button type="submit" class="btn btn-dark">Revoke Ballot</button>
91+
<button type="submit" class="btn btn-dark">View Ballot</button>
9292
</div>
9393
</div>
9494
</form>
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
{% extends 'layouts/app.html' %}
2+
3+
{% block header %}
4+
<h1>{% block title %}{{ election['name'] }}{% endblock %}</h1>
5+
{% endblock %}
6+
7+
{% block breadcrums %}
8+
<a href="{{ url_for('elections') }}" class="breadcrums">elections</a>
9+
<a href="{{ url_for('elections_single', eid=election['key']) }}" class="breadcrums breadcrums-active">{{
10+
election['name'] }}</a>
11+
{% endblock %}
12+
13+
{% block content %}
14+
15+
16+
<div class="">
17+
<div class="space--md pb-0">
18+
<h1 class="banner-title space-lr">
19+
{{ election['name'] }}
20+
</h1>
21+
<p class="banner-subtitle space-lr mb-2rem">
22+
<span class="mr-5px">{{ election['organization'] }}</span>
23+
<span class="text-muted mr-5px">|</span>
24+
<small class="badge mr-5px badge-{{ election['status'] }} ">{{ election['status'] }}</small>
25+
<span class="text-muted mr-5px">|</span>
26+
{% if g.user.username in voters['eligible_voters'] %}
27+
<small class="badge badge-blue ">eligible</small>
28+
{% else %}
29+
<small class="badge badge-blue">Not eligible</small>
30+
{% endif %}
31+
</p>
32+
<div class="description space-lr pb-0">
33+
{{ election['description'] | safe }}
34+
</div>
35+
</div>
36+
<div class="space--md pt-0">
37+
<h4 class="title space-lr mb-1rem">
38+
Your Ballot
39+
</h4>
40+
<div class="space-lr">
41+
{% for ballot in ballots %}
42+
<div class="boxed-hover row" style="border: 1px solid #80808012;">
43+
<div class="col-10 pt-5px pl-0">
44+
<h6 class="title mt-5px pb-0 mb-0">
45+
{{ ballot.candidate }}
46+
</h6>
47+
</div>
48+
<div class="col-2 text--right">
49+
<h6 class="title mt-5px pb-0 mb-0">
50+
{{ ballot.rank }}
51+
</h6>
52+
</div>
53+
</div>
54+
{% endfor %}
55+
</div>
56+
<p class="disclaimer space-lr mt-1rem">
57+
{% if election['status'] == 'running' and g.user.username in voters['eligible_voters'] %}
58+
{% if g.user.id in voted %}
59+
You have cast your vote.
60+
{% else %}
61+
You have not yet voted in this election.
62+
{% endif %}
63+
{% endif %}
64+
Voting starts at <b>{{ election['start_datetime'] }} UTC</b> and ends at
65+
<b>{{ election['end_datetime'] }} UTC</b>.
66+
{% if g.user.username not in voters['eligible_voters'] %}
67+
If you wish to participate in the election, please fill the
68+
<a href="{{ url_for('elections_exception', eid=election['key']) }}"><b>exception form</b></a>
69+
before <b>{{ election['exception_due'] }}</b>.
70+
{% endif %}
71+
</p>
72+
</div>
73+
74+
<div class="space--md pt-0">
75+
<div class="space-lr row">
76+
{% if election['status'] == 'running' and g.user.username in voters['eligible_voters'] %}
77+
{% if g.user.id not in voted %}
78+
<div class="col-md-2 pr-0">
79+
<a href="{{ url_for('elections_voting_page', eid=election['key'])}}" class="btn btn-dark pl-3rem pr-3rem">Vote</a>
80+
</div>
81+
{% else %}
82+
<div class="col-md-6">
83+
<form action="{{ url_for('elections_edit', eid=election['key']) }}" method="post">
84+
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
85+
<div class="input-group">
86+
<input type="password" name="password" class="form-control" placeholder="Enter the passphrase" id="">
87+
<div class="input-group-append">
88+
<button type="submit" class="btn btn-dark">Revoke Ballot</button>
89+
</div>
90+
</div>
91+
</form>
92+
</div>
93+
{% endif %}
94+
{% endif %}
95+
<div class="col-md-6
96+
{% if election['status'] == 'running' and g.user.username in voters['eligible_voters'] %}
97+
pl-0
98+
{% endif %}
99+
">
100+
{% if election['status'] == 'completed' %}
101+
{% if election['results'] %}
102+
<a href="{{ url_for('elections_results', eid=election['key'])}}" class="btn btn-dark">Results</a>
103+
{% else %}
104+
<button class="btn btn-dark" disabled>Results (not published)</button>
105+
{% endif %}
106+
{% endif %}
107+
108+
{% if g.user.username in election['election_officers'] %}
109+
<a href="{{ url_for('elections_admin', eid=election['key'])}}" class="btn btn-dark">Admin</a>
110+
{% endif %}
111+
</div>
112+
</div>
113+
</div>
114+
</div>
115+
116+
{% endblock %}

elekto/templates/views/elections/vote.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ <h6 class="title mt-5px pb-0 mb-0">
6161
<div class="mt-2rem pt-2rem row">
6262
<div class="col-md-6 text-justify">
6363
<small>
64-
If you wish to be able to revoke this ballot, please enter a passphrase here. If you do not enter a passphrase, you will not be able to change or delete your vote later.
64+
If you wish to be able to revoke this ballot, please enter a passphrase of minimum {{ min_passcode_len }} characters here. If you do not enter a passphrase, you will not be able to change or delete your vote later.
6565
</small>
6666
</div>
6767
<div class="col-md-6 pt-5px">

0 commit comments

Comments
 (0)