diff --git a/lib/api/client.dart b/lib/api/client.dart index 2d7b3d3..99b64cc 100644 --- a/lib/api/client.dart +++ b/lib/api/client.dart @@ -18,29 +18,34 @@ class Client { final JsonEncoder _encoder = new JsonEncoder(); String _token = ''; String _base = ''; + String _xClientToken = ''; bool authenticated = false; bool ignoreCertificates = false; bool showSnackBar = true; String get base => _base; String get token => _token; + String get xClientToken => _xClientToken; String? post_body; @override bool operator ==(Object otherClient) { if (otherClient is! Client) return false; - return otherClient._token == _token; + return otherClient._token == _token && + otherClient._xClientToken == _xClientToken; } Client( this.global_scaffold_key, { String? token, + String? xClientToken, String? base, bool authenticated = false, }) { configure( token: token, + xClientToken: xClientToken, base: base, authenticated: authenticated, ); @@ -76,6 +81,7 @@ class Client { 'Authorization': _token != '' ? 'Bearer $_token' : '', 'Content-Type': 'application/json', 'User-Agent': 'Vikunja Mobile App', + 'X-Client-Token': _xClientToken }; get headers => _headers; @@ -87,8 +93,10 @@ class Client { String? token, String? base, bool? authenticated, + String? xClientToken, }) { if (token != null) _token = token; + if (xClientToken != null) _xClientToken = xClientToken; if (base != null) { base = base.replaceAll(" ", ""); if (base.endsWith("/")) base = base.substring(0, base.length - 1); @@ -98,6 +106,7 @@ class Client { } void reset() { + _token = _base = _xClientToken = ''; authenticated = false; } diff --git a/lib/api/user_implementation.dart b/lib/api/user_implementation.dart index 94e637f..3a86944 100644 --- a/lib/api/user_implementation.dart +++ b/lib/api/user_implementation.dart @@ -9,8 +9,13 @@ class UserAPIService extends APIService implements UserService { UserAPIService(Client client) : super(client); @override - Future login(String username, password, - {bool rememberMe = false, String? totp}) async { + Future login( + String username, + password, { + bool rememberMe = false, + String? totp, + String? xClientToken, + }) async { var body = { 'long_token': rememberMe, 'password': password, @@ -26,7 +31,7 @@ class UserAPIService extends APIService implements UserService { error: response != null ? response.body["code"] : 0, errorString: response != null ? response.body["message"] : "Login error")); - client.configure(token: token); + client.configure(token: token, xClientToken: xClientToken); return UserAPIService(client) .getCurrentUser() .then((user) => UserTokenPair(user, token)); diff --git a/lib/constants.dart b/lib/constants.dart new file mode 100644 index 0000000..357a5f9 --- /dev/null +++ b/lib/constants.dart @@ -0,0 +1 @@ +const ErrorCodeOtpRequired = 1017; diff --git a/lib/global.dart b/lib/global.dart index 4bcc7eb..12311ac 100644 --- a/lib/global.dart +++ b/lib/global.dart @@ -102,7 +102,8 @@ class VikunjaGlobalState extends State { initialDelay: Duration(seconds: 15), inputData: { "client_token": client.token, - "client_base": client.base + "client_base": client.base, + "x_client_token": client.xClientToken, }); } @@ -132,7 +133,12 @@ class VikunjaGlobalState extends State { }); } - void changeUser(User newUser, {String? token, String? base}) async { + void changeUser( + User newUser, { + String? token, + String? base, + String? xClientToken, + }) async { setState(() { _loading = true; }); @@ -148,6 +154,16 @@ class VikunjaGlobalState extends State { // Write new base to secure storage await _storage.write(key: "${newUser.id.toString()}_base", value: base); } + + if (xClientToken == null) { + xClientToken = + await _storage.read(key: "${newUser.id.toString()}_x_client_token"); + } else { + // Write new xClientToken to secure storage + await _storage.write( + key: "${newUser.id.toString()}_x_client_token", value: xClientToken); + } + // Set current user in storage await _storage.write(key: 'currentUser', value: newUser.id.toString()); client.configure(token: token, base: base, authenticated: true); @@ -185,13 +201,20 @@ class VikunjaGlobalState extends State { } var token = await _storage.read(key: currentUser); var base = await _storage.read(key: '${currentUser}_base'); + var xClientToken = + await _storage.read(key: '${currentUser}_x_client_token'); if (token == null || base == null) { setState(() { _loading = false; }); return; } - client.configure(token: token, base: base, authenticated: true); + client.configure( + token: token, + base: base, + authenticated: true, + xClientToken: xClientToken, + ); User loadedCurrentUser; try { loadedCurrentUser = await UserAPIService(client).getCurrentUser(); diff --git a/lib/main.dart b/lib/main.dart index 0073576..44920df 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -35,9 +35,6 @@ class IgnoreCertHttpOverrides extends HttpOverrides { @pragma('vm:entry-point') void callbackDispatcher() { - if (kIsWeb) { - return; - } Workmanager().executeTask((task, inputData) async { print( "Native called background task: $task"); //simpleTask will be emitted here. @@ -45,6 +42,7 @@ void callbackDispatcher() { Client client = Client(null, token: inputData["client_token"], base: inputData["client_base"], + xClientToken: inputData["x_client_token"], authenticated: true); tz.initializeTimeZones(); @@ -70,13 +68,19 @@ void callbackDispatcher() { return Future.value(true); } var token = await _storage.read(key: currentUser); - var base = await _storage.read(key: '${currentUser}_base'); + var xClientToken = + await _storage.read(key: '${currentUser}_x_client_token'); if (token == null || base == null) { return Future.value(true); } Client client = Client(null); - client.configure(token: token, base: base, authenticated: true); + client.configure( + token: token, + base: base, + xClientToken: xClientToken, + authenticated: true, + ); // load new token from server to avoid expiration String? newToken = await UserAPIService(client).getToken(); if (newToken != null) { @@ -114,16 +118,15 @@ void main() async { print("Failed to initialize workmanager: $e"); } runApp(VikunjaGlobal( - child: new VikunjaApp( - home: HomePage(), - key: UniqueKey(), - navkey: globalNavigatorKey, - ), - login: new VikunjaApp( - home: LoginPage(), - key: UniqueKey(), - ), - )); + child: new VikunjaApp( + home: HomePage(), + key: UniqueKey(), + navkey: globalNavigatorKey, + ), + login: new VikunjaApp( + home: LoginPage(), + key: UniqueKey(), + ))); } final ValueNotifier updateTheme = ValueNotifier(false); diff --git a/lib/pages/user/login.dart b/lib/pages/user/login.dart index 5d6a6b1..10aef8d 100644 --- a/lib/pages/user/login.dart +++ b/lib/pages/user/login.dart @@ -4,10 +4,13 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_typeahead/flutter_typeahead.dart'; import 'package:vikunja_app/api/client.dart'; +import 'package:vikunja_app/api/user_implementation.dart'; +import 'package:vikunja_app/constants.dart'; import 'package:vikunja_app/global.dart'; import 'package:vikunja_app/models/user.dart'; import 'package:vikunja_app/pages/user/login_webview.dart'; import 'package:vikunja_app/pages/user/register.dart'; +import 'package:vikunja_app/service/services.dart'; import 'package:vikunja_app/theme/button.dart'; import 'package:vikunja_app/theme/buttonText.dart'; import 'package:vikunja_app/theme/constants.dart'; @@ -26,12 +29,14 @@ class _LoginPageState extends State { bool _rememberMe = false; bool init = false; List pastServers = []; + int amountTaps = 0; + DateTime? lastTap; + bool _showXClientTokent = false; final _serverController = TextEditingController(); final _usernameController = TextEditingController(); final _passwordController = TextEditingController(); - - final _serverSuggestionController = SuggestionsController(); + final _xClientTokenController = TextEditingController(); @override void initState() { @@ -82,12 +87,15 @@ class _LoginPageState extends State { children: [ Padding( padding: EdgeInsets.symmetric(vertical: 30), - child: Image( - image: Theme.of(context).brightness == Brightness.dark - ? AssetImage('assets/vikunja_logo_full_white.png') - : AssetImage('assets/vikunja_logo_full.png'), - height: 85.0, - semanticLabel: 'Vikunja Logo', + child: GestureDetector( + onTap: _handleLogoTap, + child: Image( + image: Theme.of(context).brightness == Brightness.dark + ? AssetImage('assets/vikunja_logo_full_white.png') + : AssetImage('assets/vikunja_logo_full.png'), + height: 80.0, + semanticLabel: 'Vikunja Logo', + ), ), ), Padding( @@ -95,9 +103,6 @@ class _LoginPageState extends State { child: Row(children: [ Expanded( child: TypeAheadField( - //suggestionsBoxController: _serverSuggestionController, - //getImmediateSuggestions: true, - //enabled: !_loading, controller: _serverController, builder: (context, controller, focusnode) { return TextFormField( @@ -116,13 +121,6 @@ class _LoginPageState extends State { labelText: 'Server Address'), ); }, - /* - textFieldConfiguration: TextFieldConfiguration( - controller: _serverController, - decoration: new InputDecoration( - border: OutlineInputBorder(), - labelText: 'Server Address'), - ),*/ onSelected: (suggestion) { _serverController.text = suggestion; setState( @@ -166,22 +164,6 @@ class _LoginPageState extends State { }, ), ), - /* - DropdownButton( - onChanged: (String? value) { - // This is called when the user selects an item. - setState(() { - if (value != null) _serverController.text = value; - }); - }, - items: pastServers - .map>((dynamic value) { - return DropdownMenuItem( - value: value, - child: Text(value), - ); - }).toList(), - ),*/ ]), ), Padding( @@ -207,6 +189,17 @@ class _LoginPageState extends State { obscureText: true, ), ), + if (_showXClientTokent) + Padding( + padding: vStandardVerticalPadding, + child: TextFormField( + enabled: !_loading, + controller: _xClientTokenController, + decoration: new InputDecoration( + border: OutlineInputBorder(), + labelText: 'X-Client-Token'), + ), + ), Padding( padding: vStandardVerticalPadding, child: CheckboxListTile( @@ -218,14 +211,7 @@ class _LoginPageState extends State { ), Builder( builder: (context) => FancyButton( - onPressed: !_loading - ? () { - if (_formKey.currentState!.validate()) { - Form.of(context).save(); - _loginUser(context); - } - } - : null, + onPressed: !_loading ? _doLogin(context) : null, child: _loading ? CircularProgressIndicator() : VikunjaButtonText('Login'), @@ -239,26 +225,11 @@ class _LoginPageState extends State { child: VikunjaButtonText('Register'), )), Builder( - builder: (context) => FancyButton( - onPressed: () { - if (_formKey.currentState!.validate() && - _serverController.text.isNotEmpty) { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => LoginWithWebView( - _serverController.text))).then( - (btp) { - if (btp != null) _loginUserByClientToken(btp); - }); - } else { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - "Please enter your frontend url"))); - } - }, - child: VikunjaButtonText("Login with Frontend"))), + builder: (context) => FancyButton( + onPressed: _loginWithFrontend, + child: VikunjaButtonText("Login with Frontend"), + ), + ), CheckboxListTile( title: Text("Ignore Certificates"), value: client.ignoreCertificates, @@ -281,12 +252,41 @@ class _LoginPageState extends State { ); } + _doLogin(BuildContext context) { + return () { + if (_formKey.currentState!.validate()) { + Form.of(context).save(); + _loginUser(context); + } + }; + } + + _loginWithFrontend() { + if (_formKey.currentState!.validate() && + _serverController.text.isNotEmpty) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + LoginWithWebView(_serverController.text))).then((btp) { + if (btp != null) _loginUserByClientToken(btp); + }); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text("Please enter your frontend url"), + ), + ); + } + } + _loginUser(BuildContext context) async { String _server = _serverController.text; String _username = _usernameController.text; String _password = _passwordController.text; - if (_server.isEmpty) return; + String _xClientToken = _xClientTokenController.text; + if (_server.isEmpty) return; if (!pastServers.contains(_server)) pastServers.add(_server); await VikunjaGlobal.of(context).settingsManager.setPastServers(pastServers); @@ -294,16 +294,26 @@ class _LoginPageState extends State { try { var vGlobal = VikunjaGlobal.of(context); vGlobal.client.showSnackBar = false; - vGlobal.client.configure(base: _server); + vGlobal.client.configure(base: _server, xClientToken: _xClientToken); Server? info = await vGlobal.serverService.getInfo(); if (info == null) throw Exception("Getting server info failed"); UserTokenPair newUser; - newUser = await vGlobal.newUserService! - .login(_username, _password, rememberMe: this._rememberMe); + Client client = Client( + vGlobal.snackbarKey, + base: _server, + xClientToken: _xClientToken, + ); + UserService userService = UserAPIService(client); + newUser = await userService.login( + _username, + _password, + rememberMe: this._rememberMe, + xClientToken: _xClientToken, + ); - if (newUser.error == 1017) { + if (newUser.error == ErrorCodeOtpRequired) { TextEditingController totpController = TextEditingController(); bool dismissed = true; await showDialog( @@ -328,35 +338,33 @@ class _LoginPageState extends State { ), ); if (!dismissed) { - newUser = await vGlobal.newUserService!.login(_username, _password, - rememberMe: this._rememberMe, totp: totpController.text); + newUser = await userService.login( + _username, + _password, + rememberMe: this._rememberMe, + totp: totpController.text, + ); } else { throw Exception(); } } if (newUser.error > 0) { - ScaffoldMessenger.of(context) - .showSnackBar(SnackBar(content: Text(newUser.errorString))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(newUser.errorString), + ), + ); } if (newUser.error == 0) - vGlobal.changeUser(newUser.user!, token: newUser.token, base: _server); + vGlobal.changeUser( + newUser.user!, + token: newUser.token, + base: _server, + xClientToken: _xClientToken, + ); } catch (ex) { print(ex); - /* log(stacktrace.toString()); - showDialog( - context: context, - builder: (context) => new AlertDialog( - title: Text( - 'Login failed! Please check your server url and credentials. ' + - ex.toString()), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Close')) - ], - )); - */ } finally { VikunjaGlobal.of(context).client.showSnackBar = true; setState(() { @@ -384,4 +392,30 @@ class _LoginPageState extends State { } setState(() => _loading = false); } + + void _handleLogoTap() { + if (lastTap != null && + DateTime.now().difference(lastTap!) < Duration(seconds: 2)) { + amountTaps++; + } else { + amountTaps = 1; + } + lastTap = DateTime.now(); + if (amountTaps == 5) { + // Show X-Client-Token field + ScaffoldMessenger.of(context).clearSnackBars(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + "X-Client-Token " + (_showXClientTokent ? "hidden" : "shown"), + ), + ), + ); + setState(() { + _showXClientTokent = !_showXClientTokent; + }); + amountTaps = 0; + lastTap = null; + } + } } diff --git a/lib/service/services.dart b/lib/service/services.dart index 53cb4c3..f89ff76 100644 --- a/lib/service/services.dart +++ b/lib/service/services.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'dart:convert'; -import 'package:flutter/material.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:vikunja_app/api/response.dart'; import 'package:vikunja_app/models/label.dart'; @@ -221,8 +220,13 @@ abstract class BucketService { } abstract class UserService { - Future login(String username, String password, - {bool rememberMe = false, String totp}); + Future login( + String username, + String password, { + bool rememberMe = false, + String totp, + String? xClientToken, + }); Future register(String username, email, password);