From 11eb512bfb4e8b53f8945775e8de4e13c0f26ba4 Mon Sep 17 00:00:00 2001 From: 007vedant Date: Thu, 21 Apr 2022 23:44:40 +0530 Subject: [PATCH 1/8] Modified minimum passcode length to admin configurable value - added env variable MIN_PASSCODE_LENGTH in .env.example (to be set by admin in .env) - added config variable for MIN_PASSCODE_LENGTH (default value = 6) - added decorator for performing passcode length check - decorated elections_voting_page() --- .env.example | 2 ++ config.py | 2 ++ elekto/controllers/elections.py | 3 ++- elekto/middlewares/auth.py | 17 ++++++++++++++++- 4 files changed, 22 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index 4126f8c..c489143 100644 --- a/.env.example +++ b/.env.example @@ -24,3 +24,5 @@ META_SECRET= GITHUB_REDIRECT=/oauth/github/callback GITHUB_CLIENT_ID= GITHUB_CLIENT_SECRET= + +MIN_PASSCODE_LENGTH= diff --git a/config.py b/config.py index 90f8e8c..542252d 100644 --- a/config.py +++ b/config.py @@ -104,3 +104,5 @@ 'redirect': env('GITHUB_REDIRECT', '/oauth/github/callback'), 'scope': 'user:login,name', } + +PASSCODE_LENGTH = env('MIN_PASSCODE_LENGTH', 6) diff --git a/elekto/controllers/elections.py b/elekto/controllers/elections.py index a405364..1bc9f25 100644 --- a/elekto/controllers/elections.py +++ b/elekto/controllers/elections.py @@ -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 @@ -92,6 +92,7 @@ def elections_candidate(eid, cid): @APP.route("/app/elections//vote", methods=["GET", "POST"]) @auth_guard @voter_guard +@len_guard def elections_voting_page(eid): election = meta.Election(eid) candidates = election.candidates() diff --git a/elekto/middlewares/auth.py b/elekto/middlewares/auth.py index dd6fcbb..a5e22c8 100644 --- a/elekto/middlewares/auth.py +++ b/elekto/middlewares/auth.py @@ -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(): @@ -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 + + From 53b33fa5b04b6a5fcc87ab4a01f32bd75747e523 Mon Sep 17 00:00:00 2001 From: 007vedant Date: Wed, 18 May 2022 03:45:22 +0530 Subject: [PATCH 2/8] Updated UI for min passcode length - added text for min passcode length in vote.html - moved MIN_PASSCODE_LENGTH env variable with the topmost group --- .env.example | 3 ++- elekto/controllers/elections.py | 1 + elekto/templates/views/elections/vote.html | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index c489143..d085bb7 100644 --- a/.env.example +++ b/.env.example @@ -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 @@ -25,4 +26,4 @@ GITHUB_REDIRECT=/oauth/github/callback GITHUB_CLIENT_ID= GITHUB_CLIENT_SECRET= -MIN_PASSCODE_LENGTH= + diff --git a/elekto/controllers/elections.py b/elekto/controllers/elections.py index 1bc9f25..414ff6c 100644 --- a/elekto/controllers/elections.py +++ b/elekto/controllers/elections.py @@ -137,6 +137,7 @@ def elections_voting_page(eid): election=election.get(), candidates=candidates, voters=voters, + min_passcode_len=APP.config.get('PASSCODE_LENGTH') ) diff --git a/elekto/templates/views/elections/vote.html b/elekto/templates/views/elections/vote.html index 7b26e9c..be4b727 100644 --- a/elekto/templates/views/elections/vote.html +++ b/elekto/templates/views/elections/vote.html @@ -61,7 +61,7 @@
- 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.
From 67b58fef9c41efe222bbaab9556718b24ee47fad Mon Sep 17 00:00:00 2001 From: 007vedant Date: Mon, 23 May 2022 01:13:12 +0530 Subject: [PATCH 3/8] Added feature to view the casted ballot - added elections_view to handle ballot recovery - added ballots.html webpage to display in UI - updated Revoke Ballot button to View Ballot in single.html webpage --- elekto/controllers/elections.py | 27 ++++ elekto/templates/views/elections/ballots.html | 116 ++++++++++++++++++ elekto/templates/views/elections/single.html | 4 +- 3 files changed, 145 insertions(+), 2 deletions(-) create mode 100644 elekto/templates/views/elections/ballots.html diff --git a/elekto/controllers/elections.py b/elekto/controllers/elections.py index a405364..74f34dd 100644 --- a/elekto/controllers/elections.py +++ b/elekto/controllers/elections.py @@ -139,6 +139,33 @@ def elections_voting_page(eid): ) +@APP.route("/app/elections//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/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//vote/edit", methods=["POST"]) @auth_guard @voter_guard diff --git a/elekto/templates/views/elections/ballots.html b/elekto/templates/views/elections/ballots.html new file mode 100644 index 0000000..4cca3c5 --- /dev/null +++ b/elekto/templates/views/elections/ballots.html @@ -0,0 +1,116 @@ +{% extends 'layouts/app.html' %} + +{% block header %} +

{% block title %}{{ election['name'] }}{% endblock %}

+{% endblock %} + +{% block breadcrums %} +elections +{{ + election['name'] }} +{% endblock %} + +{% block content %} + + +
+
+

+ {{ election['name'] }} +

+ +
+ {{ election['description'] | safe }} +
+
+
+

+ Your Ballot +

+
+ {% for ballot in ballots %} +
+
+
+ {{ ballot.candidate }} +
+
+
+
+ {{ ballot.rank }} +
+
+
+ {% endfor %} +
+

+ {% 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 {{ election['start_datetime'] }} UTC and ends at + {{ election['end_datetime'] }} UTC. + {% if g.user.username not in voters['eligible_voters'] %} + If you wish to participate in the election, please fill the + exception form + before {{ election['exception_due'] }}. + {% endif %} +

+
+ +
+
+ {% if election['status'] == 'running' and g.user.username in voters['eligible_voters'] %} + {% if g.user.id not in voted %} +
+ Vote +
+ {% else %} +
+
+ +
+ +
+ +
+
+
+
+ {% endif %} + {% endif %} +
+ {% if election['status'] == 'completed' %} + {% if election['results'] %} + Results + {% else %} + + {% endif %} + {% endif %} + + {% if g.user.username in election['election_officers'] %} + Admin + {% endif %} +
+
+
+
+ +{% endblock %} diff --git a/elekto/templates/views/elections/single.html b/elekto/templates/views/elections/single.html index 84d2263..daf5466 100644 --- a/elekto/templates/views/elections/single.html +++ b/elekto/templates/views/elections/single.html @@ -83,12 +83,12 @@

{% else %}
-
+
- +
From 8bd2526b11683a5f0b42749cbbbdbd2dc19acac0 Mon Sep 17 00:00:00 2001 From: 007vedant Date: Wed, 1 Jun 2022 12:05:38 +0530 Subject: [PATCH 4/8] Fixed CSS issues in View Ballot template --- elekto/controllers/elections.py | 2 +- .../views/elections/{ballots.html => view_ballots.html} | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) rename elekto/templates/views/elections/{ballots.html => view_ballots.html} (97%) diff --git a/elekto/controllers/elections.py b/elekto/controllers/elections.py index 74f34dd..4e04bb3 100644 --- a/elekto/controllers/elections.py +++ b/elekto/controllers/elections.py @@ -155,7 +155,7 @@ def elections_view(eid): # 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/ballots.html", election=election.get(), voters=voters, voted=[v.user_id for v in e.voters], ballots=ballots) + 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: diff --git a/elekto/templates/views/elections/ballots.html b/elekto/templates/views/elections/view_ballots.html similarity index 97% rename from elekto/templates/views/elections/ballots.html rename to elekto/templates/views/elections/view_ballots.html index 4cca3c5..6b2c457 100644 --- a/elekto/templates/views/elections/ballots.html +++ b/elekto/templates/views/elections/view_ballots.html @@ -37,9 +37,9 @@

Your Ballot

-
+
{% for ballot in ballots %} -
+
{{ ballot.candidate }} From 6a5029120fba4468bd0015a48c4b35b4f5319aff Mon Sep 17 00:00:00 2001 From: Josh Berkus Date: Thu, 9 Jun 2022 17:48:42 -0700 Subject: [PATCH 5/8] First draft of adding a new database upgrade facility in the SQL models. Signed-off-by: Josh Berkus --- elekto/models/sql.py | 77 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 72 insertions(+), 5 deletions(-) diff --git a/elekto/models/sql.py b/elekto/models/sql.py index cfff12d..c6c14f2 100644 --- a/elekto/models/sql.py +++ b/elekto/models/sql.py @@ -11,9 +11,9 @@ # 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 - import uuid import sqlalchemy as S @@ -24,6 +24,10 @@ BASE = declarative_base() +# schema version remember to update this +# whenever you make changes to the schema +schema_version = 2 + def create_session(url): """ @@ -46,6 +50,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 @@ -54,12 +59,65 @@ def migrate(url): ie: ://:@/ """ 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 + + Start by figuring out our schema version, and then upgrade + stepwise until we match + """ + db_version = 1; + + if engine.dialect.has_table(engine, "schema_version"): + db_version = engine.execute('select version from schema_version').scalar() + + while db_version < schema_version: + if engine.dialect.name != "postgresql": + raise RuntimeError('Upgrading the schema is required, but the database is not PostgreSQL') + + 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 + """ + session = scoped_session(sessionmaker(bind=engine)) + + session.execute('CREATE TABLE schema_version ( version INT );') + 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 'historical ballot ';") + session.execute('LTER 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.execute('') + session.execute('') + session.commit() + + return 2 class UUID(TypeDecorator): @@ -94,6 +152,15 @@ 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) + class User(BASE): """ User Schema - registered from the oauth external application - github @@ -185,11 +252,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 @@ -227,7 +294,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 From bb120f212be0e95fbf944f88577ee208017f39d2 Mon Sep 17 00:00:00 2001 From: Josh Berkus Date: Wed, 22 Jun 2022 16:19:43 -0700 Subject: [PATCH 6/8] More work on the mini-migration tool. Signed-off-by: Josh Berkus --- elekto/models/sql.py | 39 ++++++++++++++++++++++++++++----------- 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/elekto/models/sql.py b/elekto/models/sql.py index c6c14f2..768ff44 100644 --- a/elekto/models/sql.py +++ b/elekto/models/sql.py @@ -20,12 +20,15 @@ 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, remember to update this +whenever you make changes to the schema +""" schema_version = 2 @@ -61,6 +64,7 @@ def migrate(url): 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) @@ -74,19 +78,27 @@ def update_schema(engine, schema_version): with production Elekto databases Currently only works with PostgreSQL due to requiring transaction - support for DDL statements + 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_version = 1 + db_schema = S.inspect(engine) - if engine.dialect.has_table(engine, "schema_version"): - db_version = engine.execute('select version from schema_version').scalar() + 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') + 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) @@ -100,6 +112,8 @@ 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)) @@ -110,11 +124,9 @@ def update_schema_2(engine): 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 'historical ballot ';") - session.execute('LTER TABLE ballot ALTER COLUMN id DROP DEFAULT;') + 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.execute('') - session.execute('') session.commit() return 2 @@ -159,7 +171,12 @@ class Version(BASE): __tablename__ = "schema_version" # Attributes - version = S.Column(S.Integer, default=schema_version) + version = S.Column(S.Integer, default=schema_version, primary_key=True) + +@event.listens_for(Version.__table__, 'after_create') +def create_version(*args, **kwargs): + db.session.add(Version(version=schema_version)) + db.session.commit() class User(BASE): """ From 25f0d2d0d109bd70cfdd24f3af93d0713fc0641e Mon Sep 17 00:00:00 2001 From: Josh Berkus Date: Wed, 22 Jun 2022 17:25:03 -0700 Subject: [PATCH 7/8] Final changes for Postgres database migration. Signed-off-by: Josh Berkus --- elekto/models/sql.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/elekto/models/sql.py b/elekto/models/sql.py index 768ff44..18a3383 100644 --- a/elekto/models/sql.py +++ b/elekto/models/sql.py @@ -117,13 +117,13 @@ def update_schema_2(engine): """ session = scoped_session(sessionmaker(bind=engine)) - session.execute('CREATE TABLE schema_version ( version INT );') + 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 'historical ballot ';") + 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);') From 8954104efa221bc7b10627349bbed3852db83185 Mon Sep 17 00:00:00 2001 From: Josh Berkus Date: Wed, 22 Jun 2022 22:26:01 -0700 Subject: [PATCH 8/8] Final tweak on prepopulating schema_version on new databases. Signed-off-by: Josh Berkus --- elekto/models/sql.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/elekto/models/sql.py b/elekto/models/sql.py index 18a3383..226bada 100644 --- a/elekto/models/sql.py +++ b/elekto/models/sql.py @@ -174,9 +174,8 @@ class Version(BASE): version = S.Column(S.Integer, default=schema_version, primary_key=True) @event.listens_for(Version.__table__, 'after_create') -def create_version(*args, **kwargs): - db.session.add(Version(version=schema_version)) - db.session.commit() +def create_version(target, connection, **kwargs): + connection.execute(f"INSERT INTO schema_version ( version ) VALUES ( {schema_version} )") class User(BASE): """