From d662a6c426f57cec209f3abed1eca18954fafd01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Schw=C3=B6rer?= Date: Sat, 1 Jun 2024 14:00:16 +0200 Subject: [PATCH] Implement login --- flutter/lib/api/api_client.dart | 71 +++++----- flutter/lib/models/client.dart | 6 +- flutter/lib/pages/account/account.dart | 134 ++++++++++--------- flutter/lib/pages/account/login.dart | 177 +++++++++++++++++-------- flutter/lib/state/user_account.dart | 14 ++ 5 files changed, 252 insertions(+), 150 deletions(-) diff --git a/flutter/lib/api/api_client.dart b/flutter/lib/api/api_client.dart index 5c61194..e9fb7e4 100644 --- a/flutter/lib/api/api_client.dart +++ b/flutter/lib/api/api_client.dart @@ -36,7 +36,7 @@ class APIClient { Map? query, required T Function(Map json)? fn, dynamic jsonBody, - KeyTokenAuth? auth, + String? authToken, Map? header, }) async { final t0 = DateTime.now(); @@ -52,8 +52,8 @@ class APIClient { req.headers['Content-Type'] = 'application/json'; } - if (auth != null) { - req.headers['Authorization'] = 'SCN ${auth.tokenAdmin}'; + if (authToken != null) { + req.headers['Authorization'] = 'SCN ${authToken}'; } req.headers['User-Agent'] = 'simplecloudnotifier/flutter/${Globals().platform.replaceAll(' ', '_')} ${Globals().version}+${Globals().buildNumber}'; @@ -117,32 +117,17 @@ class APIClient { // ========================================================================================================================================================== - static Future verifyToken(String uid, String tok) async { - try { - await _request( - name: 'verifyToken', - method: 'GET', - relURL: '/users/$uid', - fn: null, - auth: KeyTokenAuth(userId: uid, tokenAdmin: tok, tokenSend: ''), - ); - return true; - } catch (e) { - return false; - } - } - static Future getUser(KeyTokenAuth auth, String uid) async { return await _request( name: 'getUser', method: 'GET', relURL: 'users/$uid', fn: User.fromJson, - auth: auth, + authToken: auth.tokenAdmin, ); } - static Future addClient(KeyTokenAuth? auth, String fcmToken, String agentModel, String agentVersion, String? descriptionName, String clientType) async { + static Future addClient(KeyTokenAuth? auth, String fcmToken, String agentModel, String agentVersion, String? name, String clientType) async { return await _request( name: 'addClient', method: 'POST', @@ -152,14 +137,14 @@ class APIClient { 'agent_model': agentModel, 'agent_version': agentVersion, 'client_type': clientType, - 'description_name': descriptionName, + 'name': name, }, fn: Client.fromJson, - auth: auth, + authToken: auth.tokenAdmin, ); } - static Future updateClient(KeyTokenAuth? auth, String clientID, String fcmToken, String agentModel, String? descriptionName, String agentVersion) async { + static Future updateClient(KeyTokenAuth? auth, String clientID, String fcmToken, String agentModel, String? name, String agentVersion) async { return await _request( name: 'updateClient', method: 'PUT', @@ -168,10 +153,10 @@ class APIClient { 'fcm_token': fcmToken, 'agent_model': agentModel, 'agent_version': agentVersion, - 'description_name': descriptionName, + 'name': name, }, fn: Client.fromJson, - auth: auth, + authToken: auth.tokenAdmin, ); } @@ -182,7 +167,7 @@ class APIClient { relURL: 'users/${auth.userId}/channels', query: {'selector': sel.apiKey}, fn: (json) => ChannelWithSubscription.fromJsonArray(json['channels'] as List), - auth: auth, + authToken: auth.tokenAdmin, ); } @@ -197,7 +182,7 @@ class APIClient { if (channelIDs != null) 'channel_id': channelIDs.join(","), }, fn: (json) => Message.fromPaginatedJsonArray(json, 'messages', 'next_page_token'), - auth: auth, + authToken: auth.tokenAdmin, ); } @@ -208,7 +193,7 @@ class APIClient { relURL: 'messages/$msgid', query: {}, fn: Message.fromJson, - auth: auth, + authToken: auth.tokenAdmin, ); } @@ -218,7 +203,7 @@ class APIClient { method: 'GET', relURL: 'users/${auth.userId}/subscriptions', fn: (json) => Subscription.fromJsonArray(json['subscriptions'] as List), - auth: auth, + authToken: auth.tokenAdmin, ); } @@ -228,7 +213,7 @@ class APIClient { method: 'GET', relURL: 'users/${auth.userId}/clients', fn: (json) => Client.fromJsonArray(json['clients'] as List), - auth: auth, + authToken: auth.tokenAdmin, ); } @@ -238,11 +223,11 @@ class APIClient { method: 'GET', relURL: 'users/${auth.userId}/keys', fn: (json) => KeyToken.fromJsonArray(json['keys'] as List), - auth: auth, + authToken: auth.tokenAdmin, ); } - static Future createUserWithClient(String? username, String clientFcmToken, String clientAgentModel, String clientAgentVersion, String? clientDescriptionName, String clientType) async { + static Future createUserWithClient(String? username, String clientFcmToken, String clientAgentModel, String clientAgentVersion, String? clientName, String clientType) async { return await _request( name: 'createUserWithClient', method: 'POST', @@ -252,11 +237,31 @@ class APIClient { 'fcm_token': clientFcmToken, 'agent_model': clientAgentModel, 'agent_version': clientAgentVersion, - 'description_name': clientDescriptionName, + 'client_name': clientName, 'client_type': clientType, 'no_client': false, }, fn: UserWithClientsAndKeys.fromJson, ); } + + static Future getKeyToken(KeyTokenAuth auth, String kid) async { + return await _request( + name: 'getKeyToken', + method: 'GET', + relURL: 'users/${auth.userId}/keys/$kid', + fn: KeyToken.fromJson, + authToken: auth.tokenAdmin, + ); + } + + static Future getKeyTokenByToken(String userid, String token) async { + return await _request( + name: 'getCurrentKeyToken', + method: 'GET', + relURL: 'users/${userid}/keys/current', + fn: KeyToken.fromJson, + authToken: token, + ); + } } diff --git a/flutter/lib/models/client.dart b/flutter/lib/models/client.dart index c448ee2..3c4d778 100644 --- a/flutter/lib/models/client.dart +++ b/flutter/lib/models/client.dart @@ -6,7 +6,7 @@ class Client { final String timestampCreated; final String agentModel; final String agentVersion; - final String? descriptionName; + final String? name; const Client({ required this.clientID, @@ -16,7 +16,7 @@ class Client { required this.timestampCreated, required this.agentModel, required this.agentVersion, - required this.descriptionName, + required this.name, }); factory Client.fromJson(Map json) { @@ -28,7 +28,7 @@ class Client { timestampCreated: json['timestamp_created'] as String, agentModel: json['agent_model'] as String, agentVersion: json['agent_version'] as String, - descriptionName: json.containsKey('description_name') ? (json['description_name'] as String?) : null, //TODO change once API is updated / branch is merged + name: json['name'] as String?, ); } diff --git a/flutter/lib/pages/account/account.dart b/flutter/lib/pages/account/account.dart index 130c47a..17bcc8e 100644 --- a/flutter/lib/pages/account/account.dart +++ b/flutter/lib/pages/account/account.dart @@ -90,7 +90,7 @@ class _AccountRootPageState extends State { return Consumer( builder: (context, acc, child) { if (acc.auth == null) { - return buildNoAuth(context); + return _buildNoAuth(context); } else { return FutureBuilder( future: acc.loadUser(false), @@ -99,7 +99,7 @@ class _AccountRootPageState extends State { if (snapshot.hasError) { return Text('Error: ${snapshot.error}'); //TODO better error display } - return buildShowAccount(context, acc, snapshot.data!); + return _buildShowAccount(context, acc, snapshot.data!); } return Center(child: CircularProgressIndicator()); }), @@ -109,61 +109,63 @@ class _AccountRootPageState extends State { ); } - Widget buildNoAuth(BuildContext context) { - return Padding( - padding: const EdgeInsets.fromLTRB(24, 32, 24, 16), - child: Column( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (!loading) - Center( - child: Container( - width: 200, - height: 200, - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.secondary, - borderRadius: BorderRadius.circular(100), + Widget _buildNoAuth(BuildContext context) { + return SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.fromLTRB(24, 32, 24, 16), + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (!loading) + Center( + child: Container( + width: 200, + height: 200, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.secondary, + borderRadius: BorderRadius.circular(100), + ), + child: Center(child: FaIcon(FontAwesomeIcons.userSecret, size: 96, color: Theme.of(context).colorScheme.onSecondary)), ), - child: Center(child: FaIcon(FontAwesomeIcons.userSecret, size: 96, color: Theme.of(context).colorScheme.onSecondary)), ), - ), - if (loading) - Center( - child: Container( - width: 200, - height: 200, - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.secondary, - borderRadius: BorderRadius.circular(100), + if (loading) + Center( + child: Container( + width: 200, + height: 200, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.secondary, + borderRadius: BorderRadius.circular(100), + ), + child: Center(child: CircularProgressIndicator(color: Theme.of(context).colorScheme.onSecondary)), ), - child: Center(child: CircularProgressIndicator(color: Theme.of(context).colorScheme.onSecondary)), ), + const SizedBox(height: 32), + FilledButton( + style: FilledButton.styleFrom(textStyle: const TextStyle(fontSize: 24), padding: const EdgeInsets.fromLTRB(8, 12, 8, 12)), + onPressed: () { + if (loading) return; + _createNewAccount(); + }, + child: const Text('Create new account'), ), - const SizedBox(height: 32), - FilledButton( - style: FilledButton.styleFrom(textStyle: const TextStyle(fontSize: 24), padding: const EdgeInsets.fromLTRB(8, 12, 8, 12)), - onPressed: () { - if (loading) return; - createNewAccount(); - }, - child: const Text('Create new account'), - ), - const SizedBox(height: 16), - FilledButton.tonal( - style: FilledButton.styleFrom(textStyle: const TextStyle(fontSize: 24), padding: const EdgeInsets.fromLTRB(8, 12, 8, 12)), - onPressed: () { - if (loading) return; - Navigator.push(context, MaterialPageRoute(builder: (context) => AccountLoginPage())); - }, - child: const Text('Use existing account'), - ), - ], + const SizedBox(height: 16), + FilledButton.tonal( + style: FilledButton.styleFrom(textStyle: const TextStyle(fontSize: 24), padding: const EdgeInsets.fromLTRB(8, 12, 8, 12)), + onPressed: () { + if (loading) return; + Navigator.push(context, MaterialPageRoute(builder: (context) => AccountLoginPage())); + }, + child: const Text('Use existing account'), + ), + ], + ), ), ); } - Widget buildShowAccount(BuildContext context, UserAccount acc, User user) { + Widget _buildShowAccount(BuildContext context, UserAccount acc, User user) { //TODO better layout return Column( children: [ @@ -173,23 +175,23 @@ class _AccountRootPageState extends State { padding: const EdgeInsets.fromLTRB(8.0, 24.0, 8.0, 8.0), child: Column( children: [ - buildHeader(context, user), + _buildHeader(context, user), const SizedBox(height: 16), Text(user.username ?? user.userID, overflow: TextOverflow.ellipsis, style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20)), const SizedBox(height: 16), - ...buildCards(context, user), + ..._buildCards(context, user), ], ), ), ), const Expanded(child: SizedBox(height: 16)), - buildFooter(context, user), + _buildFooter(context, user), SizedBox(height: 40) ], ); } - Row buildHeader(BuildContext context, User user) { + Row _buildHeader(BuildContext context, User user) { return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -284,7 +286,7 @@ class _AccountRootPageState extends State { ); } - List buildCards(BuildContext context, User user) { + List _buildCards(BuildContext context, User user) { return [ Card.filled( margin: EdgeInsets.fromLTRB(0, 4, 0, 4), @@ -416,20 +418,20 @@ class _AccountRootPageState extends State { ]; } - Widget buildFooter(BuildContext context, User user) { + Widget _buildFooter(BuildContext context, User user) { return Padding( padding: const EdgeInsets.fromLTRB(8, 0, 8, 0), child: Row( children: [ - Expanded(child: FilledButton(onPressed: () {/*TODO*/}, child: Text('Logout'), style: TextButton.styleFrom(backgroundColor: Colors.orange))), + Expanded(child: FilledButton(onPressed: _logout, child: Text('Logout'), style: TextButton.styleFrom(backgroundColor: Colors.orange))), const SizedBox(width: 8), - Expanded(child: FilledButton(onPressed: () {/*TODO*/}, child: Text('Delete Account'), style: TextButton.styleFrom(backgroundColor: Colors.red))), + Expanded(child: FilledButton(onPressed: _deleteAccount, child: Text('Delete Account'), style: TextButton.styleFrom(backgroundColor: Colors.red))), ], ), ); } - void createNewAccount() async { + void _createNewAccount() async { setState(() => loading = true); final acc = Provider.of(context, listen: false); @@ -453,9 +455,8 @@ class _AccountRootPageState extends State { final user = await APIClient.createUserWithClient(null, fcmToken, Globals().platform, Globals().version, Globals().hostname, Globals().clientType); - acc.setUser(user.user); - acc.setToken(KeyTokenAuth(userId: user.user.userID, tokenAdmin: user.adminKey, tokenSend: user.sendKey)); - acc.setClient(user.clients[0]); + acc.set(user.user, user.clients[0], KeyTokenAuth(userId: user.user.userID, tokenAdmin: user.adminKey, tokenSend: user.sendKey)); + await acc.save(); } catch (exc, trace) { ApplicationLog.error('Failed to create user account: ' + exc.toString(), trace: trace); @@ -464,4 +465,17 @@ class _AccountRootPageState extends State { setState(() => loading = false); } } + + void _logout() async { + final acc = Provider.of(context, listen: false); + + acc.clear(); + await acc.save(); + + Toaster.info('Logout', 'Successfully logged out'); + } + + void _deleteAccount() async { + //TODO + } } diff --git a/flutter/lib/pages/account/login.dart b/flutter/lib/pages/account/login.dart index 7ce7795..9713308 100644 --- a/flutter/lib/pages/account/login.dart +++ b/flutter/lib/pages/account/login.dart @@ -1,14 +1,17 @@ +import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/material.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:provider/provider.dart'; import 'package:simplecloudnotifier/api/api_client.dart'; +import 'package:simplecloudnotifier/components/layout/scaffold.dart'; import 'package:simplecloudnotifier/models/key_token_auth.dart'; import 'package:simplecloudnotifier/state/application_log.dart'; +import 'package:simplecloudnotifier/state/globals.dart'; import 'package:simplecloudnotifier/state/user_account.dart'; +import 'package:simplecloudnotifier/utils/toaster.dart'; class AccountLoginPage extends StatefulWidget { - final void Function()? onLogin; - - const AccountLoginPage({super.key, this.onLogin}); + const AccountLoginPage({super.key}); @override State createState() => _AccountLoginPageState(); @@ -16,81 +19,147 @@ class AccountLoginPage extends StatefulWidget { class _AccountLoginPageState extends State { final TextEditingController _ctrlUserID = TextEditingController(); - final TextEditingController _ctrlToken = TextEditingController(); + final TextEditingController _ctrlTokenAdmin = TextEditingController(); + final TextEditingController _ctrlTokenSend = TextEditingController(); + + bool loading = false; @override void dispose() { _ctrlUserID.dispose(); - _ctrlToken.dispose(); + _ctrlTokenAdmin.dispose(); + _ctrlTokenSend.dispose(); super.dispose(); } @override Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - FractionallySizedBox( - widthFactor: 1.0, - child: TextField( - controller: _ctrlUserID, - decoration: const InputDecoration( - border: OutlineInputBorder(), - labelText: 'UserID', + return SCNScaffold( + title: 'Login', + showSearch: false, + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.fromLTRB(24, 32, 24, 16), + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (!loading) + Center( + child: Container( + width: 200, + height: 200, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.secondary, + borderRadius: BorderRadius.circular(100), + ), + child: Center(child: FaIcon(FontAwesomeIcons.solidRightToBracket, size: 96, color: Theme.of(context).colorScheme.onSecondary)), + ), + ), + if (loading) + Center( + child: Container( + width: 200, + height: 200, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.secondary, + borderRadius: BorderRadius.circular(100), + ), + child: Center(child: CircularProgressIndicator(color: Theme.of(context).colorScheme.onSecondary)), + ), + ), + const SizedBox(height: 32), + FractionallySizedBox( + widthFactor: 1.0, + child: TextField( + controller: _ctrlUserID, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'UserID', + ), + ), ), - ), - ), - const SizedBox(height: 16), - FractionallySizedBox( - widthFactor: 1.0, - child: TextField( - controller: _ctrlToken, - decoration: const InputDecoration( - border: OutlineInputBorder(), - labelText: 'Token', + const SizedBox(height: 16), + FractionallySizedBox( + widthFactor: 1.0, + child: TextField( + controller: _ctrlTokenAdmin, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'Admin Token', + ), + ), ), - ), + const SizedBox(height: 16), + FractionallySizedBox( + widthFactor: 1.0, + child: TextField( + controller: _ctrlTokenSend, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'Send Token (optional)', + ), + ), + ), + const SizedBox(height: 16), + FilledButton( + style: FilledButton.styleFrom(textStyle: const TextStyle(fontSize: 24), padding: const EdgeInsets.fromLTRB(8, 12, 8, 12)), + onPressed: _login, + child: const Text('Login'), + ), + ], ), - const SizedBox(height: 16), - FilledButton( - style: FilledButton.styleFrom(textStyle: const TextStyle(fontSize: 20)), - onPressed: _login, - child: const Text('Login'), - ), - ], + ), ), ); } void _login() async { - final msgr = ScaffoldMessenger.of(context); final acc = Provider.of(context, listen: false); try { - final uid = _ctrlUserID.text; - final tok = _ctrlToken.text; + setState(() => loading = true); - final verified = await APIClient.verifyToken(uid, tok); //TODO verify that this is an perm=ADMIN key - if (verified) { - msgr.showSnackBar( - const SnackBar( - content: Text('Data ok'), //TODO use toast? - ), - ); - acc.setToken(KeyTokenAuth(userId: uid, tokenAdmin: tok, tokenSend: '')); //TOTO send token - await acc.save(); - widget.onLogin?.call(); - } else { - msgr.showSnackBar( - const SnackBar( - content: Text('Failed to verify token'), //TODO use toast? - ), - ); + final uid = _ctrlUserID.text; + final atokv = _ctrlTokenAdmin.text; + final stokv = _ctrlTokenSend.text; + + final fcmToken = await FirebaseMessaging.instance.getToken(); + + if (fcmToken == null) { + Toaster.warn("Missing Token", 'No FCM Token found, please allow notifications, ensure you have a network connection and restart the app'); + return; } + + final toka = await APIClient.getKeyTokenByToken(uid, atokv); + + if (!toka.allChannels || toka.permissions != 'A') { + Toaster.error("Error", 'Admin token does not have required permissions'); + return; + } + + final toks = await APIClient.getKeyTokenByToken(uid, stokv); + + if (!toks.allChannels || toks.permissions != 'CS') { + Toaster.error("Error", 'Send token does not have required permissions'); + return; + } + + final kta = KeyTokenAuth(userId: uid, tokenAdmin: atokv, tokenSend: stokv); + + final user = await APIClient.getUser(kta, uid); + + final client = await APIClient.addClient(acc.auth, fcmToken, Globals().deviceModel, Globals().version, Globals().hostname, Globals().clientType); + + acc.set(user, client, kta); + await acc.save(); + + Toaster.success("Login", "Successfully logged in"); } catch (exc, trace) { ApplicationLog.error('Failed to verify token: ' + exc.toString(), trace: trace); + Toaster.error("Error", 'Failed to verify token'); + } finally { + setState(() => loading = false); } } } diff --git a/flutter/lib/state/user_account.dart b/flutter/lib/state/user_account.dart index 3255e4b..98805a5 100644 --- a/flutter/lib/state/user_account.dart +++ b/flutter/lib/state/user_account.dart @@ -58,6 +58,20 @@ class UserAccount extends ChangeNotifier { notifyListeners(); } + void set(User user, Client client, KeyTokenAuth auth) { + _client = client; + _user = user; + _auth = auth; + notifyListeners(); + } + + void clear() { + _client = null; + _user = null; + _auth = null; + notifyListeners(); + } + void load() { final uid = Globals().sharedPrefs.getString('auth.userid'); final toka = Globals().sharedPrefs.getString('auth.tokenadmin');