diff --git a/.env.example b/.env.example index 4126f8c..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 @@ -24,3 +25,5 @@ META_SECRET= GITHUB_REDIRECT=/oauth/github/callback GITHUB_CLIENT_ID= GITHUB_CLIENT_SECRET= + + 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..41ee921 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() @@ -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//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//vote/edit", methods=["POST"]) @auth_guard @voter_guard 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 + + diff --git a/elekto/models/sql.py b/elekto/models/sql.py index cfff12d..226bada 100644 --- a/elekto/models/sql.py +++ b/elekto/models/sql.py @@ -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 - 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): """ @@ -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 @@ -54,12 +62,74 @@ 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. 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): @@ -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 @@ -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 @@ -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 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 %}
-
+
- +
diff --git a/elekto/templates/views/elections/view_ballots.html b/elekto/templates/views/elections/view_ballots.html new file mode 100644 index 0000000..6b2c457 --- /dev/null +++ b/elekto/templates/views/elections/view_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/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.