Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
36 changes: 32 additions & 4 deletions lib/providers/auth_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ class AuthProvider with StreamSubscriber {
}));
}

Future<void> login(
/// Returns a [TwoFactorChallenge] when the server requires a second factor,
/// or `null` when the credentials alone completed the login.
Future<TwoFactorChallenge?> login(
{required String host,
required String email,
required String password}) async {
Expand All @@ -37,8 +39,25 @@ class AuthProvider with StreamSubscriber {
};

final response = await post('me', data: loginData);
preferences.apiToken = response['token'];
preferences.audioToken = response['audio-token'];

if (response['two_factor'] == true) {
return TwoFactorChallenge(loginToken: response['login_token']);
}

_storeCompositeToken(response);
return null;
}

Future<void> completeTwoFactorChallenge({
required String loginToken,
required String code,
}) async {
final response = await post('me/two-factor-challenge', data: {
'login_token': loginToken,
'code': code,
});

_storeCompositeToken(response);
}

Future<void> loginWithOneTimeToken({
Expand All @@ -51,7 +70,10 @@ class AuthProvider with StreamSubscriber {
'token': token,
};

final response = await post('me/otp', data: loginData);
_storeCompositeToken(await post('me/otp', data: loginData));
}

void _storeCompositeToken(dynamic response) {
preferences.apiToken = response['token'];
preferences.audioToken = response['audio-token'];
}
Expand Down Expand Up @@ -79,3 +101,9 @@ class AuthProvider with StreamSubscriber {
_userLoggedOut.add(null);
}
}

class TwoFactorChallenge {
final String loginToken;

const TwoFactorChallenge({required this.loginToken});
}
32 changes: 27 additions & 5 deletions lib/ui/screens/login.dart
Original file line number Diff line number Diff line change
Expand Up @@ -95,29 +95,49 @@ class _LoginScreenState extends State<LoginScreen> with StreamSubscriber {

try {
_host = standardizeHost(_host);
await _auth.login(host: _host, email: _email, password: _password);
final challenge =
await _auth.login(host: _host, email: _email, password: _password);
if (!mounted) return;

if (challenge != null) {
_gotoTwoFactorChallenge(challenge);
return;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

await _auth.tryGetAuthUser();
successful = true;
} on HttpResponseException catch (error) {
if (!mounted) return;
await showErrorDialog(
context,
message: error.response.statusCode == 401
? 'Invalid email or password.'
: null,
);
} catch (error) {
if (!mounted) return;
await showErrorDialog(context);
} finally {
setState(() => _authenticating = false);
if (mounted) setState(() => _authenticating = false);
}

if (successful) {
if (successful && mounted) {
preferences.host = _host;
preferences.userEmail = _email;
redirectToDataLoadingScreen();
}
}

void _gotoTwoFactorChallenge(TwoFactorChallenge challenge) {
Navigator.of(context).push(CupertinoPageRoute(
builder: (_) => TwoFactorChallengeScreen(
host: _host,
email: _email,
loginToken: challenge.loginToken,
),
));
}

Future<void> attemptLoginWithOtp({
required String host,
required String token,
Expand All @@ -131,18 +151,20 @@ class _LoginScreenState extends State<LoginScreen> with StreamSubscriber {
await _auth.tryGetAuthUser();
successful = true;
} on HttpResponseException catch (error) {
if (!mounted) return;
await showErrorDialog(
context,
message:
error.response.statusCode == 401 ? 'Invalid login token.' : null,
);
} catch (error) {
if (!mounted) return;
await showErrorDialog(context);
} finally {
setState(() => _authenticating = false);
if (mounted) setState(() => _authenticating = false);
}

if (successful) {
if (successful && mounted) {
preferences.host = host;
redirectToDataLoadingScreen();
}
Expand Down
1 change: 1 addition & 0 deletions lib/ui/screens/screens.dart
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export 'info_sheet/info_sheet.dart';
export 'initial.dart';
export 'library.dart';
export 'login.dart';
export 'two_factor_challenge.dart';
export 'main.dart';
export 'no_connection.dart';
export 'now_playing.dart';
Expand Down
252 changes: 252 additions & 0 deletions lib/ui/screens/two_factor_challenge.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
import 'package:app/constants/constants.dart';
import 'package:app/exceptions/exceptions.dart';
import 'package:app/providers/providers.dart';
import 'package:app/ui/screens/screens.dart';
import 'package:app/ui/widgets/widgets.dart';
import 'package:app/utils/preferences.dart' as preferences;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_spinkit/flutter_spinkit.dart';
import 'package:provider/provider.dart';

enum _CodeMode { totp, recovery }

class TwoFactorChallengeScreen extends StatefulWidget {
static const routeName = '/two-factor-challenge';

final String host;
final String email;
final String loginToken;

const TwoFactorChallengeScreen({
Key? key,
required this.host,
required this.email,
required this.loginToken,
}) : super(key: key);

@override
_TwoFactorChallengeScreenState createState() =>
_TwoFactorChallengeScreenState();
}

class _TwoFactorChallengeScreenState extends State<TwoFactorChallengeScreen> {
late final AuthProvider _auth;
var _mode = _CodeMode.totp;
var _code = '';
var _verifying = false;
var _totpResetToken = 0;

@override
void initState() {
super.initState();
_auth = context.read();
}

void _switchMode(_CodeMode mode) {
setState(() {
_mode = mode;
_code = '';
});
}

Future<void> showErrorDialog(BuildContext context, {String? message}) async {
await showDialog(
context: context,
builder: (BuildContext context) => AlertDialog(
title: const Text('Error'),
content: Text(
message ?? 'There was a problem verifying the code. Please try again.',
),
actions: <Widget>[
TextButton(
child: const Text('OK'),
onPressed: () => Navigator.pop(context),
),
],
),
);
}

Future<void> verify() async {
final code = _code.trim();
if (code.isEmpty || _verifying) return;

var successful = false;
setState(() => _verifying = true);

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 {
if (mounted) setState(() => _verifying = false);
}

if (successful && mounted) {
preferences.host = widget.host;
preferences.userEmail = widget.email;
Navigator.of(context, rootNavigator: true)
.pushReplacementNamed(DataLoadingScreen.routeName);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

void _resetTotpInput() {
setState(() {
_code = '';
_totpResetToken++;
});
}
Comment thread
phanan marked this conversation as resolved.

@override
Widget build(BuildContext context) {
return Scaffold(
body: GradientDecoratedContainer(
child: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(
horizontal: AppDimensions.hPadding,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
...[
Text(
'Two-Factor Authentication',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.headlineSmall,
),
if (_mode == _CodeMode.totp)
..._buildTotpInput()
else
..._buildRecoveryInput(),
SizedBox(
width: double.infinity,
child: ElevatedButton(
child: _verifying
? const SpinKitThreeBounce(
color: Colors.white,
size: 16,
)
: const Text('Verify'),
onPressed: _verifying ? null : verify,
),
),
_FooterButton(
label: _mode == _CodeMode.totp
? 'Use a recovery code'
: 'Use authenticator code instead',
onPressed: _verifying
? null
: () => _switchMode(
_mode == _CodeMode.totp
? _CodeMode.recovery
: _CodeMode.totp,
),
),
_FooterButton(
label: 'Back to login',
onPressed: _verifying
? null
: () => Navigator.of(context).pop(),
),
].expand((child) => [child, const SizedBox(height: 16)]),
],
),
),
),
),
),
);
}

List<Widget> _buildTotpInput() {
return [
const Text(
'Enter the code from your authenticator app.',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.white70),
),
OneTimeCodeInput(
key: ValueKey(_totpResetToken),
onChanged: (value) => _code = value,
onCompleted: (value) {
_code = value;
verify();
},
),
];
}

List<Widget> _buildRecoveryInput() {
return [
const Text(
'Enter one of your recovery codes.',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.white70),
),
TextField(
autofocus: true,
autocorrect: false,
enableSuggestions: false,
textAlign: TextAlign.center,
textInputAction: TextInputAction.go,
style: const TextStyle(fontFamily: 'monospace', letterSpacing: 2),
inputFormatters: [_UpperCaseFormatter()],
onChanged: (value) => _code = value,
onSubmitted: (_) => verify(),
decoration: const InputDecoration(
hintText: 'Recovery code',
),
),
];
}
}

class _FooterButton extends StatelessWidget {
final String label;
final VoidCallback? onPressed;

const _FooterButton({Key? key, required this.label, this.onPressed})
: super(key: key);

@override
Widget build(BuildContext context) {
return SizedBox(
width: double.infinity,
child: TextButton(
onPressed: onPressed,
style: TextButton.styleFrom(foregroundColor: Colors.white),
child: Text(label, textAlign: TextAlign.center),
),
);
}
}

class _UpperCaseFormatter extends TextInputFormatter {
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue,
TextEditingValue newValue,
) {
return newValue.copyWith(text: newValue.text.toUpperCase());
}
}
Loading
Loading