Mobile client OAuth2 support
This commit is contained in:
@@ -1,4 +1,7 @@
|
|||||||
abstract class ThingsboardAppConstants {
|
abstract class ThingsboardAppConstants {
|
||||||
static final thingsBoardApiEndpoint = 'http://localhost:8080';
|
static final thingsBoardApiEndpoint = 'http://localhost:8080';
|
||||||
static final thingsboardOAuth2CallbackUrlScheme = 'org.thingsboard.app.auth';
|
static final thingsboardOAuth2CallbackUrlScheme = 'org.thingsboard.app.auth';
|
||||||
|
|
||||||
|
/// Not for production (only for debugging)
|
||||||
|
static final thingsboardOAuth2AppSecret = 'Your app secret here';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,7 @@ import 'package:flutter/cupertino.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:flutter_svg/flutter_svg.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: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/constants/assets_path.dart';
|
||||||
import 'package:thingsboard_app/core/context/tb_context.dart';
|
import 'package:thingsboard_app/core/context/tb_context.dart';
|
||||||
import 'package:thingsboard_app/core/context/tb_context_widget.dart';
|
import 'package:thingsboard_app/core/context/tb_context_widget.dart';
|
||||||
@@ -25,10 +23,12 @@ class LoginPage extends TbPageWidget<LoginPage, _LoginPageState> {
|
|||||||
class _LoginPageState extends TbPageState<LoginPage, _LoginPageState> {
|
class _LoginPageState extends TbPageState<LoginPage, _LoginPageState> {
|
||||||
|
|
||||||
final ButtonStyle _oauth2ButtonWithTextStyle =
|
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 =
|
final ButtonStyle _oauth2IconButtonStyle =
|
||||||
OutlinedButton.styleFrom(alignment: Alignment.center);
|
OutlinedButton.styleFrom(padding: EdgeInsets.all(16),
|
||||||
|
alignment: Alignment.center);
|
||||||
|
|
||||||
final _isLoginNotifier = ValueNotifier<bool>(false);
|
final _isLoginNotifier = ValueNotifier<bool>(false);
|
||||||
final _showPasswordNotifier = ValueNotifier<bool>(false);
|
final _showPasswordNotifier = ValueNotifier<bool>(false);
|
||||||
@@ -91,7 +91,7 @@ class _LoginPageState extends TbPageState<LoginPage, _LoginPageState> {
|
|||||||
),
|
),
|
||||||
Container(height: 48),
|
Container(height: 48),
|
||||||
if (tbContext.hasOAuthClients)
|
if (tbContext.hasOAuthClients)
|
||||||
_buildOAuth2Buttons(tbContext.oauth2Clients!),
|
_buildOAuth2Buttons(tbContext.oauth2ClientInfos!),
|
||||||
if (tbContext.hasOAuthClients)
|
if (tbContext.hasOAuthClients)
|
||||||
Padding(padding: EdgeInsets.only(top: 10, bottom: 16),
|
Padding(padding: EdgeInsets.only(top: 10, bottom: 16),
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -308,27 +308,14 @@ class _LoginPageState extends TbPageState<LoginPage, _LoginPageState> {
|
|||||||
|
|
||||||
void _oauth2ButtonPressed(OAuth2ClientInfo client) async {
|
void _oauth2ButtonPressed(OAuth2ClientInfo client) async {
|
||||||
_isLoginNotifier.value = true;
|
_isLoginNotifier.value = true;
|
||||||
var url = Uri.parse(ThingsboardAppConstants.thingsBoardApiEndpoint + client.url);
|
|
||||||
var params = Map<String,String>.from(url.queryParameters);
|
|
||||||
params['pkg'] = tbContext.packageName;
|
|
||||||
url = url.replace(queryParameters: params);
|
|
||||||
try {
|
try {
|
||||||
final result = await FlutterWebAuth.authenticate(
|
final result = await tbContext.oauth2Client.authenticate(client.url);
|
||||||
url: url.toString(),
|
if (result.success) {
|
||||||
callbackUrlScheme: ThingsboardAppConstants.thingsboardOAuth2CallbackUrlScheme);
|
await tbClient.setUserFromJwtToken(result.accessToken, result.refreshToken, true);
|
||||||
final resultUri = Uri.parse(result);
|
|
||||||
final error = resultUri.queryParameters['error'];
|
|
||||||
if (error != null) {
|
|
||||||
_isLoginNotifier.value = false;
|
|
||||||
showErrorNotification(error);
|
|
||||||
} else {
|
} else {
|
||||||
final accessToken = resultUri.queryParameters['accessToken'];
|
_isLoginNotifier.value = false;
|
||||||
final refreshToken = resultUri.queryParameters['refreshToken'];
|
showErrorNotification(result.error!);
|
||||||
if (accessToken != null && refreshToken != null) {
|
|
||||||
await tbClient.setUserFromJwtToken(accessToken, refreshToken, true);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
log.debug('result = $result');
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log.error('Auth Error:', e);
|
log.error('Auth Error:', e);
|
||||||
_isLoginNotifier.value = false;
|
_isLoginNotifier.value = false;
|
||||||
|
|||||||
19
lib/core/auth/oauth2/app_secret_provider.dart
Normal file
19
lib/core/auth/oauth2/app_secret_provider.dart
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import 'package:thingsboard_app/constants/app_constants.dart';
|
||||||
|
|
||||||
|
abstract class AppSecretProvider {
|
||||||
|
|
||||||
|
Future<String> getAppSecret();
|
||||||
|
|
||||||
|
factory AppSecretProvider.local() => _LocalAppSecretProvider();
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Not for production (only for debugging)
|
||||||
|
class _LocalAppSecretProvider implements AppSecretProvider {
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String> getAppSecret() async {
|
||||||
|
return ThingsboardAppConstants.thingsboardOAuth2AppSecret;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
119
lib/core/auth/oauth2/tb_oauth2_client.dart
Normal file
119
lib/core/auth/oauth2/tb_oauth2_client.dart
Normal file
@@ -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<TbOAuth2AuthenticateResult> 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<String,String>.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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,8 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:logger/logger.dart';
|
import 'package:logger/logger.dart';
|
||||||
import 'package:package_info/package_info.dart';
|
import 'package:package_info/package_info.dart';
|
||||||
import 'package:thingsboard_app/constants/app_constants.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/modules/main/main_page.dart';
|
||||||
import 'package:thingsboard_app/utils/services/widget_action_handler.dart';
|
import 'package:thingsboard_app/utils/services/widget_action_handler.dart';
|
||||||
import 'package:thingsboard_client/thingsboard_client.dart';
|
import 'package:thingsboard_client/thingsboard_client.dart';
|
||||||
@@ -108,7 +110,7 @@ class TbContext {
|
|||||||
bool isUserLoaded = false;
|
bool isUserLoaded = false;
|
||||||
final ValueNotifier<bool> _isAuthenticated = ValueNotifier(false);
|
final ValueNotifier<bool> _isAuthenticated = ValueNotifier(false);
|
||||||
PlatformType? _oauth2PlatformType;
|
PlatformType? _oauth2PlatformType;
|
||||||
List<OAuth2ClientInfo>? oauth2Clients;
|
List<OAuth2ClientInfo>? oauth2ClientInfos;
|
||||||
User? userDetails;
|
User? userDetails;
|
||||||
HomeDashboardInfo? homeDashboard;
|
HomeDashboardInfo? homeDashboard;
|
||||||
final _isLoadingNotifier = ValueNotifier<bool>(false);
|
final _isLoadingNotifier = ValueNotifier<bool>(false);
|
||||||
@@ -120,7 +122,8 @@ class TbContext {
|
|||||||
TbMainDashboardHolder? _mainDashboardHolder;
|
TbMainDashboardHolder? _mainDashboardHolder;
|
||||||
|
|
||||||
GlobalKey<ScaffoldMessengerState> messengerKey = GlobalKey<ScaffoldMessengerState>();
|
GlobalKey<ScaffoldMessengerState> messengerKey = GlobalKey<ScaffoldMessengerState>();
|
||||||
late ThingsboardClient tbClient;
|
late final ThingsboardClient tbClient;
|
||||||
|
late final TbOAuth2Client oauth2Client;
|
||||||
|
|
||||||
final FluroRouter router;
|
final FluroRouter router;
|
||||||
final RouteObserver<PageRoute> routeObserver = RouteObserver<PageRoute>();
|
final RouteObserver<PageRoute> routeObserver = RouteObserver<PageRoute>();
|
||||||
@@ -149,6 +152,9 @@ class TbContext {
|
|||||||
onLoadStarted: onLoadStarted,
|
onLoadStarted: onLoadStarted,
|
||||||
onLoadFinished: onLoadFinished,
|
onLoadFinished: onLoadFinished,
|
||||||
computeFunc: <Q, R>(callback, message) => compute(callback, message));
|
computeFunc: <Q, R>(callback, message) => compute(callback, message));
|
||||||
|
|
||||||
|
oauth2Client = TbOAuth2Client(tbContext: this, appSecretProvider: AppSecretProvider.local());
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (Platform.isAndroid) {
|
if (Platform.isAndroid) {
|
||||||
_androidInfo = await deviceInfoPlugin.androidInfo;
|
_androidInfo = await deviceInfoPlugin.androidInfo;
|
||||||
@@ -261,7 +267,7 @@ class TbContext {
|
|||||||
} else {
|
} else {
|
||||||
userDetails = null;
|
userDetails = null;
|
||||||
homeDashboard = null;
|
homeDashboard = null;
|
||||||
oauth2Clients = await tbClient.getOAuth2Service().getOAuth2Clients(pkgName: packageName, platform: _oauth2PlatformType);
|
oauth2ClientInfos = await tbClient.getOAuth2Service().getOAuth2Clients(pkgName: packageName, platform: _oauth2PlatformType);
|
||||||
}
|
}
|
||||||
await updateRouteState();
|
await updateRouteState();
|
||||||
|
|
||||||
@@ -274,7 +280,7 @@ class TbContext {
|
|||||||
|
|
||||||
bool get isAuthenticated => _isAuthenticated.value;
|
bool get isAuthenticated => _isAuthenticated.value;
|
||||||
|
|
||||||
bool get hasOAuthClients => oauth2Clients != null && oauth2Clients!.isNotEmpty;
|
bool get hasOAuthClients => oauth2ClientInfos != null && oauth2ClientInfos!.isNotEmpty;
|
||||||
|
|
||||||
Future<void> updateRouteState() async {
|
Future<void> updateRouteState() async {
|
||||||
if (currentState != null) {
|
if (currentState != null) {
|
||||||
|
|||||||
23
pubspec.lock
23
pubspec.lock
@@ -64,8 +64,15 @@ packages:
|
|||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.15.0"
|
version: "1.15.0"
|
||||||
crypto:
|
convert:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: convert
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.0"
|
||||||
|
crypto:
|
||||||
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: crypto
|
name: crypto
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
@@ -78,6 +85,13 @@ packages:
|
|||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.3"
|
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:
|
device_info:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -359,6 +373,13 @@ packages:
|
|||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.0"
|
version: "2.0.0"
|
||||||
|
pointycastle:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: pointycastle
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "3.1.1"
|
||||||
qr_code_scanner:
|
qr_code_scanner:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ dependencies:
|
|||||||
material_design_icons_flutter: ^5.0.5955-rc.1
|
material_design_icons_flutter: ^5.0.5955-rc.1
|
||||||
flutter_web_auth: ^0.3.0
|
flutter_web_auth: ^0.3.0
|
||||||
package_info: ^2.0.2
|
package_info: ^2.0.2
|
||||||
|
dart_jsonwebtoken: ^2.2.0
|
||||||
|
crypto: ^3.0.1
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
Reference in New Issue
Block a user