diff --git a/lib/constants/app_constants.dart b/lib/constants/app_constants.dart index 32b8214..8a85a50 100644 --- a/lib/constants/app_constants.dart +++ b/lib/constants/app_constants.dart @@ -1,4 +1,7 @@ abstract class ThingsboardAppConstants { static final thingsBoardApiEndpoint = 'http://localhost:8080'; static final thingsboardOAuth2CallbackUrlScheme = 'org.thingsboard.app.auth'; + + /// Not for production (only for debugging) + static final thingsboardOAuth2AppSecret = 'Your app secret here'; } diff --git a/lib/core/auth/login/login_page.dart b/lib/core/auth/login/login_page.dart index 0e645f5..ba5f96f 100644 --- a/lib/core/auth/login/login_page.dart +++ b/lib/core/auth/login/login_page.dart @@ -4,9 +4,7 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_svg/flutter_svg.dart'; -import 'package:flutter_web_auth/flutter_web_auth.dart'; import 'package:material_design_icons_flutter/material_design_icons_flutter.dart'; -import 'package:thingsboard_app/constants/app_constants.dart'; import 'package:thingsboard_app/constants/assets_path.dart'; import 'package:thingsboard_app/core/context/tb_context.dart'; import 'package:thingsboard_app/core/context/tb_context_widget.dart'; @@ -25,10 +23,12 @@ class LoginPage extends TbPageWidget { class _LoginPageState extends TbPageState { final ButtonStyle _oauth2ButtonWithTextStyle = - OutlinedButton.styleFrom(alignment: Alignment.centerLeft, primary: Colors.black87); + OutlinedButton.styleFrom(padding: EdgeInsets.all(16), + alignment: Alignment.centerLeft, primary: Colors.black87); final ButtonStyle _oauth2IconButtonStyle = - OutlinedButton.styleFrom(alignment: Alignment.center); + OutlinedButton.styleFrom(padding: EdgeInsets.all(16), + alignment: Alignment.center); final _isLoginNotifier = ValueNotifier(false); final _showPasswordNotifier = ValueNotifier(false); @@ -91,7 +91,7 @@ class _LoginPageState extends TbPageState { ), Container(height: 48), if (tbContext.hasOAuthClients) - _buildOAuth2Buttons(tbContext.oauth2Clients!), + _buildOAuth2Buttons(tbContext.oauth2ClientInfos!), if (tbContext.hasOAuthClients) Padding(padding: EdgeInsets.only(top: 10, bottom: 16), child: Row( @@ -308,27 +308,14 @@ class _LoginPageState extends TbPageState { void _oauth2ButtonPressed(OAuth2ClientInfo client) async { _isLoginNotifier.value = true; - var url = Uri.parse(ThingsboardAppConstants.thingsBoardApiEndpoint + client.url); - var params = Map.from(url.queryParameters); - params['pkg'] = tbContext.packageName; - url = url.replace(queryParameters: params); try { - final result = await FlutterWebAuth.authenticate( - url: url.toString(), - callbackUrlScheme: ThingsboardAppConstants.thingsboardOAuth2CallbackUrlScheme); - final resultUri = Uri.parse(result); - final error = resultUri.queryParameters['error']; - if (error != null) { - _isLoginNotifier.value = false; - showErrorNotification(error); + final result = await tbContext.oauth2Client.authenticate(client.url); + if (result.success) { + await tbClient.setUserFromJwtToken(result.accessToken, result.refreshToken, true); } else { - final accessToken = resultUri.queryParameters['accessToken']; - final refreshToken = resultUri.queryParameters['refreshToken']; - if (accessToken != null && refreshToken != null) { - await tbClient.setUserFromJwtToken(accessToken, refreshToken, true); - } + _isLoginNotifier.value = false; + showErrorNotification(result.error!); } - log.debug('result = $result'); } catch (e) { log.error('Auth Error:', e); _isLoginNotifier.value = false; diff --git a/lib/core/auth/oauth2/app_secret_provider.dart b/lib/core/auth/oauth2/app_secret_provider.dart new file mode 100644 index 0000000..5fd9f58 --- /dev/null +++ b/lib/core/auth/oauth2/app_secret_provider.dart @@ -0,0 +1,19 @@ +import 'package:thingsboard_app/constants/app_constants.dart'; + +abstract class AppSecretProvider { + + Future getAppSecret(); + + factory AppSecretProvider.local() => _LocalAppSecretProvider(); + +} + +/// Not for production (only for debugging) +class _LocalAppSecretProvider implements AppSecretProvider { + + @override + Future getAppSecret() async { + return ThingsboardAppConstants.thingsboardOAuth2AppSecret; + } + +} diff --git a/lib/core/auth/oauth2/tb_oauth2_client.dart b/lib/core/auth/oauth2/tb_oauth2_client.dart new file mode 100644 index 0000000..067a26e --- /dev/null +++ b/lib/core/auth/oauth2/tb_oauth2_client.dart @@ -0,0 +1,119 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter_web_auth/flutter_web_auth.dart'; +import 'package:thingsboard_app/constants/app_constants.dart'; +import 'package:thingsboard_app/core/context/tb_context.dart'; +import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart'; +import 'package:crypto/crypto.dart'; + +import 'app_secret_provider.dart'; + +class TbOAuth2AuthenticateResult { + String? accessToken; + String? refreshToken; + String? error; + + TbOAuth2AuthenticateResult.success(this.accessToken, this.refreshToken); + + TbOAuth2AuthenticateResult.failed(this.error); + + bool get success => error == null; + +} + +class TbOAuth2Client { + + final TbContext _tbContext; + final AppSecretProvider _appSecretProvider; + + TbOAuth2Client( + { required TbContext tbContext, + required AppSecretProvider appSecretProvider} ): + _tbContext = tbContext, + _appSecretProvider = appSecretProvider; + + Future authenticate(String oauth2Url) async { + final appSecret = await _appSecretProvider.getAppSecret(); + final pkgName = _tbContext.packageName; + final jwt = JWT( + { + 'callbackUrlScheme': ThingsboardAppConstants.thingsboardOAuth2CallbackUrlScheme + }, + issuer: pkgName, + ); + final key = SecretKey(appSecret); + final appToken = jwt.sign(key, algorithm: _HMACBase64Algorithm.HS512, expiresIn: Duration(minutes: 2)); + var url = Uri.parse(ThingsboardAppConstants.thingsBoardApiEndpoint + oauth2Url); + final params = Map.from(url.queryParameters); + params['pkg'] = pkgName; + params['appToken'] = appToken; + url = url.replace(queryParameters: params); + final result = await FlutterWebAuth.authenticate( + url: url.toString(), + callbackUrlScheme: ThingsboardAppConstants.thingsboardOAuth2CallbackUrlScheme); + final resultUri = Uri.parse(result); + final error = resultUri.queryParameters['error']; + if (error != null) { + return TbOAuth2AuthenticateResult.failed(error); + } else { + final accessToken = resultUri.queryParameters['accessToken']; + final refreshToken = resultUri.queryParameters['refreshToken']; + if (accessToken != null && refreshToken != null) { + return TbOAuth2AuthenticateResult.success(accessToken, refreshToken); + } else { + return TbOAuth2AuthenticateResult.failed('No authentication credentials in response.'); + } + } + } +} + +class _HMACBase64Algorithm extends JWTAlgorithm { + + static const HS512 = _HMACBase64Algorithm('HS512'); + + final String _name; + + const _HMACBase64Algorithm(this._name); + + @override + String get name => _name; + + @override + Uint8List sign(JWTKey key, Uint8List body) { + assert(key is SecretKey, 'key must be a SecretKey'); + final secretKey = key as SecretKey; + + final hmac = Hmac(_getHash(name), base64Decode(secretKey.key)); + + return Uint8List.fromList(hmac.convert(body).bytes); + } + + @override + bool verify(JWTKey key, Uint8List body, Uint8List signature) { + assert(key is SecretKey, 'key must be a SecretKey'); + + final actual = sign(key, body); + + if (actual.length != signature.length) return false; + + for (var i = 0; i < actual.length; i++) { + if (actual[i] != signature[i]) return false; + } + + return true; + } + + Hash _getHash(String name) { + switch (name) { + case 'HS256': + return sha256; + case 'HS384': + return sha384; + case 'HS512': + return sha512; + default: + throw ArgumentError.value(name, 'name', 'unknown hash name'); + } + } +} diff --git a/lib/core/context/tb_context.dart b/lib/core/context/tb_context.dart index 59c43ca..d8aec33 100644 --- a/lib/core/context/tb_context.dart +++ b/lib/core/context/tb_context.dart @@ -8,6 +8,8 @@ import 'package:flutter/material.dart'; import 'package:logger/logger.dart'; import 'package:package_info/package_info.dart'; import 'package:thingsboard_app/constants/app_constants.dart'; +import 'package:thingsboard_app/core/auth/oauth2/app_secret_provider.dart'; +import 'package:thingsboard_app/core/auth/oauth2/tb_oauth2_client.dart'; import 'package:thingsboard_app/modules/main/main_page.dart'; import 'package:thingsboard_app/utils/services/widget_action_handler.dart'; import 'package:thingsboard_client/thingsboard_client.dart'; @@ -108,7 +110,7 @@ class TbContext { bool isUserLoaded = false; final ValueNotifier _isAuthenticated = ValueNotifier(false); PlatformType? _oauth2PlatformType; - List? oauth2Clients; + List? oauth2ClientInfos; User? userDetails; HomeDashboardInfo? homeDashboard; final _isLoadingNotifier = ValueNotifier(false); @@ -120,7 +122,8 @@ class TbContext { TbMainDashboardHolder? _mainDashboardHolder; GlobalKey messengerKey = GlobalKey(); - late ThingsboardClient tbClient; + late final ThingsboardClient tbClient; + late final TbOAuth2Client oauth2Client; final FluroRouter router; final RouteObserver routeObserver = RouteObserver(); @@ -149,6 +152,9 @@ class TbContext { onLoadStarted: onLoadStarted, onLoadFinished: onLoadFinished, computeFunc: (callback, message) => compute(callback, message)); + + oauth2Client = TbOAuth2Client(tbContext: this, appSecretProvider: AppSecretProvider.local()); + try { if (Platform.isAndroid) { _androidInfo = await deviceInfoPlugin.androidInfo; @@ -261,7 +267,7 @@ class TbContext { } else { userDetails = null; homeDashboard = null; - oauth2Clients = await tbClient.getOAuth2Service().getOAuth2Clients(pkgName: packageName, platform: _oauth2PlatformType); + oauth2ClientInfos = await tbClient.getOAuth2Service().getOAuth2Clients(pkgName: packageName, platform: _oauth2PlatformType); } await updateRouteState(); @@ -274,7 +280,7 @@ class TbContext { bool get isAuthenticated => _isAuthenticated.value; - bool get hasOAuthClients => oauth2Clients != null && oauth2Clients!.isNotEmpty; + bool get hasOAuthClients => oauth2ClientInfos != null && oauth2ClientInfos!.isNotEmpty; Future updateRouteState() async { if (currentState != null) { diff --git a/pubspec.lock b/pubspec.lock index f0d4730..7752473 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -64,8 +64,15 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.15.0" - crypto: + convert: dependency: transitive + description: + name: convert + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.0" + crypto: + dependency: "direct main" description: name: crypto url: "https://pub.dartlang.org" @@ -78,6 +85,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.0.3" + dart_jsonwebtoken: + dependency: "direct main" + description: + name: dart_jsonwebtoken + url: "https://pub.dartlang.org" + source: hosted + version: "2.2.0" device_info: dependency: "direct main" description: @@ -359,6 +373,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.0.0" + pointycastle: + dependency: transitive + description: + name: pointycastle + url: "https://pub.dartlang.org" + source: hosted + version: "3.1.1" qr_code_scanner: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 469bb27..6e94ac8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -37,6 +37,8 @@ dependencies: material_design_icons_flutter: ^5.0.5955-rc.1 flutter_web_auth: ^0.3.0 package_info: ^2.0.2 + dart_jsonwebtoken: ^2.2.0 + crypto: ^3.0.1 dev_dependencies: flutter_test: