-
Notifications
You must be signed in to change notification settings - Fork 384
Add comprehensive plan for token-based voter login #452
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
0858ceb
a866257
defee02
e5979fa
141cdc5
7ef4609
9181c67
41973ed
f2dedac
4de4cf7
b774a94
cdeaaf6
fe0ebfe
d4d4c0b
3a60dc1
2773223
8752273
b0388b1
199cd2b
065709f
6a97d31
83354ad
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,111 @@ | ||
| # Token-Based Voter Authentication | ||
| **Date:** 2026-01-04 | ||
| **Status:** Implemented | ||
|
|
||
| ## Overview | ||
|
|
||
| Single-token voter authentication replaces the traditional two-field system (voter ID + password) with a single 20-character token, simplifying the voting experience especially on mobile devices. | ||
|
|
||
| ## Motivation | ||
|
|
||
| The original system requires voters to copy-paste two separate fields from their email: | ||
| - `voter_login_id`: A voter identifier | ||
| - `voter_password`: A 10-character password | ||
|
|
||
| Token-based auth consolidates this into one field with improved security (20 chars = ~117 bits of entropy vs 10 chars = ~59 bits). | ||
|
|
||
| ## Architecture | ||
|
|
||
| ### Database Schema | ||
|
|
||
| **Election Model:** | ||
| - `use_token_auth` (Boolean, default=False): Controls authentication method per election | ||
|
|
||
| **Voter Model:** | ||
| - `voting_token` (String, nullable): 20-character authentication token | ||
| - Unique constraint: `(election, voting_token)` ensures per-election uniqueness | ||
| - Original fields (`voter_login_id`, `voter_password`) remain for backward compatibility | ||
|
|
||
| ### Token Specification | ||
|
|
||
| - **Length:** 20 characters | ||
| - **Character set:** `abcdefghjkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789` | ||
| - **Format:** Plain string (no dashes) for easy mobile copy-paste | ||
| - **Excludes:** Ambiguous characters (i, l, o, I, O, 0, 1) | ||
|
|
||
| ### Authentication Flow | ||
|
|
||
| The system branches based on `Election.use_token_auth`: | ||
|
|
||
| **Token-Based (use_token_auth=True):** | ||
| 1. Email contains single voting token | ||
| 2. Voter enters token in one field | ||
| 3. System validates against `Voter.voting_token` | ||
|
|
||
| **Password-Based (use_token_auth=False):** | ||
| 1. Email contains voter_login_id and voter_password | ||
| 2. Voter enters both credentials | ||
| 3. System validates against both fields | ||
|
|
||
| ### Implementation | ||
|
|
||
| **Models:** | ||
| - `Voter.generate_voting_token()`: Generates 20-character tokens | ||
| - `VoterFile.process()`: Conditional generation based on election setting | ||
|
|
||
| **Views:** | ||
| - `password_voter_login()`: Branches authentication logic based on `election.use_token_auth` | ||
|
|
||
| **Forms:** | ||
| - `VoterPasswordForm`: For password-based auth (voter_id + password fields) | ||
| - `VoterTokenForm`: For token-based auth (voting_token field) | ||
| - View selects and instantiates appropriate form based on election setting | ||
|
|
||
| **Templates:** | ||
| - Login template renders the selected form (VoterPasswordForm or VoterTokenForm) | ||
| - Email templates show appropriate credentials based on election type | ||
|
|
||
| ## Migration & Compatibility | ||
|
|
||
| **Default Behavior:** | ||
| - Existing elections: Continue using password-based auth (`use_token_auth=False`) | ||
| - New elections: Can opt into token-based auth via admin interface | ||
| - No automatic migration occurs | ||
|
|
||
| **Migration Path:** | ||
| Election administrators can either: | ||
| 1. Keep existing elections on password-based auth indefinitely | ||
| 2. Enable `use_token_auth=True` and re-send credentials with new tokens | ||
|
|
||
| **Deployment:** | ||
| - Single database migration adds two nullable fields | ||
| - Zero downtime, no data migration required | ||
| - All existing voter credentials remain valid | ||
|
|
||
| ## Security | ||
|
|
||
| **Improvements:** | ||
| - 2× more entropy (20 chars vs 10) | ||
| - Database-enforced uniqueness per election | ||
|
|
||
| **Unchanged Security Model:** | ||
| - Plaintext storage in database | ||
| - Unencrypted email delivery | ||
| - No rate limiting or token expiration | ||
|
|
||
| Token-based auth maintains the existing security posture while improving entropy and user experience. | ||
|
|
||
| ## Testing | ||
|
|
||
| - `test_create_token_voter`: Validates 20-character token generation | ||
| - `test_token_uniqueness_per_election`: Ensures per-election uniqueness | ||
| - `test_token_authentication_functional`: End-to-end token authentication flow | ||
| - Existing password tests continue passing without modification | ||
|
|
||
| ## Usage | ||
|
|
||
| To enable for a new election: | ||
| 1. Create election in admin interface | ||
| 2. Set `use_token_auth = True` | ||
| 3. Upload voter list | ||
| 4. Voters receive email with single token and copy-paste instructions | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| # Generated by Django 5.2 on 2026-01-03 20:27 | ||
|
|
||
| from django.db import migrations, models | ||
|
|
||
|
|
||
| class Migration(migrations.Migration): | ||
|
|
||
| dependencies = [ | ||
| ("helios", "0008_add_election_soft_delete"), | ||
| ] | ||
|
|
||
| operations = [ | ||
| migrations.AddField( | ||
| model_name="election", | ||
| name="use_token_auth", | ||
| field=models.BooleanField(default=False), | ||
| ), | ||
| migrations.AddField( | ||
| model_name="voter", | ||
| name="voting_token", | ||
| field=models.CharField(max_length=100, null=True), | ||
| ), | ||
| migrations.AlterUniqueTogether( | ||
| name="voter", | ||
| unique_together={ | ||
| ("election", "voter_login_id"), | ||
| ("election", "voting_token"), | ||
| }, | ||
| ), | ||
| ] |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -96,6 +96,9 @@ class Election(HeliosModel): | |||||
| # randomize candidate order? | ||||||
| randomize_answer_order = models.BooleanField(default=False, null=False) | ||||||
|
|
||||||
| # use token-based authentication for voters? | ||||||
| use_token_auth = models.BooleanField(default=False, null=False) | ||||||
|
|
||||||
| # where votes should be cast | ||||||
| cast_url = models.CharField(max_length = 500) | ||||||
|
|
||||||
|
|
@@ -838,6 +841,32 @@ def itervoters(self): | |||||
| continue | ||||||
|
|
||||||
| voter_type = voter_fields[0].strip() | ||||||
|
|
||||||
| # Special handling for "token" voter type | ||||||
| if voter_type == "token": | ||||||
| # Format: token,<email>,<full name> | ||||||
| if len(voter_fields) < 3: | ||||||
| raise Exception("token voter format requires: token,<email>,<full name>") | ||||||
|
|
||||||
| voter_email = voter_fields[1].strip() | ||||||
| voter_name = voter_fields[2].strip() | ||||||
|
|
||||||
| # Validate email | ||||||
| if not validate_email(voter_email): | ||||||
| raise Exception("invalid email '%s' for token voter" % voter_email) | ||||||
|
|
||||||
| # Use email as voter_id for token voters | ||||||
| voter_id = voter_email | ||||||
|
|
||||||
| yield { | ||||||
| 'voter_type': voter_type, | ||||||
| 'voter_id': voter_id, | ||||||
| 'email': voter_email, | ||||||
| 'name': voter_name, | ||||||
| } | ||||||
| continue | ||||||
|
|
||||||
| # Standard handling for other voter types | ||||||
| voter_id = voter_fields[1].strip() | ||||||
|
|
||||||
| if not voter_type in AUTH_SYSTEMS: | ||||||
|
|
@@ -872,6 +901,31 @@ def process(self): | |||||
| self.num_voters = len(voters) | ||||||
| random.shuffle(voters) | ||||||
|
|
||||||
| # Check for mixing of token and password voters | ||||||
| has_token_voters = any(v['voter_type'] == 'token' for v in voters) | ||||||
| has_password_voters = any(v['voter_type'] == 'password' for v in voters) | ||||||
|
|
||||||
| # Cannot mix token and password voters in the same upload | ||||||
| if has_token_voters and has_password_voters: | ||||||
| raise Exception("Cannot mix 'token' and 'password' voter types in the same upload. Please use only one authentication method per election.") | ||||||
|
|
||||||
| # Check existing voters in the election | ||||||
| existing_password_voters = self.election.voter_set.filter(voter_password__isnull=False).exists() | ||||||
| existing_token_voters = self.election.voter_set.filter(voting_token__isnull=False).exists() | ||||||
|
|
||||||
| # Cannot upload token voters if election already has password voters | ||||||
| if has_token_voters and existing_password_voters: | ||||||
| raise Exception("Cannot upload token voters to an election that already has password voters. Election must use a single authentication method.") | ||||||
|
|
||||||
| # Cannot upload password voters if election already has token voters | ||||||
| if has_password_voters and existing_token_voters: | ||||||
| raise Exception("Cannot upload password voters to an election that already has token voters. Election must use a single authentication method.") | ||||||
|
|
||||||
| # Auto-enable token auth if all voters are token type | ||||||
| if has_token_voters and not self.election.use_token_auth: | ||||||
| self.election.use_token_auth = True | ||||||
| self.election.save() | ||||||
|
|
||||||
| opted_out_voters = [] | ||||||
| successful_voters = 0 | ||||||
|
|
||||||
|
|
@@ -887,7 +941,29 @@ def process(self): | |||||
| }) | ||||||
| continue | ||||||
|
|
||||||
| if voter['voter_type'] == 'password': | ||||||
| if voter['voter_type'] == 'token': | ||||||
| # Token voter type - always generates voting tokens | ||||||
| # does voter for this user already exist | ||||||
| existing_voter = Voter.get_by_election_and_voter_id(self.election, voter['voter_id']) | ||||||
| if existing_voter: | ||||||
| continue | ||||||
| # create the voter | ||||||
| voter_uuid = str(uuid.uuid4()) | ||||||
| new_voter = Voter(uuid=voter_uuid, user = None, voter_login_id = voter['voter_id'], | ||||||
| voter_name = voter['name'], voter_email = voter['email'], election = self.election) | ||||||
| new_voter.generate_voting_token() | ||||||
| election=self.election | ||||||
| if election.use_voter_aliases: | ||||||
| # Use transaction to ensure alias assignment is atomic | ||||||
| with transaction.atomic(): | ||||||
| utils.lock_row(Election, election.id) | ||||||
| alias_num = election.last_alias_num + 1 | ||||||
| new_voter.alias = "V%s" % alias_num | ||||||
| new_voter.save() | ||||||
| else: | ||||||
| new_voter.save() | ||||||
| successful_voters += 1 | ||||||
| elif voter['voter_type'] == 'password': | ||||||
| # does voter for this user already exist | ||||||
| existing_voter = Voter.get_by_election_and_voter_id(self.election, voter['voter_id']) | ||||||
| if existing_voter: | ||||||
|
|
@@ -896,7 +972,11 @@ def process(self): | |||||
| voter_uuid = str(uuid.uuid4()) | ||||||
| new_voter = Voter(uuid=voter_uuid, user = None, voter_login_id = voter['voter_id'], | ||||||
| voter_name = voter['name'], voter_email = voter['email'], election = self.election) | ||||||
| new_voter.generate_password() | ||||||
| # generate token or password based on election setting | ||||||
| if self.election.use_token_auth: | ||||||
| new_voter.generate_voting_token() | ||||||
| else: | ||||||
| new_voter.generate_password() | ||||||
|
benadida marked this conversation as resolved.
|
||||||
| election=self.election | ||||||
| if election.use_voter_aliases: | ||||||
| # Use transaction to ensure alias assignment is atomic | ||||||
|
|
@@ -943,6 +1023,10 @@ class Voter(HeliosModel): | |||||
| # if user is null, then you need a voter login ID and password | ||||||
| voter_login_id = models.CharField(max_length = 100, null=True) | ||||||
| voter_password = models.CharField(max_length = 100, null=True) | ||||||
|
|
||||||
| # token-based authentication (alternative to voter_login_id + voter_password) | ||||||
| voting_token = models.CharField(max_length = 100, null=True) | ||||||
|
||||||
| voting_token = models.CharField(max_length = 100, null=True) | |
| voting_token = models.CharField(max_length = 100, null=True, db_index=True) |
Uh oh!
There was an error while loading. Please reload this page.