Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
0858ceb
Add comprehensive plan for token-based voter login
claude Jan 4, 2026
a866257
Simplify token login plan - focus on essential changes only
claude Jan 4, 2026
defee02
Update plan: support both auth methods with election-level setting
claude Jan 4, 2026
e5979fa
Add database fields for token-based authentication
claude Jan 4, 2026
141cdc5
Add generate_voting_token() method to Voter model
claude Jan 4, 2026
7ef4609
Update VoterFile.process() to generate tokens conditionally
claude Jan 4, 2026
9181c67
Update login form and view to support both auth methods
claude Jan 4, 2026
41973ed
Update login template to support both auth methods
claude Jan 4, 2026
f2dedac
Update email templates for token-based authentication
claude Jan 4, 2026
4de4cf7
Update password resend template for token-based elections
claude Jan 4, 2026
b774a94
Add tests for token-based authentication
claude Jan 4, 2026
cdeaaf6
Change use_token_auth default to False for backward compatibility
claude Jan 4, 2026
fe0ebfe
Remove redundant AlterUniqueTogether operation from migration
claude Jan 4, 2026
d4d4c0b
Add high-level architecture documentation for token-based voter authe…
claude Jan 4, 2026
3a60dc1
Remove old plan file
claude Jan 4, 2026
2773223
Tighten documentation by removing redundancies
claude Jan 4, 2026
8752273
Refactor authentication to use separate Django forms
claude Jan 4, 2026
b0388b1
Update documentation to reflect separate Django forms approach
claude Jan 5, 2026
199cd2b
Add 'token' voter type for simplified CSV uploads
claude Jan 18, 2026
065709f
Renumber migration to 0009 after rebase on master
claude Jan 19, 2026
6a97d31
Remove old 0008 migration file after renumbering to 0009
claude Jan 19, 2026
83354ad
Fix template and view for token/password form handling
claude Feb 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions docs/2026-01-04-voter-tokens.md
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.

Comment thread
benadida marked this conversation as resolved.
## 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
3 changes: 3 additions & 0 deletions helios/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ class VoterPasswordForm(forms.Form):
voter_id = forms.CharField(max_length=50, label="Voter ID")
password = forms.CharField(widget=forms.PasswordInput(), max_length=100)

class VoterTokenForm(forms.Form):
voting_token = forms.CharField(max_length=100, label="Voting Token")

Comment thread
benadida marked this conversation as resolved.
class VoterPasswordResendForm(forms.Form):
voter_id = forms.CharField(max_length=50, label="Voter ID", help_text="Enter the voter ID you were assigned for this election")

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"),
},
),
]
101 changes: 98 additions & 3 deletions helios/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand All @@ -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:
Expand All @@ -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()
Comment thread
benadida marked this conversation as resolved.
election=self.election
if election.use_voter_aliases:
# Use transaction to ensure alias assignment is atomic
Expand Down Expand Up @@ -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)
Copy link

Copilot AI Jan 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The voting_token field should have a database index for performance. The authentication query filters by voting_token on every login attempt, which will perform a full table scan without an index. As the number of voters grows, this could significantly slow down authentication. Consider adding db_index=True to the voting_token field definition or creating an index in the migration.

Suggested change
voting_token = models.CharField(max_length = 100, null=True)
voting_token = models.CharField(max_length = 100, null=True, db_index=True)

Copilot uses AI. Check for mistakes.

voter_name = models.CharField(max_length = 200, null=True)
voter_email = models.CharField(max_length = 250, null=True)

Expand All @@ -955,7 +1039,7 @@ class Voter(HeliosModel):
cast_at = models.DateTimeField(auto_now_add=False, null=True)

class Meta:
unique_together = (('election', 'voter_login_id'))
unique_together = (('election', 'voter_login_id'), ('election', 'voting_token'))
Comment thread
benadida marked this conversation as resolved.
app_label = 'helios'

def __init__(self, *args, **kwargs):
Expand Down Expand Up @@ -1117,6 +1201,17 @@ def generate_password(self, length=10):

self.voter_password = utils.random_string(length, alphabet='abcdefghjkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789')

def generate_voting_token(self, length=20):
"""
Generate a voting token for single-field authentication.
Token is 20 characters (vs 10 for password) for higher entropy.
Uses same alphabet as password (no ambiguous characters).
"""
if self.voting_token:
raise Exception("voting token already exists")

self.voting_token = utils.random_string(length, alphabet='abcdefghjkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789')

def store_vote(self, cast_vote):
# only store the vote if it's cast later than the current one
if self.cast_at and cast_vote.cast_at < self.cast_at:
Expand Down
25 changes: 24 additions & 1 deletion helios/templates/_castconfirm_password.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,40 @@
<input type="hidden" name="cast_ballot" value="{{cast_ballot}}" />

<div style="border: 1px solid #888; padding: 10px; max-width: 500px;">
{{password_login_form.as_p}}
{% if election.use_token_auth %}
<p>
{{ login_form.voting_token.label_tag }}
{{ login_form.voting_token }}
</p>
{% else %}
<p>
{{ login_form.voter_id.label_tag }}
{{ login_form.voter_id }}
</p>
<p>
{{ login_form.password.label_tag }}
{{ login_form.password }}
</p>
{% endif %}
{% if bad_voter_login %}
<p style="color: red;">
{% if election.use_token_auth %}
Invalid voting token, please try again.
{% else %}
bad voter ID or password, please try again.
{% endif %}
</p>
{% endif %}
{% if cast_ballot == "1" %}
<input type="submit" class="button" value="authenticate &amp; cast ballot" />
<p class="small">
{% if election.use_token_auth %}
Your voting token can be found in the email you received.
<a href="{% url "election@password-voter-resend" election.uuid %}" target="_blank">Didn't receive your token? Click here to resend it.</a>
{% else %}
Your voter ID and password can be found in the email you received.
<a href="{% url "election@password-voter-resend" election.uuid %}" target="_blank">Forgot your password? Click here to resend it.</a>
{% endif %}
{% if election.help_email %}If you still cannot find your login information, contact your election administrator at <tt>{{election.help_email}}</tt>.{% endif %}
</p>
<p class="small">
Expand Down
7 changes: 7 additions & 0 deletions helios/templates/email/password_resend_body.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,17 @@ Dear {{voter.name}},

You requested your voting credentials for the election "{{election.name}}".

{% if voter.election.use_token_auth %}
Your voting token:
{{voter.voting_token}}

Copy and paste this token when you visit the election URL:
{% else %}
Your voter ID: {{voter.voter_login_id}}
Your password: {{voter.voter_password}}

Use this election URL to prepare a new ballot if needed:
Comment thread
benadida marked this conversation as resolved.
{% endif %}
{{election_vote_url}}

--
Expand Down
7 changes: 7 additions & 0 deletions helios/templates/email/vote_body.txt
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,15 @@ Election URL (click to begin voting):
{{election_vote_url}}

{% if voter.voter_type == "password" %}
{% if voter.election.use_token_auth %}
Your voting token:
{{voter.voting_token}}

Copy and paste this token when prompted to log in.
{% else %}
Your voter ID: {{voter.voter_login_id}}
Your password: {{voter.voter_password}}
{% endif %}
{% else %}
Comment thread
benadida marked this conversation as resolved.
Log in with your {{voter.voter_type}} account.
{% endif %}{% if voter.vote_hash %}
Expand Down
Loading
Loading