Skip to content

Add comprehensive plan for token-based voter login#452

Open
benadida wants to merge 22 commits intomasterfrom
claude/token-login-voting-pf4bT
Open

Add comprehensive plan for token-based voter login#452
benadida wants to merge 22 commits intomasterfrom
claude/token-login-voting-pf4bT

Conversation

@benadida
Copy link
Owner

@benadida benadida commented Jan 4, 2026

There's no great reason to require username and password for voters. Just a voting token is enough. This makes voting easier, only one field to copy / paste.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR adds comprehensive support for token-based voter authentication as an alternative to the traditional voter ID + password system. The implementation allows election administrators to choose between the two authentication methods via a new use_token_auth flag, with backward compatibility for existing elections.

Key changes:

  • Adds voting_token field to Voter model and use_token_auth flag to Election model
  • Implements conditional authentication logic in login views to support both token-based and password-based authentication
  • Updates email templates and UI to display appropriate authentication fields based on election configuration
  • Includes new tests for token generation and uniqueness validation

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 11 comments.

Show a summary per file
File Description
helios/models.py Adds use_token_auth boolean field to Election model, voting_token field to Voter model with unique constraint, and generate_voting_token() method. Updates VoterFile.process to conditionally generate tokens or passwords.
helios/migrations/0008_alter_voter_unique_together_election_use_token_auth_and_more.py Database migration adding the new fields and updating unique_together constraint to include voting_token.
helios/views.py Updates password_voter_login() to handle both token-based and password-based authentication with conditional logic based on election.use_token_auth.
helios/forms.py Modifies VoterPasswordForm to include optional voting_token field alongside existing voter_id and password fields, all marked as not required.
helios/templates/_castconfirm_password.html Adds conditional rendering logic to show either token input field or voter ID/password fields based on election authentication method.
helios/templates/email/vote_body.txt Updates voter notification email to conditionally display voting token or voter ID/password based on election settings.
helios/templates/email/password_resend_body.txt Updates credential resend email to show appropriate authentication information based on election configuration.
helios/templates/password_voter_resend.html Updates resend credentials page with conditional text for token vs password authentication.
helios/tests.py Adds three new test cases for token voter creation, token generation constraints, and token uniqueness per election.
TOKEN_LOGIN_PLAN.md Comprehensive planning document detailing the implementation strategy, changes, migration safety, and implementation order for the token-based authentication feature.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +709 to +721
# Token-based authentication
if election.use_token_auth:
voting_token = password_login_form.cleaned_data.get('voting_token', '').strip()
if not voting_token:
raise Voter.DoesNotExist("No token provided")
voter = election.voter_set.get(voting_token=voting_token)
# Password-based authentication (legacy)
else:
voter_id = password_login_form.cleaned_data.get('voter_id', '').strip()
password = password_login_form.cleaned_data.get('password', '').strip()
if not voter_id or not password:
raise Voter.DoesNotExist("No voter_id or password provided")
voter = election.voter_set.get(voter_login_id=voter_id, voter_password=password)
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 token-based authentication flow lacks test coverage for the login view integration. While tests exist for token generation and uniqueness, there are no tests verifying the actual login flow using a token through the password_voter_login view. Consider adding tests that verify successful token-based login, failed token-based login with invalid token, and that the correct authentication path is taken based on election.use_token_auth.

Copilot uses AI. Check for mistakes.
helios/views.py Outdated
Comment on lines +713 to +720
raise Voter.DoesNotExist("No token provided")
voter = election.voter_set.get(voting_token=voting_token)
# Password-based authentication (legacy)
else:
voter_id = password_login_form.cleaned_data.get('voter_id', '').strip()
password = password_login_form.cleaned_data.get('password', '').strip()
if not voter_id or not password:
raise Voter.DoesNotExist("No voter_id or password provided")
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 error messages passed to Voter.DoesNotExist are not user-facing and won't be displayed to voters. However, these messages ("No token provided", "No voter_id or password provided") could be logged or exposed in debug mode. The current implementation raises DoesNotExist with a custom message, but Django's DoesNotExist typically doesn't accept arguments. This could cause issues or confusing error messages. Consider logging these conditions separately and raising DoesNotExist without arguments.

Suggested change
raise Voter.DoesNotExist("No token provided")
voter = election.voter_set.get(voting_token=voting_token)
# Password-based authentication (legacy)
else:
voter_id = password_login_form.cleaned_data.get('voter_id', '').strip()
password = password_login_form.cleaned_data.get('password', '').strip()
if not voter_id or not password:
raise Voter.DoesNotExist("No voter_id or password provided")
logging.warning(
"Password voter login failed for election %s: no voting token provided.",
election.uuid
)
raise Voter.DoesNotExist()
voter = election.voter_set.get(voting_token=voting_token)
# Password-based authentication (legacy)
else:
voter_id = password_login_form.cleaned_data.get('voter_id', '').strip()
password = password_login_form.cleaned_data.get('password', '').strip()
if not voter_id or not password:
logging.warning(
"Password voter login failed for election %s: missing voter_id or password.",
election.uuid
)
raise Voter.DoesNotExist()

Copilot uses AI. Check for mistakes.
{% else %}
<input type="submit" class="button" value="log in" />
{# Password-based authentication (legacy) #}
{{password_login_form.as_p}}
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 template uses password_login_form.as_p for legacy authentication, which will render all form fields including the voting_token field (since it's part of the form). This could expose the token field to password-based elections. While it won't be visible if properly styled, it's better practice to explicitly render only the fields needed for password authentication (voter_id and password) rather than using as_p.

Suggested change
{{password_login_form.as_p}}
<p>
<label for="{{ password_login_form.voter_id.id_for_label }}">
{{ password_login_form.voter_id.label }}:
</label><br />
{{ password_login_form.voter_id }}
</p>
<p>
<label for="{{ password_login_form.password.id_for_label }}">
{{ password_login_form.password.label }}:
</label><br />
{{ password_login_form.password }}
</p>

Copilot uses AI. Check for mistakes.
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.

**Election Model** (`helios/models.py`, Election class, line ~300):
```python
use_token_auth = models.BooleanField(default=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 implementation of use_token_auth uses default=False in the migration and models.py, but the plan document at line 19 specifies default=True. This inconsistency could cause confusion. The actual implementation (default=False) is safer for backward compatibility with existing elections, but this discrepancy between the plan and implementation should be reconciled. Consider updating the plan document to match the implementation or adding a comment explaining why the default differs from the plan.

Copilot uses AI. Check for mistakes.
Comment on lines +44 to +52
def generate_voting_token(self, length=20):
"""Generate a 20-character voting token"""
if self.voting_token:
raise Exception("voting token already exists")

self.voting_token = utils.random_string(
length,
alphabet='abcdefghjkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789'
)
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 plan proposes storing voting_token as a raw, randomly generated string in the database and using it directly for authentication lookups. If the database (or a backup/log) is compromised, an attacker can immediately use these plaintext tokens to impersonate voters and cast ballots without any additional cracking effort. Treat voting_token as a password-equivalent by storing only a strong, salted hash in the model and performing authentication/uniqueness checks on the hashed value rather than the raw token.

Copilot uses AI. Check for mistakes.
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated 5 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

- `password_voter_login()`: Branches authentication logic based on `election.use_token_auth`

**Forms:**
- `VoterPasswordForm`: Contains fields for both methods (all optional)
Copy link

Copilot AI Jan 5, 2026

Choose a reason for hiding this comment

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

The documentation incorrectly states that "VoterPasswordForm: Contains fields for both methods (all optional)". This is not accurate based on the implementation. The code uses two separate forms: VoterPasswordForm for password-based auth and VoterTokenForm for token-based auth. The form selection is conditional based on election.use_token_auth, not a single form containing all optional fields.

Suggested change
- `VoterPasswordForm`: Contains fields for both methods (all optional)
- `VoterPasswordForm`: Handles traditional voter_login_id + voter_password authentication
- `VoterTokenForm`: Handles single-token authentication when `election.use_token_auth` is True

Copilot uses AI. Check for mistakes.
claude added 20 commits January 19, 2026 01:56
- Add use_token_auth boolean to Election model
- Keep both voter_login_id/voter_password and voting_token fields
- New elections default to token auth, existing elections unchanged
- Views/forms/templates adapt based on election setting
- Safe migration, no breaking changes
- Add use_token_auth boolean field to Election model (default=True)
- Add voting_token field to Voter model for single-token auth
- Update unique_together constraint to include (election, voting_token)
- Keep existing voter_login_id and voter_password fields for backwards compatibility
- Creates 20-character tokens (vs 10 for passwords) for better security
- Uses same character set as passwords (no ambiguous characters)
- Raises exception if token already exists (same pattern as generate_password)
- Token provides ~117 bits of entropy for strong security
- Check election.use_token_auth to decide which credential type to generate
- New elections (use_token_auth=True) get voting tokens
- Legacy elections (use_token_auth=False) get voter_login_id + password
- Ensures backwards compatibility for in-progress elections
- Modify VoterPasswordForm to accept voting_token (new) and voter_id+password (legacy)
- Update password_voter_login() to check election.use_token_auth
- Token-based elections: authenticate using voting_token only
- Password-based elections: authenticate using voter_login_id + voter_password
- Maintains backwards compatibility for existing elections
- Show single voting_token field for token-based elections
- Show voter_id + password fields for password-based elections
- Update error messages and help text appropriately
- Conditional rendering based on election.use_token_auth
- Modify vote_body.txt to show voting_token for token-based elections
- Modify password_resend_body.txt to show voting_token for token-based elections
- Keep voter_login_id + voter_password for legacy password-based elections
- Clear instructions to copy and paste token
- Conditional messaging based on election.use_token_auth
- Token-based elections: mention 'voting token' instead of 'password'
- Password-based elections: keep original messaging
- Update page title to reflect credential type
- test_create_token_voter: verifies token generation works correctly
- test_token_uniqueness_per_election: verifies tokens are unique
- Tests verify 20-character token length (no dashes)
- Tests verify token cannot be generated twice for same voter
- Set default=False for existing elections and tests
- New elections can explicitly enable token auth
- Maintains backward compatibility with existing voter workflows
- Allows gradual migration to token-based authentication
- Remove first AlterUniqueTogether that was redundant
- Keep only the final operation that adds voting_token constraint
- Original (election, voter_login_id) constraint remains unchanged
…ntication

- Move plan to docs/ directory with dated filename
- Remove code snippets, focus on architecture and design decisions
- Document security analysis, migration strategy, and deployment considerations
- Remove old TOKEN_LOGIN_PLAN.md file
- Merge Migration Strategy and Backward Compatibility sections
- Consolidate security analysis into single concise section
- Remove repetitive statements about dual system support
- Streamline component descriptions
- Remove redundant Summary and Future Improvements sections
- Reduce from 161 lines to 109 lines while retaining all key information
Replace VoterPasswordForm (which had all fields optional) with two
dedicated forms:
- VoterPasswordForm: For password-based auth (voter_id + password)
- VoterTokenForm: For token-based auth (voting_token only)

The login view now selects the appropriate form based on
election.use_token_auth, simplifying validation logic and making
the template cleaner (just renders login_form.as_p).

Also add test_token_authentication_functional() to verify end-to-end
token authentication flow with multiple voters.
The implementation now uses VoterPasswordForm and VoterTokenForm
as separate form classes, rather than a single combined form.
Also add reference to the functional test.
Implement new CSV format for token-based elections:
  token,<email>,<full name>

Key features:
- Auto-enables use_token_auth when token voters are uploaded
- Prevents mixing token and password voters (in same CSV or election)
- Simplified format with only email and name (no unique_id needed)
- voter_login_id is set to email for token voters

Changes:
- VoterFile.itervoters(): Parse "token" rows with format validation
- VoterFile.process(): Add mixing checks and auto-enable logic
- voters_upload.html: Show both token and password formats
- Add comprehensive tests for upload scenarios and validation

Backward compatible: existing "password" CSV format still works.
After rebasing on master, which added migration 0008_add_election_soft_delete,
our token auth migration has been renumbered from 0008 to 0009 and updated
to depend on the soft delete migration.
@benadida benadida force-pushed the claude/token-login-voting-pf4bT branch from 58f8b7d to 065709f Compare January 19, 2026 01:59
Changes:
- Update _castconfirm_password.html to render explicit form fields
  instead of using as_p() for better webtest compatibility
- Update one_election_cast_confirm view to select appropriate form
  (VoterTokenForm or VoterPasswordForm) based on election.use_token_auth
- Pass login_form to template context for _castconfirm_password.html
- Make LDAP imports optional in settings.py (wrapped in try/except)
- Comment out python-ldap dependencies in pyproject.toml temporarily

Fixes ElectionBlackboxTests.test_do_complete_election test failure.
All 179 helios tests now passing.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants