314 lines
10 KiB
Dart
314 lines
10 KiB
Dart
import 'dart:convert';
|
|
import 'dart:io';
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:crypto/crypto.dart';
|
|
import 'package:dio/dio.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:freezer/api/definitions.dart';
|
|
import 'package:freezer/settings.dart';
|
|
import 'package:freezer/ui/login_screen.dart';
|
|
import 'package:freezer/utils.dart';
|
|
import 'package:pointycastle/export.dart' as pc;
|
|
import 'package:pointycastle/src/platform_check/platform_check.dart';
|
|
import 'package:encrypt/encrypt.dart' as encrypt;
|
|
import 'package:freezer/translations.i18n.dart';
|
|
|
|
class LoginOnOtherDevice extends StatefulWidget {
|
|
const LoginOnOtherDevice({super.key});
|
|
|
|
@override
|
|
State<LoginOnOtherDevice> createState() => _LoginOnOtherDeviceState();
|
|
}
|
|
|
|
class _LoginOnOtherDeviceState extends State<LoginOnOtherDevice> {
|
|
String _ipAddress = '';
|
|
String _code = '';
|
|
String? _error;
|
|
bool _loading = false;
|
|
bool _step2 = false;
|
|
final GlobalKey<FormState> _formKey = GlobalKey();
|
|
|
|
late final encrypt.Key key;
|
|
|
|
void _doHandshake() async {
|
|
if (!_formKey.currentState!.validate()) return;
|
|
|
|
setState(() {
|
|
_loading = true;
|
|
_error = null;
|
|
});
|
|
|
|
// generate keypair
|
|
final keyGen = pc.RSAKeyGenerator();
|
|
final secureRandom = pc.SecureRandom('Fortuna')
|
|
..seed(pc.KeyParameter(
|
|
Platform.instance.platformEntropySource().getBytes(32)));
|
|
|
|
keyGen.init(pc.ParametersWithRandom(
|
|
pc.RSAKeyGeneratorParameters(BigInt.parse('65537'), 2048, 64),
|
|
secureRandom));
|
|
|
|
final keyPair = keyGen.generateKeyPair();
|
|
final privKey = keyPair.privateKey as pc.RSAPrivateKey;
|
|
final pubKey = keyPair.publicKey as pc.RSAPublicKey;
|
|
|
|
// initial handshake
|
|
final hash = Hmac(sha512, Utils.splitDigits(int.parse(_code)))
|
|
.convert(Utils.serializeBigInt(pubKey.exponent! ^ pubKey.modulus!))
|
|
.toString();
|
|
late final Response res;
|
|
try {
|
|
res = await Dio().post('http://$_ipAddress/',
|
|
data: jsonEncode({
|
|
'_': 'handshake',
|
|
'pubKey': [
|
|
base64.encode(Utils.serializeBigInt(pubKey.modulus!)),
|
|
base64.encode(Utils.serializeBigInt(pubKey.exponent!))
|
|
],
|
|
'hash': hash,
|
|
}),
|
|
options: Options(responseType: ResponseType.json));
|
|
} on DioException catch (e) {
|
|
if (e.type == DioExceptionType.badResponse) {
|
|
if (e.response?.statusCode == 403) {
|
|
setState(() {
|
|
_error = 'Wrong code';
|
|
_loading = false;
|
|
});
|
|
return;
|
|
}
|
|
final data = e.response?.data as Map?;
|
|
if (data != null && data['message'] is String) {
|
|
setState(() {
|
|
_error = 'Request failed: ${data['message']}';
|
|
_loading = false;
|
|
});
|
|
|
|
return;
|
|
}
|
|
setState(() {
|
|
_error = 'Request failed: $e';
|
|
_loading = false;
|
|
});
|
|
|
|
return;
|
|
}
|
|
}
|
|
|
|
print(res);
|
|
final data = res.data as Map;
|
|
if (!data['ok']) {
|
|
setState(() {
|
|
_error = data['message'] as String? ?? 'Unknown server error';
|
|
_loading = false;
|
|
});
|
|
|
|
return;
|
|
}
|
|
|
|
final encryptedKey = data['key'] as String;
|
|
final encrypter = encrypt.Encrypter(encrypt.RSA(privateKey: privKey));
|
|
key = encrypt.Key(Uint8List.fromList(
|
|
encrypter.decryptBytes(encrypt.Encrypted.fromBase64(encryptedKey))));
|
|
|
|
setState(() {
|
|
_loading = false;
|
|
_step2 = true;
|
|
});
|
|
}
|
|
|
|
void _loginWithArl(String arl) async {
|
|
setState(() {
|
|
_loading = true;
|
|
});
|
|
|
|
final encrypter = encrypt.Encrypter(
|
|
encrypt.AES(key),
|
|
);
|
|
final iv = encrypt.IV.fromSecureRandom(16);
|
|
final encryptedARL = encrypter.encrypt(arl, iv: iv);
|
|
|
|
// send it over with dio
|
|
late final Response res;
|
|
try {
|
|
res = await Dio().post('http://$_ipAddress/',
|
|
data: jsonEncode({
|
|
'_': 'arl',
|
|
'arl': encryptedARL.base64,
|
|
'iv': iv.base64,
|
|
}),
|
|
options: Options(responseType: ResponseType.json));
|
|
} on DioException catch (e) {
|
|
if (e.type == DioExceptionType.badResponse) {
|
|
final data = e.response?.data as Map?;
|
|
if (data != null && data['message'] is String) {
|
|
setState(() {
|
|
_error = 'Request failed: ${data['message']}';
|
|
_loading = false;
|
|
});
|
|
}
|
|
}
|
|
setState(() {
|
|
_error = 'Request failed: $e';
|
|
_loading = false;
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (!res.data['ok']) {
|
|
setState(() {
|
|
_error = res.data['message'] as String? ?? 'Unknown server error.';
|
|
_loading = false;
|
|
});
|
|
return;
|
|
}
|
|
|
|
Navigator.pop(context);
|
|
ScaffoldMessenger.of(context).snack('Logged in successfully!'.i18n);
|
|
}
|
|
|
|
@override
|
|
void dispose() async {
|
|
if (_step2) {
|
|
final hash =
|
|
Hmac(sha512, key.bytes).convert(utf8.encode(_code)).toString();
|
|
await Dio().post('http://$_ipAddress/',
|
|
data: jsonEncode({'_': 'cancel', 'hash': hash}));
|
|
}
|
|
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return AlertDialog(
|
|
title: Text('Login on other device'.i18n),
|
|
content: _step2
|
|
? (_loading
|
|
? const CircularProgressIndicator()
|
|
: Column(mainAxisSize: MainAxisSize.min, children: [
|
|
if (_error != null)
|
|
Text(_error!, style: const TextStyle(color: Colors.red)),
|
|
OutlinedButton(
|
|
onPressed: () => _loginWithArl(settings.arl!),
|
|
child: Text('Login with current ARL (one-click)'.i18n)),
|
|
const SizedBox(height: 16.0),
|
|
Text('Or, login with external ARL'.i18n),
|
|
const SizedBox(height: 16.0),
|
|
if (LoginBrowser.supported()) ...[
|
|
OutlinedButton(
|
|
onPressed: () {
|
|
Navigator.of(context).pushRoute(
|
|
builder: (context) =>
|
|
LoginBrowser(_loginWithArl));
|
|
},
|
|
child: Text('Login using browser'.i18n)),
|
|
const SizedBox(height: 16.0),
|
|
],
|
|
OutlinedButton(
|
|
onPressed: () {
|
|
String arl = '';
|
|
final key = GlobalKey<FormFieldState<String>>();
|
|
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) {
|
|
submit(String arl) {
|
|
if (!key.currentState!.validate()) return;
|
|
Navigator.pop(context);
|
|
_loginWithArl(arl);
|
|
}
|
|
|
|
return AlertDialog(
|
|
title: Text('Enter ARL'.i18n),
|
|
content: TextFormField(
|
|
key: key,
|
|
validator: (value) =>
|
|
value?.trim().length == 192
|
|
? null
|
|
: 'Invalid ARL length!'.i18n,
|
|
onChanged: (value) => arl = value,
|
|
decoration: InputDecoration(
|
|
labelText: 'Token (ARL)'.i18n),
|
|
onFieldSubmitted: submit,
|
|
),
|
|
actions: <Widget>[
|
|
TextButton(
|
|
child: Text('Save'.i18n),
|
|
onPressed: () => submit(arl),
|
|
)
|
|
],
|
|
);
|
|
});
|
|
},
|
|
child: Text('Login with ARL'.i18n)),
|
|
]))
|
|
: Form(
|
|
key: _formKey,
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
if (_error != null)
|
|
Text(_error!, style: const TextStyle(color: Colors.red)),
|
|
TextFormField(
|
|
validator: (value) {
|
|
value ??= '';
|
|
final p = value.split(':');
|
|
if (p.length != 2) {
|
|
return 'Invalid IP and Port'.i18n;
|
|
}
|
|
|
|
final ip = p[0];
|
|
final port = int.tryParse(p[1]);
|
|
if (port == null || port > 65535) {
|
|
return 'Invalid port'.i18n;
|
|
}
|
|
|
|
final ipParts = ip.split('.');
|
|
if (ipParts.length != 4) {
|
|
return 'Invalid IP'.i18n;
|
|
}
|
|
|
|
for (final part in ipParts) {
|
|
final a = int.tryParse(part);
|
|
if (a == null || a < 0 || a > 255) {
|
|
return 'Invalid IP'.i18n;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
},
|
|
onChanged: (value) => _ipAddress = value,
|
|
decoration:
|
|
InputDecoration(label: Text('IP Address'.i18n)),
|
|
),
|
|
TextFormField(
|
|
validator: (value) {
|
|
value ??= '';
|
|
if (value.length != 6 || int.tryParse(value) == null) {
|
|
return 'Invalid code'.i18n;
|
|
}
|
|
|
|
return null;
|
|
},
|
|
onChanged: (value) => _code = value,
|
|
decoration: InputDecoration(label: Text('Code'.i18n)),
|
|
)
|
|
]),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: Text('Cancel'.i18n)),
|
|
if (!_step2)
|
|
TextButton(
|
|
onPressed: _loading ? null : _doHandshake,
|
|
child: Text('Login'.i18n),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|