Skip to content

Support two-factor authentication at login#201

Merged
phanan merged 4 commits into
masterfrom
feat/two-factor-authentication
Jul 8, 2026
Merged

Support two-factor authentication at login#201
phanan merged 4 commits into
masterfrom
feat/two-factor-authentication

Conversation

@phanan

@phanan phanan commented Jul 8, 2026

Copy link
Copy Markdown
Member

Integrates the two-factor authentication added in koel 9.9.0.

Server contract

  • POST me {email, password} now returns {two_factor: true, login_token} instead of the token pair when the user has 2FA enabled (401 still means bad credentials).
  • POST me/two-factor-challenge {login_token, code} returns the {token, audio-token} pair. code accepts either a TOTP code or a recovery code.

Changes

  • AuthProvider.login returns a TwoFactorChallenge (carrying the login_token) when the server asks for a second factor, or null when the credentials alone completed the login. Token storage is centralised in a _storeCompositeToken helper reused by the OTP path too.
  • AuthProvider.completeTwoFactorChallenge posts the login token + code and stores the resulting token pair.
  • The login screen pushes a new TwoFactorChallengeScreen when a challenge is returned.

Challenge UI (mirrors koel's web flow)

  • Defaults to six auto-advancing digit boxes (OneTimeCodeInput) for the authenticator code, auto-submitting once all six are filled; supports paste/autofill distribution and backspace-to-previous.
  • A toggle switches to a recovery-code text field (upper-cased) and back.
  • "Use a recovery code" and "Back to login" sit side by side as ghost buttons below Verify.

Tests

  • auth_provider_test.dart — normal login stores tokens and returns null; a two_factor response returns the challenge and stores no token; the challenge posts the right body and stores tokens; a 401 propagates.
  • two_factor_challenge_test.dart — six boxes by default; entering all six auto-submits and advances; toggling to recovery mode and back; a recovery code is upper-cased and submitted; an invalid code shows an error and stays put.

Summary by CodeRabbit

  • New Features
    • Added a two-factor login challenge flow that returns a server-issued challenge when verification is required.
    • Introduced a two-factor verification screen supporting authenticator TOTP and recovery codes, including in-flow navigation.
    • Added a reusable one-time code input component.
  • Bug Fixes
    • Token persistence now only occurs after successful login or completed two-factor verification.
    • Improved error handling for invalid codes, with safer in-screen state handling during failures.
  • Tests
    • Expanded provider and UI/widget tests to cover challenge handling, code formatting, mode switching, and 401 error behavior.

koel 9.9.0 lets a login require a second factor: POST me now answers
{two_factor: true, login_token} instead of the token pair when the user
has 2FA enabled. AuthProvider.login returns a TwoFactorChallenge in that
case (null otherwise), and completeTwoFactorChallenge posts the login
token plus the code to me/two-factor-challenge for the token pair.

The login screen pushes a TwoFactorChallengeScreen that accepts a TOTP
code or a recovery code and finishes the login on success.
@coderabbitai

coderabbitai Bot commented Jul 8, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3329aaea-a355-4843-85e7-c6ed50de59cb

📥 Commits

Reviewing files that changed from the base of the PR and between 3f1ead6 and 39fe9b2.

📒 Files selected for processing (2)
  • lib/ui/screens/login.dart
  • lib/ui/screens/two_factor_challenge.dart
🚧 Files skipped from review as they are similar to previous changes (2)
  • lib/ui/screens/login.dart
  • lib/ui/screens/two_factor_challenge.dart

📝 Walkthrough

Walkthrough

Adds two-factor authentication login support, a challenge screen with code entry and verification, segmented one-time-code input, and provider and widget tests covering the new flow.

Changes

Two-Factor Authentication Flow

Layer / File(s) Summary
AuthProvider login and token storage changes
lib/providers/auth_provider.dart
login now returns an optional TwoFactorChallenge, stores tokens through _storeCompositeToken when 2FA is not required, reuses that helper in loginWithOneTimeToken, and adds TwoFactorChallenge.
Login screen navigation to challenge
lib/ui/screens/login.dart, lib/ui/screens/screens.dart
attemptLogin now branches on the returned challenge and routes to TwoFactorChallengeScreen; the screen barrel export is added.
TwoFactorChallengeScreen implementation
lib/ui/screens/two_factor_challenge.dart
Adds the 2FA challenge screen, mode switching, verification, error handling, success navigation, and the supporting footer button and uppercase formatter widgets.
One-time code input widget
lib/ui/widgets/one_time_code_input.dart, lib/ui/widgets/widgets.dart
Adds the segmented one-time-code input widget and exports it from the widget barrel.
AuthProvider tests for login and challenge completion
test/providers/auth_provider_test.dart
Covers normal login, 2FA-required login, successful challenge completion, and 401 error propagation.
TwoFactorChallengeScreen widget tests and mocks
test/ui/screens/two_factor_challenge_test.dart, test/ui/screens/two_factor_challenge_test.mocks.dart, test/ui/widgets/oops_box_test.mocks.dart
Adds widget coverage for the new screen and updates generated mocks for the changed auth provider API.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant LoginScreen
  participant AuthProvider
  participant Server
  participant TwoFactorChallengeScreen
  participant DataLoadingScreen

  User->>LoginScreen: submit host/email/password
  LoginScreen->>AuthProvider: login(host, email, password)
  AuthProvider->>Server: POST me
  Server-->>AuthProvider: response(two_factor, login_token)

  alt two_factor required
    AuthProvider-->>LoginScreen: TwoFactorChallenge(loginToken)
    LoginScreen->>TwoFactorChallengeScreen: navigate(host, email, loginToken)
    User->>TwoFactorChallengeScreen: enter code, submit
    TwoFactorChallengeScreen->>AuthProvider: completeTwoFactorChallenge(loginToken, code)
    AuthProvider->>Server: POST me/two-factor-challenge
    Server-->>AuthProvider: success or 401
    alt success
      TwoFactorChallengeScreen->>AuthProvider: tryGetAuthUser()
      TwoFactorChallengeScreen->>DataLoadingScreen: replace route
    else invalid code
      TwoFactorChallengeScreen->>User: show error dialog
    end
  else no 2FA
    AuthProvider-->>LoginScreen: null
    LoginScreen->>LoginScreen: continue authenticated flow
  end
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: adding two-factor authentication support during login.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/two-factor-authentication

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (1)
lib/ui/screens/two_factor_challenge.dart (1)

149-149: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Rename widget lambda parameter to avoid shadowing State.widget.

The .expand((widget) => ...) parameter shadows the State.widget getter that provides access to TwoFactorChallengeScreen. If someone references widget.host inside this lambda they'd get a compile error instead of the screen's property. Rename to child to encode intent and avoid confusion.

As per coding guidelines: "rename variables to encode intent."

♻️ Proposed refactor
-                  ].expand((widget) => [widget, const SizedBox(height: 12)]),
+                  ].expand((child) => [child, const SizedBox(height: 12)]),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/ui/screens/two_factor_challenge.dart` at line 149, Rename the
`.expand((widget) => ...)` callback parameter in `TwoFactorChallengeScreen` to a
non-conflicting name like `child` to avoid shadowing `State.widget` and make the
intent clearer. Update the lambda used in the list of widgets so any future
access to the screen’s `widget` getter remains unambiguous, and keep the
surrounding widget-building logic unchanged.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@lib/ui/screens/login.dart`:
- Around line 98-104: The login flow in attemptLogin and attemptLoginWithOtp
resumes after async auth calls and then uses context to navigate or show
dialogs, so add a mounted guard immediately after each await before calling
_gotoTwoFactorChallenge, _gotoHomeScreen, or any dialog-related helper. Use the
State methods in login.dart to locate the affected async flows and ensure no UI
actions run if the widget has been disposed.

In `@lib/ui/screens/two_factor_challenge.dart`:
- Around line 76-94: The async flow in two_factor_challenge.dart needs mounted
checks after await gaps to avoid using a disposed State or stale context. In the
method that wraps completeTwoFactorChallenge and tryGetAuthUser, guard the
finally block before calling setState(() => _verifying = false), and also check
mounted again before the post-success navigation and preference updates. Use the
surrounding _verifying, showErrorDialog, and Navigator.of(context,
rootNavigator: true).pushReplacementNamed(DataLoadingScreen.routeName) logic as
the fix points.

---

Nitpick comments:
In `@lib/ui/screens/two_factor_challenge.dart`:
- Line 149: Rename the `.expand((widget) => ...)` callback parameter in
`TwoFactorChallengeScreen` to a non-conflicting name like `child` to avoid
shadowing `State.widget` and make the intent clearer. Update the lambda used in
the list of widgets so any future access to the screen’s `widget` getter remains
unambiguous, and keep the surrounding widget-building logic unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a85f7738-3967-40fd-b4cb-7ca0467ed280

📥 Commits

Reviewing files that changed from the base of the PR and between b0696d1 and 6eb4780.

📒 Files selected for processing (7)
  • lib/providers/auth_provider.dart
  • lib/ui/screens/login.dart
  • lib/ui/screens/screens.dart
  • lib/ui/screens/two_factor_challenge.dart
  • test/providers/auth_provider_test.dart
  • test/ui/screens/two_factor_challenge_test.dart
  • test/ui/screens/two_factor_challenge_test.mocks.dart

Comment thread lib/ui/screens/login.dart
Comment thread lib/ui/screens/two_factor_challenge.dart
phanan added 2 commits July 8, 2026 20:56
Mirror koel's web flow: default to six auto-advancing digit boxes for
the authenticator code (auto-submitting once full), with a toggle to a
recovery-code text field and back. "Use a recovery code" and "Back to
login" sit side by side as ghost buttons below Verify.
Revert the side-by-side row; "Use a recovery code" and "Back to login"
are borderless text buttons stacked below Verify.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (1)
lib/ui/screens/two_factor_challenge.dart (1)

78-105: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Add mounted checks after async gaps to prevent post-dispose crashes.

This was previously flagged but remains unaddressed. After the await calls on lines 79 and 83, the catch blocks call _resetTotpInput() (which triggers setState) and showErrorDialog (which uses context), the finally block calls setState, and the post-finally code uses context for Navigator — all without mounted checks.

The previous fix suggestion placed if (!mounted) return before showErrorDialog, but the current code also calls _resetTotpInput() (which calls setState) before showErrorDialog in both catch blocks. The mounted guard must precede _resetTotpInput() as well.

🔒 Proposed fix
     try {
       await _auth.completeTwoFactorChallenge(
         loginToken: widget.loginToken,
         code: code,
       );
       await _auth.tryGetAuthUser();
       successful = true;
     } on HttpResponseException catch (error) {
+      if (!mounted) return;
       _resetTotpInput();
       await showErrorDialog(
         context,
         message: error.response.statusCode == 401
             ? 'Invalid authentication code.'
             : null,
       );
     } catch (error) {
+      if (!mounted) return;
       _resetTotpInput();
       await showErrorDialog(context);
     } finally {
-      setState(() => _verifying = false);
+      if (mounted) setState(() => _verifying = false);
     }
 
-    if (successful) {
+    if (successful && mounted) {
       preferences.host = widget.host;
       preferences.userEmail = widget.email;
       Navigator.of(context, rootNavigator: true)
           .pushReplacementNamed(DataLoadingScreen.routeName);
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/ui/screens/two_factor_challenge.dart` around lines 78 - 105, The async
flow in the two-factor submit handler can still touch `setState` and `context`
after the widget is disposed. In the `TwoFactorChallengeScreen` callback, add
`mounted` guards immediately after each `await` boundary before calling
`_resetTotpInput()`, `showErrorDialog()`, the `finally` block’s `setState`, and
the post-success `Navigator.of(context, rootNavigator: true)` path. Use the
existing `_resetTotpInput`, `showErrorDialog`, and the submit method in
`two_factor_challenge.dart` to place the checks so no state or context access
happens when the widget is unmounted.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@lib/ui/screens/two_factor_challenge.dart`:
- Around line 108-113: _resetTotpInput() only clears the TOTP state, but
verify() calls it for both TOTP and recovery flows, so the recovery TextField
keeps stale text while _code is reset. Update the reset helper in
two_factor_challenge.dart to handle both modes by adding a TextEditingController
for the recovery input, clearing that controller alongside _code and
_totpResetToken, and renaming _resetTotpInput() to _resetInput() to reflect its
broader use. Also update the recovery TextField to use the new controller, and
change both verify() catch-block call sites to use the renamed reset method.

---

Duplicate comments:
In `@lib/ui/screens/two_factor_challenge.dart`:
- Around line 78-105: The async flow in the two-factor submit handler can still
touch `setState` and `context` after the widget is disposed. In the
`TwoFactorChallengeScreen` callback, add `mounted` guards immediately after each
`await` boundary before calling `_resetTotpInput()`, `showErrorDialog()`, the
`finally` block’s `setState`, and the post-success `Navigator.of(context,
rootNavigator: true)` path. Use the existing `_resetTotpInput`,
`showErrorDialog`, and the submit method in `two_factor_challenge.dart` to place
the checks so no state or context access happens when the widget is unmounted.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 98ee5126-2705-405d-966a-5c32bb289a5e

📥 Commits

Reviewing files that changed from the base of the PR and between 6eb4780 and 49051e2.

📒 Files selected for processing (5)
  • lib/ui/screens/two_factor_challenge.dart
  • lib/ui/widgets/one_time_code_input.dart
  • lib/ui/widgets/widgets.dart
  • test/ui/screens/two_factor_challenge_test.dart
  • test/ui/widgets/oops_box_test.mocks.dart
✅ Files skipped from review due to trivial changes (1)
  • lib/ui/widgets/widgets.dart

Comment thread lib/ui/screens/two_factor_challenge.dart
Address review: add mounted checks after the awaited auth calls in the
login and two-factor screens so a widget disposed mid-request doesn't
setState or use a stale context. Also rename an `.expand` lambda param
that shadowed State.widget.
@phanan phanan merged commit 4883cc4 into master Jul 8, 2026
2 checks passed
@phanan phanan deleted the feat/two-factor-authentication branch July 8, 2026 19:39
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.

1 participant