Skip to content

Commit

Permalink
Merge pull request #66 from elekto-io/migration
Browse files Browse the repository at this point in the history
Security updates and migration code for 0.6
  • Loading branch information
jberkus authored Jul 1, 2022
2 parents 4fff346 + 8954104 commit 01f044b
Show file tree
Hide file tree
Showing 8 changed files with 258 additions and 10 deletions.
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ APP_URL=http://localhost
APP_PORT=5000
APP_HOST=localhost
APP_CONNECT=http
MIN_PASSCODE_LENGTH=

DB_CONNECTION=mysql
DB_HOST=localhost
Expand All @@ -24,3 +25,5 @@ META_SECRET=
GITHUB_REDIRECT=/oauth/github/callback
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=


2 changes: 2 additions & 0 deletions config.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,3 +104,5 @@
'redirect': env('GITHUB_REDIRECT', '/oauth/github/callback'),
'scope': 'user:login,name',
}

PASSCODE_LENGTH = env('MIN_PASSCODE_LENGTH', 6)
31 changes: 30 additions & 1 deletion elekto/controllers/elections.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
from elekto.models import meta
from elekto.core.election import Election as CoreElection
from elekto.models.sql import Election, Ballot, Voter, Request
from elekto.middlewares.auth import auth_guard
from elekto.middlewares.auth import auth_guard, len_guard
from elekto.core.encryption import encrypt, decrypt
from elekto.middlewares.election import * # noqa

Expand Down Expand Up @@ -92,6 +92,7 @@ def elections_candidate(eid, cid):
@APP.route("/app/elections/<eid>/vote", methods=["GET", "POST"])
@auth_guard
@voter_guard
@len_guard
def elections_voting_page(eid):
election = meta.Election(eid)
candidates = election.candidates()
Expand Down Expand Up @@ -136,9 +137,37 @@ def elections_voting_page(eid):
election=election.get(),
candidates=candidates,
voters=voters,
min_passcode_len=APP.config.get('PASSCODE_LENGTH')
)


@APP.route("/app/elections/<eid>/vote/view", methods=["POST"])
@auth_guard
@voter_guard
@has_voted_condition
def elections_view(eid):
election = meta.Election(eid)
voters = election.voters()
e = SESSION.query(Election).filter_by(key=eid).first()
voter = SESSION.query(Voter).filter_by(user_id=F.g.user.id).first()

passcode = F.request.form["password"]

try:
# decrypt ballot_id if passcode is correct
ballot_voter = decrypt(voter.salt, passcode, voter.ballot_id)
ballots = SESSION.query(Ballot).filter_by(voter=ballot_voter)
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)

# if passcode is wrong
except Exception:
F.flash(
"Incorrect password, the password must match with the one used\
before"
)
return F.redirect(F.url_for("elections_single", eid=eid))


@APP.route("/app/elections/<eid>/vote/edit", methods=["POST"])
@auth_guard
@voter_guard
Expand Down
17 changes: 16 additions & 1 deletion elekto/middlewares/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
import flask as F
import base64
from functools import wraps
from elekto import constants
from elekto import APP, constants


def authenticated():
Expand Down Expand Up @@ -53,3 +53,18 @@ def decorated_function(*args, **kwargs):

return f(*args, **kwargs)
return decorated_function


def len_guard(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if F.request.method == "POST":
passcode = F.request.form["password"]
min_passcode_len = int(APP.config.get('PASSCODE_LENGTH'))
if 0 < len(passcode) < min_passcode_len:
F.flash(f"Please enter a passphrase with minimum {min_passcode_len} characters")
return F.redirect(F.request.url)
return f(*args, **kwargs)
return decorated_function


93 changes: 88 additions & 5 deletions elekto/models/sql.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,26 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

#
# Author(s): Manish Sahani <[email protected]>

import uuid
import sqlalchemy as S

from sqlalchemy.orm import sessionmaker, scoped_session
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.types import TypeDecorator, CHAR
from sqlalchemy import event


BASE = declarative_base()

"""
schema version, remember to update this
whenever you make changes to the schema
"""
schema_version = 2


def create_session(url):
"""
Expand All @@ -46,6 +53,7 @@ def create_session(url):
def migrate(url):
"""
Create the tables in the database using the url
Check if we need to upgrade the schema, and do that as well
Args:
url (string): the URL used to connect the application to the
Expand All @@ -54,12 +62,74 @@ def migrate(url):
ie: <engine>://<user>:<password>@<host>/<dbname>
"""
engine = S.create_engine(url)
update_schema(engine, schema_version)
BASE.metadata.create_all(bind=engine)


session = scoped_session(
sessionmaker(bind=engine, autocommit=False, autoflush=False)
)
return session


def update_schema(engine, schema_version):
"""
Primitive database schema upgrade facility, designed to work
with production Elekto databases
Currently only works with PostgreSQL due to requiring transaction
support for DDL statements. MySQL, SQLite backends will error.
Start by figuring out our schema version, and then upgrade
stepwise until we match
"""
db_version = 1
db_schema = S.inspect(engine)

if db_schema.has_table("election"):
if db_schema.has_table("schema_version"):
db_version = engine.execute('select version from schema_version').scalar()
if db_version is None:
""" intialize the table, if necessary """
engine.execute('insert into schema_version ( version ) values ( 2 )')
else:
""" new, empty db """
return schema_version

while db_version < schema_version:
if engine.dialect.name != "postgresql":
raise RuntimeError('Upgrading the schema is required, but the database is not PostgreSQL. You will need to upgrade manually.')

if db_version < 2:
db_version = update_schema_2(engine)
continue

return db_version;


def update_schema_2(engine):
"""
update from schema version 1 to schema version 2
as a set of raw SQL statements
currently only works for PostgreSQL
written this way because SQLalchemy can't handle the
steps involved without data loss
"""
session = scoped_session(sessionmaker(bind=engine))

session.execute('CREATE TABLE schema_version ( version INT PRIMARY KEY);')
session.execute('INSERT INTO schema_version VALUES ( 2 );')
session.execute('ALTER TABLE voter ADD COLUMN salt BYTEA, ADD COLUMN ballot_id BYTEA;')
session.execute('CREATE INDEX voter_election_id ON voter(election_id);')
session.execute('ALTER TABLE ballot DROP COLUMN created_at, DROP COLUMN updated_at;')
session.execute('ALTER TABLE ballot DROP CONSTRAINT ballot_pkey;')
session.execute("ALTER TABLE ballot ALTER COLUMN id TYPE CHAR(32) USING to_char(id , 'FM00000000000000000000000000000000');")
session.execute('ALTER TABLE ballot ALTER COLUMN id DROP DEFAULT;')
session.execute('ALTER TABLE ballot ADD CONSTRAINT ballot_pkey PRIMARY KEY ( id );')
session.execute('CREATE INDEX ballot_election_id ON ballot(election_id);')
session.commit()

return 2


class UUID(TypeDecorator):
Expand Down Expand Up @@ -94,6 +164,19 @@ def process_result_value(self, value, dialect):
return value


class Version(BASE):
"""
Stores Elekto schema version in the database for ad-hoc upgrades
"""
__tablename__ = "schema_version"

# Attributes
version = S.Column(S.Integer, default=schema_version, primary_key=True)

@event.listens_for(Version.__table__, 'after_create')
def create_version(target, connection, **kwargs):
connection.execute(f"INSERT INTO schema_version ( version ) VALUES ( {schema_version} )")

class User(BASE):
"""
User Schema - registered from the oauth external application - github
Expand Down Expand Up @@ -185,11 +268,11 @@ class Voter(BASE):

id = S.Column(S.Integer, primary_key=True)
user_id = S.Column(S.Integer, S.ForeignKey("user.id", ondelete="CASCADE"))
election_id = S.Column(S.Integer, S.ForeignKey("election.id", ondelete="CASCADE"))
election_id = S.Column(S.Integer, S.ForeignKey("election.id", ondelete="CASCADE"), index=True)
created_at = S.Column(S.DateTime, default=S.func.now())
updated_at = S.Column(S.DateTime, default=S.func.now())
salt = S.Column(S.LargeBinary, nullable=False)
ballot_id = S.Column(S.LargeBinary, nullable=False) # encrypted
salt = S.Column(S.LargeBinary)
ballot_id = S.Column(S.LargeBinary) # encrypted

# Relationships

Expand Down Expand Up @@ -227,7 +310,7 @@ class Ballot(BASE):

# Attributes
id = S.Column(UUID(), primary_key=True, default=uuid.uuid4)
election_id = S.Column(S.Integer, S.ForeignKey("election.id", ondelete="CASCADE"))
election_id = S.Column(S.Integer, S.ForeignKey("election.id", ondelete="CASCADE"), index=True)
rank = S.Column(S.Integer, default=100000000)
candidate = S.Column(S.String(255), nullable=False)
voter = S.Column(S.String(255), nullable=False) # uuid
Expand Down
4 changes: 2 additions & 2 deletions elekto/templates/views/elections/single.html
Original file line number Diff line number Diff line change
Expand Up @@ -83,12 +83,12 @@ <h2 class="title pb-0 mb-0">
</div>
{% else %}
<div class="col-md-6">
<form action="{{ url_for('elections_edit', eid=election['key']) }}" method="post">
<form action="{{ url_for('elections_view', eid=election['key']) }}" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="input-group">
<input type="password" name="password" class="form-control" placeholder="Enter the passphrase" id="">
<div class="input-group-append">
<button type="submit" class="btn btn-dark">Revoke Ballot</button>
<button type="submit" class="btn btn-dark">View Ballot</button>
</div>
</div>
</form>
Expand Down
116 changes: 116 additions & 0 deletions elekto/templates/views/elections/view_ballots.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
{% extends 'layouts/app.html' %}

{% block header %}
<h1>{% block title %}{{ election['name'] }}{% endblock %}</h1>
{% endblock %}

{% block breadcrums %}
<a href="{{ url_for('elections') }}" class="breadcrums">elections</a>
<a href="{{ url_for('elections_single', eid=election['key']) }}" class="breadcrums breadcrums-active">{{
election['name'] }}</a>
{% endblock %}

{% block content %}


<div class="">
<div class="space--md pb-0">
<h1 class="banner-title space-lr">
{{ election['name'] }}
</h1>
<p class="banner-subtitle space-lr mb-2rem">
<span class="mr-5px">{{ election['organization'] }}</span>
<span class="text-muted mr-5px">|</span>
<small class="badge mr-5px badge-{{ election['status'] }} ">{{ election['status'] }}</small>
<span class="text-muted mr-5px">|</span>
{% if g.user.username in voters['eligible_voters'] %}
<small class="badge badge-blue ">eligible</small>
{% else %}
<small class="badge badge-blue">Not eligible</small>
{% endif %}
</p>
<div class="description space-lr pb-0">
{{ election['description'] | safe }}
</div>
</div>
<div class="space--md pt-0">
<h4 class="title space-lr mb-1rem">
Your Ballot
</h4>
<div class="space-lr">
{% for ballot in ballots %}
<div class="boxed-hover row" style="border: 1px solid #80808012;">
<div class="col-10 pt-5px pl-0">
<h6 class="title mt-5px pb-0 mb-0">
{{ ballot.candidate }}
</h6>
</div>
<div class="col-2 text--right">
<h6 class="title mt-5px pb-0 mb-0">
{{ ballot.rank }}
</h6>
</div>
</div>
{% endfor %}
</div>
<p class="disclaimer space-lr mt-1rem">
{% if election['status'] == 'running' and g.user.username in voters['eligible_voters'] %}
{% if g.user.id in voted %}
You have cast your vote.
{% else %}
You have not yet voted in this election.
{% endif %}
{% endif %}
Voting starts at <b>{{ election['start_datetime'] }} UTC</b> and ends at
<b>{{ election['end_datetime'] }} UTC</b>.
{% if g.user.username not in voters['eligible_voters'] %}
If you wish to participate in the election, please fill the
<a href="{{ url_for('elections_exception', eid=election['key']) }}"><b>exception form</b></a>
before <b>{{ election['exception_due'] }}</b>.
{% endif %}
</p>
</div>

<div class="space--md pt-0">
<div class="space-lr row">
{% if election['status'] == 'running' and g.user.username in voters['eligible_voters'] %}
{% if g.user.id not in voted %}
<div class="col-md-2 pr-0">
<a href="{{ url_for('elections_voting_page', eid=election['key'])}}" class="btn btn-dark pl-3rem pr-3rem">Vote</a>
</div>
{% else %}
<div class="col-md-6">
<form action="{{ url_for('elections_edit', eid=election['key']) }}" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="input-group">
<input type="password" name="password" class="form-control" placeholder="Enter the passphrase" id="">
<div class="input-group-append">
<button type="submit" class="btn btn-dark">Revoke Ballot</button>
</div>
</div>
</form>
</div>
{% endif %}
{% endif %}
<div class="col-md-6
{% if election['status'] == 'running' and g.user.username in voters['eligible_voters'] %}
pl-0
{% endif %}
">
{% if election['status'] == 'completed' %}
{% if election['results'] %}
<a href="{{ url_for('elections_results', eid=election['key'])}}" class="btn btn-dark">Results</a>
{% else %}
<button class="btn btn-dark" disabled>Results (not published)</button>
{% endif %}
{% endif %}

{% if g.user.username in election['election_officers'] %}
<a href="{{ url_for('elections_admin', eid=election['key'])}}" class="btn btn-dark">Admin</a>
{% endif %}
</div>
</div>
</div>
</div>

{% endblock %}
2 changes: 1 addition & 1 deletion elekto/templates/views/elections/vote.html
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ <h6 class="title mt-5px pb-0 mb-0">
<div class="mt-2rem pt-2rem row">
<div class="col-md-6 text-justify">
<small>
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.
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.
</small>
</div>
<div class="col-md-6 pt-5px">
Expand Down

0 comments on commit 01f044b

Please sign in to comment.