Initial wiring in of OAuth

codemagic-setup
Hank Grabowski 2023-02-27 23:36:18 -05:00
rodzic af5d0728ce
commit 04ed08bebb
9 zmienionych plików z 425 dodań i 112 usunięć

Wyświetl plik

@ -1,43 +1,53 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="social.myportal.relatica">
package="social.myportal.relatica">
<application
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher_round"
android:label="Relatica">
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher_round"
android:label="Relatica">
<activity
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=".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">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme" />
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"/>
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<activity
android:name="com.linusu.flutter_web_auth_2.CallbackActivity"
android:exported="true">
<intent-filter android:label="flutter_web_auth_2">
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="relatica"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
android:name="flutterEmbedding"
android:value="2"/>
</application>
<queries>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="https" />
<action android:name="android.intent.action.VIEW"/>
<data android:scheme="https"/>
</intent>
</queries>
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
</manifest>

Wyświetl plik

@ -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;

Wyświetl plik

@ -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<ICredentials, ExecError> signIn() async {
final result = await _getIds()
.andThenAsync((_) async => await _login())
.andThenSuccessAsync((_) async => this);
return result.execErrorCast();
}
@override
Map<String, dynamic> toJson() => {
'clientId': clientId,
'clientSecret': clientSecret,
'redirectUrl': redirectUrl,
'redirectScheme': redirectScheme,
'accessToken': accessToken,
'serverName': serverName,
'id': id,
};
static OAuthCredentials fromJson(Map<String, dynamic> json) =>
OAuthCredentials(
clientId: json['clientId'],
clientSecret: json['clientSecret'],
redirectUrl: json['redirectUrl'],
redirectScheme: json['redirectScheme'],
accessToken: json['accessToken'],
serverName: json['serverName'],
id: json['id'],
);
FutureResult<bool, ExecError> _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<bool, ExecError> _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);
}
}

Wyświetl plik

@ -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<SignInScreen> {
static final _logger = Logger('$SignInScreen');
static const usernamePasswordType = 'Username/Password';
static const oauthType = 'OAuth';
static final authTypes = [usernamePasswordType, oauthType];
final formKey = GlobalKey<FormState>();
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<AccountsService>();
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<SignInScreen> {
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
DropdownButton<String>(
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<SignInScreen> {
),
),
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<SignInScreen> {
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<SignInScreen> {
: 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<SignInScreen> {
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<AccountsService>().signIn(creds);
if (!mounted) {

Wyświetl plik

@ -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) {

Wyświetl plik

@ -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<List<Profile>, ExecError> loadProfiles() async {
try {
final basicJson = await _secureStorage.read(key: _basicProfilesKey);
if (basicJson == null) {
return Result.ok(profiles);
}
final basicCreds = (jsonDecode(basicJson) as List<dynamic>)
.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<List<Profile>, 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<BasicCredentials>(_basicProfilesKey);
await _saveJson<OAuthCredentials>(_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<void> _loadJson(
String key,
ICredentials Function(Map<String, dynamic>) fromJson,
) async {
final jsonString = await _secureStorage.read(key: key);
if (jsonString == null || jsonString.isEmpty) {
return;
}
final profiles = (jsonDecode(jsonString) as List<dynamic>)
.map((json) => Profile.fromJson(json, fromJson))
.toList();
_cachedProfiles.addAll(profiles);
}
Future<void> _saveJson<T>(
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);
}
}

Wyświetl plik

@ -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"))

Wyświetl plik

@ -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

Wyświetl plik

@ -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