diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 8a400e8..0b8d3d9 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,43 +1,53 @@ + package="social.myportal.relatica"> + android:name="${applicationName}" + android:icon="@mipmap/ic_launcher_round" + android:label="Relatica"> + android:name=".MainActivity" + android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" + android:exported="true" + android:hardwareAccelerated="true" + android:launchMode="singleTop" + android:theme="@style/LaunchTheme" + android:windowSoftInputMode="adjustResize"> + android:name="io.flutter.embedding.android.NormalTheme" + android:resource="@style/NormalTheme"/> - - + + + + + + + + + + + android:name="flutterEmbedding" + android:value="2"/> - - + + - - + + diff --git a/lib/models/auth/basic_credentials.dart b/lib/models/auth/basic_credentials.dart index a41fe1f..d05e69e 100644 --- a/lib/models/auth/basic_credentials.dart +++ b/lib/models/auth/basic_credentials.dart @@ -5,7 +5,7 @@ import 'package:relatica/models/exec_error.dart'; import 'package:result_monad/src/result_monad_base.dart'; import 'package:uuid/uuid.dart'; -class BasicCredentials extends ICredentials { +class BasicCredentials implements ICredentials { late final String id; final String username; final String password; diff --git a/lib/models/auth/oauth_credentials.dart b/lib/models/auth/oauth_credentials.dart new file mode 100644 index 0000000..6f28372 --- /dev/null +++ b/lib/models/auth/oauth_credentials.dart @@ -0,0 +1,168 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_web_auth_2/flutter_web_auth_2.dart'; +import 'package:http/http.dart' as http; +import 'package:logging/logging.dart'; +import 'package:relatica/models/auth/credentials_intf.dart'; +import 'package:relatica/models/exec_error.dart'; +import 'package:result_monad/result_monad.dart'; +import 'package:uuid/uuid.dart'; + +class OAuthCredentials implements ICredentials { + static final _logger = Logger('$OAuthCredentials'); + String clientId; + String clientSecret; + final String redirectUrl; + final String redirectScheme; + String accessToken; + + @override + final String serverName; + + @override + final String id; + + OAuthCredentials({ + required this.clientId, + required this.clientSecret, + required this.redirectUrl, + required this.redirectScheme, + required this.accessToken, + required this.serverName, + required this.id, + }); + + factory OAuthCredentials.bootstrap(String serverName) { + final redirectScheme = Platform.isWindows || Platform.isLinux + ? 'http://localhost:43824' + : 'relatica'; + final redirectUrl = Platform.isWindows || Platform.isLinux + ? redirectScheme + : '$redirectScheme:/'; + return OAuthCredentials( + clientId: '', + clientSecret: '', + redirectUrl: redirectUrl, + redirectScheme: redirectScheme, + accessToken: '', + serverName: serverName, + id: const Uuid().v4(), + ); + } + + @override + String get authHeaderValue => 'Bearer $accessToken'; + + @override + FutureResult signIn() async { + final result = await _getIds() + .andThenAsync((_) async => await _login()) + .andThenSuccessAsync((_) async => this); + return result.execErrorCast(); + } + + @override + Map toJson() => { + 'clientId': clientId, + 'clientSecret': clientSecret, + 'redirectUrl': redirectUrl, + 'redirectScheme': redirectScheme, + 'accessToken': accessToken, + 'serverName': serverName, + 'id': id, + }; + + static OAuthCredentials fromJson(Map json) => + OAuthCredentials( + clientId: json['clientId'], + clientSecret: json['clientSecret'], + redirectUrl: json['redirectUrl'], + redirectScheme: json['redirectScheme'], + accessToken: json['accessToken'], + serverName: json['serverName'], + id: json['id'], + ); + + FutureResult _getIds() async { + if (clientId.isNotEmpty || clientSecret.isNotEmpty) { + _logger.info( + 'Client ID and Client Secret are already set, skipping ID fetching'); + return Result.ok(true); + } + final idEndpoint = + Uri.parse('https://friendicadevtest1.myportal.social/api/v1/apps'); + final response = await http.post(idEndpoint, body: { + 'client_name': 'Relatica', + 'redirect_uris': '$redirectUrl', + 'scopes': 'read write push', + 'website': 'https://myportal.social', + }); + + if (response.statusCode != 200) { + _logger.severe('Error: ${response.statusCode}: ${response.body}'); + return buildErrorResult( + type: ErrorType.serverError, + message: 'Error: ${response.statusCode}: ${response.body}', + ); + } + + final json = jsonDecode(response.body); + clientId = json['client_id']; + clientSecret = json['client_secret']; + return Result.ok(true); + } + + FutureResult _login() async { + if (accessToken.isNotEmpty) { + _logger.info('Already have access token, skipping'); + return Result.ok(true); + } + // Construct the url + final url = Uri.https(serverName, '/oauth/authorize', { + 'response_type': 'code', + 'client_id': clientId, + 'redirect_uri': redirectUrl, + 'scope': 'read write push', + }); + + try { + final result = await FlutterWebAuth2.authenticate( + url: url.toString(), callbackUrlScheme: redirectScheme); + final code = Uri.parse(result).queryParameters['code']; + if (code == null) { + _logger.severe( + 'Error code was not returned with the query parameters: $result'); + return buildErrorResult( + type: ErrorType.serverError, + message: 'Error getting the response code during authentication', + ); + } + final url2 = Uri.parse('https://$serverName/oauth/token'); + final body = { + 'client_id': clientId, + 'client_secret': clientSecret, + 'redirect_uri': redirectUrl, + 'grant_type': 'authorization_code', + 'code': code, + }; + final response = await http.post(url2, body: body); + if (response.statusCode != 200) { + _logger.severe('Error: ${response.statusCode}: ${response.body}'); + return buildErrorResult( + type: ErrorType.serverError, + message: 'Error: ${response.statusCode}: ${response.body}', + ); + } + accessToken = jsonDecode(response.body)['access_token']; + } catch (e) { + _logger.severe('Exception while Doing OAuth Process: $e'); + return buildErrorResult( + type: ErrorType.serverError, + message: 'Exception while Doing OAuth Process: $e', + ); + } + + return Result.ok(true); + } +} diff --git a/lib/screens/sign_in.dart b/lib/screens/sign_in.dart index fe2025b..604b7be 100644 --- a/lib/screens/sign_in.dart +++ b/lib/screens/sign_in.dart @@ -1,11 +1,14 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; +import 'package:logging/logging.dart'; import 'package:relatica/models/auth/basic_credentials.dart'; import 'package:relatica/routes.dart'; import 'package:string_validator/string_validator.dart'; import '../controls/padding.dart'; import '../globals.dart'; +import '../models/auth/credentials_intf.dart'; +import '../models/auth/oauth_credentials.dart'; import '../services/auth_service.dart'; import '../utils/snackbar_builder.dart'; @@ -15,21 +18,57 @@ class SignInScreen extends StatefulWidget { } class _SignInScreenState extends State { + static final _logger = Logger('$SignInScreen'); + static const usernamePasswordType = 'Username/Password'; + static const oauthType = 'OAuth'; + static final authTypes = [usernamePasswordType, oauthType]; final formKey = GlobalKey(); final usernameController = TextEditingController(); final serverNameController = TextEditingController(); final passwordController = TextEditingController(); + var authType = oauthType; var hidePassword = true; + var showUsernameAndPasswordFields = true; @override void initState() { super.initState(); final service = getIt(); if (service.loggedIn) { - usernameController.text = service.currentProfile.username; - passwordController.text = - (service.currentProfile.credentials as BasicCredentials).password; - serverNameController.text = service.currentProfile.serverName; + setCredentials(null, service.currentProfile.credentials); + } + } + + void setBasicCredentials(BasicCredentials credentials) { + usernameController.text = credentials.username; + passwordController.text = credentials.password; + serverNameController.text = credentials.serverName; + showUsernameAndPasswordFields = true; + authType = usernamePasswordType; + } + + void setOauthCredentials(OAuthCredentials credentials) { + serverNameController.text = credentials.serverName; + showUsernameAndPasswordFields = false; + authType = oauthType; + } + + void setCredentials(BuildContext? context, ICredentials credentials) { + if (credentials is BasicCredentials) { + setBasicCredentials(credentials); + return; + } + + if (credentials is OAuthCredentials) { + setOauthCredentials(credentials); + return; + } + + final msg = 'Unknown credentials type: ${credentials.runtimeType}'; + _logger.severe(msg); + + if (context?.mounted ?? false) { + buildSnackbar(context!, msg); } } @@ -51,6 +90,27 @@ class _SignInScreenState extends State { crossAxisAlignment: CrossAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center, children: [ + DropdownButton( + value: authType, + items: authTypes + .map((a) => DropdownMenuItem(value: a, child: Text(a))) + .toList(), + onChanged: (value) { + setState(() { + authType = value ?? ''; + switch (value) { + case usernamePasswordType: + showUsernameAndPasswordFields = true; + break; + case oauthType: + showUsernameAndPasswordFields = false; + break; + default: + print("Don't know this"); + } + }); + }), + const VerticalPadding(), TextFormField( autovalidateMode: AutovalidateMode.onUserInteraction, controller: serverNameController, @@ -69,64 +129,66 @@ class _SignInScreenState extends State { ), ), const VerticalPadding(), - TextFormField( - autovalidateMode: AutovalidateMode.onUserInteraction, - controller: usernameController, - keyboardType: TextInputType.emailAddress, - validator: (value) { - if (value == null) { - return null; - } + if (showUsernameAndPasswordFields) ...[ + TextFormField( + autovalidateMode: AutovalidateMode.onUserInteraction, + controller: usernameController, + keyboardType: TextInputType.emailAddress, + validator: (value) { + if (value == null) { + return null; + } - if (value.contains('@')) { - return isEmail(value ?? '') + if (value.contains('@')) { + return isEmail(value) + ? null + : 'Not a valid Friendica Account Address'; + } + + return isAlphanumeric(value.replaceAll('-', '')) ? null - : 'Not a valid Friendica Account Address'; - } - - return isAlphanumeric(value.replaceAll('-', '')) - ? null - : 'Username should be alpha-numeric'; - }, - decoration: InputDecoration( - prefixIcon: const Icon(Icons.alternate_email), - hintText: 'Username (user@example.com)', - border: OutlineInputBorder( - borderSide: BorderSide( - color: Theme.of(context).backgroundColor, + : 'Username should be alpha-numeric'; + }, + decoration: InputDecoration( + prefixIcon: const Icon(Icons.alternate_email), + hintText: 'Username (user@example.com)', + border: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).backgroundColor, + ), + borderRadius: BorderRadius.circular(5.0), ), - borderRadius: BorderRadius.circular(5.0), + labelText: 'Username', ), - labelText: 'Username', ), - ), - const VerticalPadding(), - TextFormField( - obscureText: hidePassword, - controller: passwordController, - decoration: InputDecoration( - prefixIcon: const Icon(Icons.password), - suffixIcon: IconButton( - onPressed: () { - setState(() { - hidePassword = !hidePassword; - }); - }, - icon: hidePassword - ? const Icon(Icons.remove_red_eye_outlined) - : const Icon(Icons.remove_red_eye), - ), - hintText: 'Password', - border: OutlineInputBorder( - borderSide: BorderSide( - color: Theme.of(context).backgroundColor, + const VerticalPadding(), + TextFormField( + obscureText: hidePassword, + controller: passwordController, + decoration: InputDecoration( + prefixIcon: const Icon(Icons.password), + suffixIcon: IconButton( + onPressed: () { + setState(() { + hidePassword = !hidePassword; + }); + }, + icon: hidePassword + ? const Icon(Icons.remove_red_eye_outlined) + : const Icon(Icons.remove_red_eye), ), - borderRadius: BorderRadius.circular(5.0), + hintText: 'Password', + border: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).backgroundColor, + ), + borderRadius: BorderRadius.circular(5.0), + ), + labelText: 'Password', ), - labelText: 'Password', ), - ), - const VerticalPadding(), + const VerticalPadding(), + ], ElevatedButton( onPressed: () => _signIn(context), child: const Text('Signin'), @@ -140,12 +202,8 @@ class _SignInScreenState extends State { final p = loggedOutProfiles[index]; return ListTile( onTap: () { - setState(() { - serverNameController.text = p.serverName; - usernameController.text = p.username; - passwordController.text = - (p.credentials as BasicCredentials).password; - }); + setCredentials(context, p.credentials); + setState(() {}); }, title: Text(p.handle), subtitle: Text(p.id), @@ -167,7 +225,10 @@ class _SignInScreenState extends State { : false; return ListTile( onTap: () async { + setCredentials(context, p.credentials); + setState(() {}); await service.setActiveProfile(p); + if (mounted) { clearCaches(); context.goNamed(ScreenPaths.timelines); @@ -196,10 +257,25 @@ class _SignInScreenState extends State { void _signIn(BuildContext context) async { if (formKey.currentState?.validate() ?? false) { - final creds = BasicCredentials( - username: usernameController.text, - password: passwordController.text, - serverName: serverNameController.text); + ICredentials? creds; + switch (authType) { + case usernamePasswordType: + creds = BasicCredentials( + username: usernameController.text, + password: passwordController.text, + serverName: serverNameController.text); + break; + case oauthType: + creds = OAuthCredentials.bootstrap(serverNameController.text); + break; + default: + buildSnackbar(context, 'Unknown authorization type: $authType'); + break; + } + + if (creds == null) { + return; + } final result = await getIt().signIn(creds); if (!mounted) { diff --git a/lib/services/auth_service.dart b/lib/services/auth_service.dart index c7c8d75..c7d9565 100644 --- a/lib/services/auth_service.dart +++ b/lib/services/auth_service.dart @@ -119,6 +119,26 @@ class AccountsService extends ChangeNotifier { } } + Future removeProfile(Profile profile, {bool withNotification = true}) async { + if (_currentProfile == profile) { + await clearActiveProfile(withNotification: withNotification); + } + _loggedInProfiles.remove(profile); + _loggedOutProfiles.remove(profile); + await secretsService.removeProfile(profile); + + if (_loggedInProfiles.isNotEmpty) { + setActiveProfile( + _loggedInProfiles.first, + withNotification: withNotification, + ); + } + + if (withNotification) { + notifyListeners(); + } + } + Future clearActiveProfile({bool withNotification = true}) async { _currentProfile = null; if (withNotification) { diff --git a/lib/services/secrets_service.dart b/lib/services/secrets_service.dart index 5abf836..cf92ec0 100644 --- a/lib/services/secrets_service.dart +++ b/lib/services/secrets_service.dart @@ -1,11 +1,12 @@ import 'dart:collection'; import 'dart:convert'; -import 'package:flutter/services.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:relatica/models/auth/credentials_intf.dart'; import 'package:result_monad/result_monad.dart'; import '../models/auth/basic_credentials.dart'; +import '../models/auth/oauth_credentials.dart'; import '../models/auth/profile.dart'; import '../models/exec_error.dart'; @@ -33,10 +34,10 @@ class SecretsService { await _secureStorage.delete(key: _basicProfilesKey); await _secureStorage.delete(key: _oauthProfilesKey); return Result.ok(profiles); - } on PlatformException catch (e) { + } catch (e) { return Result.error(ExecError( type: ErrorType.localError, - message: e.message ?? '', + message: e.toString(), )); } } @@ -47,10 +48,10 @@ class SecretsService { _cachedProfiles.remove(profile); _cachedProfiles.add(profile); return await saveCredentials(); - } on PlatformException catch (e) { + } catch (e) { return Result.error(ExecError( type: ErrorType.localError, - message: e.message ?? '', + message: e.toString(), )); } } @@ -59,48 +60,62 @@ class SecretsService { try { _cachedProfiles.remove(profile); return await saveCredentials(); - } on PlatformException catch (e) { + } catch (e) { return Result.error(ExecError( type: ErrorType.localError, - message: e.message ?? '', + message: e.toString(), )); } } FutureResult, ExecError> loadProfiles() async { try { - final basicJson = await _secureStorage.read(key: _basicProfilesKey); - if (basicJson == null) { - return Result.ok(profiles); - } - final basicCreds = (jsonDecode(basicJson) as List) - .map((json) => Profile.fromJson(json, BasicCredentials.fromJson)) - .toList(); - _cachedProfiles.addAll(basicCreds); + await _loadJson(_basicProfilesKey, BasicCredentials.fromJson); + await _loadJson(_oauthProfilesKey, OAuthCredentials.fromJson); return Result.ok(profiles); - } on PlatformException catch (e) { + } catch (e) { return Result.error(ExecError( type: ErrorType.localError, - message: e.message ?? '', + message: e.toString(), )); } } FutureResult, ExecError> saveCredentials() async { try { - final basicCredsJson = _cachedProfiles - .where((p) => p.credentials is BasicCredentials) - .map((p) => p.toJson()) - .toList(); - final basicCredsString = jsonEncode(basicCredsJson); - await _secureStorage.write( - key: _basicProfilesKey, value: basicCredsString); + await _saveJson(_basicProfilesKey); + await _saveJson(_oauthProfilesKey); return Result.ok(profiles); - } on PlatformException catch (e) { + } catch (e) { return Result.error(ExecError( type: ErrorType.localError, - message: e.message ?? '', + message: e.toString(), )); } } + + Future _loadJson( + String key, + ICredentials Function(Map) fromJson, + ) async { + final jsonString = await _secureStorage.read(key: key); + if (jsonString == null || jsonString.isEmpty) { + return; + } + final profiles = (jsonDecode(jsonString) as List) + .map((json) => Profile.fromJson(json, fromJson)) + .toList(); + _cachedProfiles.addAll(profiles); + } + + Future _saveJson( + String key, + ) async { + final json = _cachedProfiles + .where((p) => p.credentials is T) + .map((p) => p.toJson()) + .toList(); + final jsonString = jsonEncode(json); + await _secureStorage.write(key: key, value: jsonString); + } } diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index a9d8564..e662fd7 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -8,6 +8,7 @@ import Foundation import desktop_window import device_info_plus import flutter_secure_storage_macos +import flutter_web_auth_2 import objectbox_flutter_libs import path_provider_foundation import shared_preferences_foundation @@ -18,6 +19,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { DesktopWindowPlugin.register(with: registry.registrar(forPlugin: "DesktopWindowPlugin")) DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) + FlutterWebAuth2Plugin.register(with: registry.registrar(forPlugin: "FlutterWebAuth2Plugin")) ObjectboxFlutterLibsPlugin.register(with: registry.registrar(forPlugin: "ObjectboxFlutterLibsPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) diff --git a/pubspec.lock b/pubspec.lock index 9d20e16..f7e9554 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -427,6 +427,23 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_web_auth_2: + dependency: "direct main" + description: + path: flutter_web_auth_2 + ref: add-linux-support + resolved-ref: ce43f48b3b6c0b055e3871eedf1feee1cbe1a2d9 + url: "https://github.com/HankG/flutter_web_auth_2.git" + source: git + version: "2.0.4" + flutter_web_auth_2_platform_interface: + dependency: transitive + description: + name: flutter_web_auth_2_platform_interface + sha256: "777df76e0a3b3c3ec2c33bda2fd832d58ba68183644ede0738665399c8810048" + url: "https://pub.dev" + source: hosted + version: "2.0.1" flutter_web_plugins: dependency: transitive description: flutter diff --git a/pubspec.yaml b/pubspec.yaml index eb29482..158ef96 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -17,6 +17,11 @@ dependencies: flutter_dotenv: ^5.0.2 flutter_file_dialog: ^2.3.2 flutter_secure_storage: ^7.0.1 + flutter_web_auth_2: + git: + url: https://github.com/HankG/flutter_web_auth_2.git + path: flutter_web_auth_2 + ref: add-linux-support flutter_widget_from_html_core: ^0.9.0 get_it: ^7.2.0 get_it_mixin: ^3.1.4