From bfa4f6b422c0b03793d4c5835903fa26b3efb237 Mon Sep 17 00:00:00 2001 From: Tovi Jaeschke-Rogers Date: Sun, 21 Aug 2022 15:29:10 +0930 Subject: [PATCH] Add friends through scanning qr code --- Backend/Api/Friends/Friends.go | 52 ++++++- Backend/Api/Routes.go | 3 +- Backend/Database/FriendRequests.go | 6 + mobile/android/app/build.gradle | 2 +- mobile/ios/Runner/Info.plist | 4 + mobile/lib/components/qr_reader.dart | 163 ++++++++++++++++++++ mobile/lib/models/friends.dart | 37 ++++- mobile/lib/utils/storage/conversations.dart | 4 +- mobile/lib/views/main/friend/list.dart | 7 +- mobile/pubspec.lock | 9 +- mobile/pubspec.yaml | 1 + mobile/test/pajamasenergy_qr_code.png | Bin 0 -> 13313 bytes mobile/test/qr_payload.json | 1 + 13 files changed, 279 insertions(+), 10 deletions(-) create mode 100644 mobile/lib/components/qr_reader.dart create mode 100644 mobile/test/pajamasenergy_qr_code.png create mode 100644 mobile/test/qr_payload.json diff --git a/Backend/Api/Friends/Friends.go b/Backend/Api/Friends/Friends.go index 07316af..3327e10 100644 --- a/Backend/Api/Friends/Friends.go +++ b/Backend/Api/Friends/Friends.go @@ -4,6 +4,7 @@ import ( "encoding/json" "io/ioutil" "net/http" + "time" "git.tovijaeschke.xyz/tovi/Envelope/Backend/Database" "git.tovijaeschke.xyz/tovi/Envelope/Backend/Models" @@ -20,19 +21,25 @@ func CreateFriendRequest(w http.ResponseWriter, r *http.Request) { requestBody, err = ioutil.ReadAll(r.Body) if err != nil { - panic(err) + http.Error(w, "Error", http.StatusInternalServerError) + w.WriteHeader(http.StatusInternalServerError) + return } err = json.Unmarshal(requestBody, &friendRequest) if err != nil { - panic(err) + http.Error(w, "Error", http.StatusInternalServerError) + w.WriteHeader(http.StatusInternalServerError) + return } friendRequest.AcceptedAt.Scan(nil) err = Database.CreateFriendRequest(&friendRequest) if err != nil { - panic(err) + http.Error(w, "Error", http.StatusInternalServerError) + w.WriteHeader(http.StatusInternalServerError) + return } returnJSON, err = json.MarshalIndent(friendRequest, "", " ") @@ -46,3 +53,42 @@ func CreateFriendRequest(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) w.Write(returnJSON) } + +// CreateFriendRequestQrCode creates a FriendRequest from post data from qr code scan +func CreateFriendRequestQrCode(w http.ResponseWriter, r *http.Request) { + var ( + friendRequests []Models.FriendRequest + requestBody []byte + i int + err error + ) + + requestBody, err = ioutil.ReadAll(r.Body) + if err != nil { + http.Error(w, "Error", http.StatusInternalServerError) + w.WriteHeader(http.StatusInternalServerError) + return + } + + err = json.Unmarshal(requestBody, &friendRequests) + if err != nil { + http.Error(w, "Error", http.StatusInternalServerError) + w.WriteHeader(http.StatusInternalServerError) + return + } + + for i = range friendRequests { + friendRequests[i].AcceptedAt.Time = time.Now() + friendRequests[i].AcceptedAt.Valid = true + } + + err = Database.CreateFriendRequests(&friendRequests) + if err != nil { + http.Error(w, "Error", http.StatusInternalServerError) + w.WriteHeader(http.StatusInternalServerError) + return + } + + // Return updated json + w.WriteHeader(http.StatusOK) +} diff --git a/Backend/Api/Routes.go b/Backend/Api/Routes.go index c9d76ee..50f4f01 100644 --- a/Backend/Api/Routes.go +++ b/Backend/Api/Routes.go @@ -65,12 +65,13 @@ func InitAPIEndpoints(router *mux.Router) { authAPI.HandleFunc("/friend_requests", Friends.EncryptedFriendRequestList).Methods("GET") authAPI.HandleFunc("/friend_request", Friends.CreateFriendRequest).Methods("POST") + authAPI.HandleFunc("/friend_request/qr_code", Friends.CreateFriendRequestQrCode).Methods("POST") authAPI.HandleFunc("/friend_request/{requestID}", Friends.AcceptFriendRequest).Methods("POST") authAPI.HandleFunc("/friend_request/{requestID}", Friends.RejectFriendRequest).Methods("DELETE") authAPI.HandleFunc("/conversations", Messages.EncryptedConversationList).Methods("GET") authAPI.HandleFunc("/conversation_details", Messages.EncryptedConversationDetailsList).Methods("GET") - authAPI.HandleFunc("/conversations", Messages.reateConversation).Methods("POST") + authAPI.HandleFunc("/conversations", Messages.CreateConversation).Methods("POST") authAPI.HandleFunc("/conversations", Messages.UpdateConversation).Methods("PUT") authAPI.HandleFunc("/message", Messages.CreateMessage).Methods("POST") diff --git a/Backend/Database/FriendRequests.go b/Backend/Database/FriendRequests.go index f6393d5..0f6e58a 100644 --- a/Backend/Database/FriendRequests.go +++ b/Backend/Database/FriendRequests.go @@ -42,6 +42,12 @@ func CreateFriendRequest(friendRequest *Models.FriendRequest) error { Error } +// CreateFriendRequests creates multiple friend requests +func CreateFriendRequests(friendRequest *[]Models.FriendRequest) error { + return DB.Create(friendRequest). + Error +} + // UpdateFriendRequest Updates friend request func UpdateFriendRequest(friendRequest *Models.FriendRequest) error { return DB.Where("id = ?", friendRequest.ID). diff --git a/mobile/android/app/build.gradle b/mobile/android/app/build.gradle index 5536a1b..df5e935 100644 --- a/mobile/android/app/build.gradle +++ b/mobile/android/app/build.gradle @@ -44,7 +44,7 @@ android { defaultConfig { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId "com.example.mobile" - minSdkVersion flutter.minSdkVersion + minSdkVersion 20 targetSdkVersion flutter.targetSdkVersion versionCode flutterVersionCode.toInteger() versionName flutterVersionName diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist index 7c170fc..d3ba628 100644 --- a/mobile/ios/Runner/Info.plist +++ b/mobile/ios/Runner/Info.plist @@ -43,5 +43,9 @@ UIViewControllerBasedStatusBarAppearance + io.flutter.embedded_views_preview + + NSCameraUsageDescription + This app needs camera access to scan QR codes diff --git a/mobile/lib/components/qr_reader.dart b/mobile/lib/components/qr_reader.dart new file mode 100644 index 0000000..1ff79ed --- /dev/null +++ b/mobile/lib/components/qr_reader.dart @@ -0,0 +1,163 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:Envelope/utils/storage/session_cookie.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:pointycastle/impl.dart'; +import 'package:qr_code_scanner/qr_code_scanner.dart'; +import 'package:sqflite/sqflite.dart'; +import 'package:uuid/uuid.dart'; +import 'package:http/http.dart' as http; + +import '/models/friends.dart'; +import '/models/my_profile.dart'; +import '/utils/encryption/aes_helper.dart'; +import '/utils/encryption/crypto_utils.dart'; +import '/utils/storage/database.dart'; +import '/utils/strings.dart'; +import 'flash_message.dart'; + +class QrReader extends StatefulWidget { + const QrReader({ + Key? key, + }) : super(key: key); + + @override + State createState() => _QrReaderState(); +} + +class _QrReaderState extends State { + final GlobalKey qrKey = GlobalKey(debugLabel: 'QR'); + Barcode? result; + QRViewController? controller; + + // In order to get hot reload to work we need to pause the camera if the platform + // is android, or resume the camera if the platform is iOS. + @override + void reassemble() { + super.reassemble(); + if (Platform.isAndroid) { + controller!.pauseCamera(); + } else if (Platform.isIOS) { + controller!.resumeCamera(); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Column( + children: [ + Expanded( + flex: 5, + child: QRView( + key: qrKey, + onQRViewCreated: _onQRViewCreated, + formatsAllowed: const [BarcodeFormat.qrcode], + overlay: QrScannerOverlayShape(), + ), + ), + ], + ), + ); + } + + void _onQRViewCreated(QRViewController controller) { + this.controller = controller; + controller.scannedDataStream.listen((scanData) { + addFriend(scanData) + .then((dynamic ret) { + if (ret) { + // Delay exit to prevent exit mid way through rendering + Future.delayed(Duration.zero, () { + Navigator.of(context).pop(); + }); + } + }); + }); + } + + @override + void dispose() { + controller?.dispose(); + super.dispose(); + } + + Future addFriend(Barcode scanData) async { + Map friendJson = jsonDecode(scanData.code!); + + RSAPublicKey publicKey = CryptoUtils.rsaPublicKeyFromPem( + String.fromCharCodes( + base64.decode( + friendJson['k'] + ) + ) + ); + + MyProfile profile = await MyProfile.getProfile(); + + var uuid = const Uuid(); + + final symmetricKey1 = AesHelper.deriveKey(generateRandomString(32)); + final symmetricKey2 = AesHelper.deriveKey(generateRandomString(32)); + + Friend request1 = Friend( + id: uuid.v4(), + userId: friendJson['i'], + username: profile.username, + friendId: profile.id, + friendSymmetricKey: base64.encode(symmetricKey1), + publicKey: profile.publicKey!, + acceptedAt: DateTime.now(), + ); + + Friend request2 = Friend( + id: uuid.v4(), + userId: profile.id, + friendId: friendJson['i'], + username: friendJson['u'], + friendSymmetricKey: base64.encode(symmetricKey2), + publicKey: publicKey, + acceptedAt: DateTime.now(), + ); + + String payload = jsonEncode([ + request1.payloadJson(), + request2.payloadJson(), + ]); + + var resp = await http.post( + Uri.parse('${dotenv.env["SERVER_URL"]}api/v1/auth/friend_request/qr_code'), + headers: { + 'Content-Type': 'application/json; charset=UTF-8', + 'cookie': await getSessionCookie(), + }, + body: payload, + ); + + if (resp.statusCode != 200) { + showMessage( + 'Failed to add friend, please try again later', + context + ); + return false; + } + + final db = await getDatabaseConnection(); + + await db.insert( + 'friends', + request1.toMap(), + conflictAlgorithm: ConflictAlgorithm.replace, + ); + + await db.insert( + 'friends', + request2.toMap(), + conflictAlgorithm: ConflictAlgorithm.replace, + ); + + return true; + } +} diff --git a/mobile/lib/models/friends.dart b/mobile/lib/models/friends.dart index 269a8ad..c435697 100644 --- a/mobile/lib/models/friends.dart +++ b/mobile/lib/models/friends.dart @@ -129,6 +129,41 @@ class Friend{ ); } + Map payloadJson() { + + Uint8List friendIdEncrypted = CryptoUtils.rsaEncrypt( + Uint8List.fromList(friendId.codeUnits), + publicKey, + ); + + Uint8List usernameEncrypted = CryptoUtils.rsaEncrypt( + Uint8List.fromList(username.codeUnits), + publicKey, + ); + + Uint8List symmetricKeyEncrypted = CryptoUtils.rsaEncrypt( + Uint8List.fromList( + base64.decode(friendSymmetricKey), + ), + publicKey, + ); + + var publicKeyEncrypted = AesHelper.aesEncrypt( + base64.decode(friendSymmetricKey), + Uint8List.fromList(CryptoUtils.encodeRSAPublicKeyToPem(publicKey).codeUnits), + ); + + return { + 'id': id, + 'user_id': userId, + 'friend_id': base64.encode(friendIdEncrypted), + 'friend_username': base64.encode(usernameEncrypted), + 'symmetric_key': base64.encode(symmetricKeyEncrypted), + 'asymmetric_public_key': publicKeyEncrypted, + 'accepted_at': null, + }; + } + String publicKeyPem() { return CryptoUtils.encodeRSAPublicKeyToPem(publicKey); } @@ -139,7 +174,7 @@ class Friend{ 'user_id': userId, 'username': username, 'friend_id': friendId, - 'symmetric_key': friendSymmetricKey, + 'symmetric_key': base64.encode(friendSymmetricKey.codeUnits), 'asymmetric_public_key': publicKeyPem(), 'accepted_at': acceptedAt?.toIso8601String(), }; diff --git a/mobile/lib/utils/storage/conversations.dart b/mobile/lib/utils/storage/conversations.dart index 9ed72e5..fc65477 100644 --- a/mobile/lib/utils/storage/conversations.dart +++ b/mobile/lib/utils/storage/conversations.dart @@ -149,7 +149,7 @@ Future uploadConversation(Conversation conversation, BuildContext context) Map conversationJson = await conversation.payloadJson(); - var x = await http.post( + var resp = await http.post( Uri.parse('${dotenv.env["SERVER_URL"]}api/v1/auth/conversations'), headers: { 'Content-Type': 'application/json; charset=UTF-8', @@ -158,7 +158,7 @@ Future uploadConversation(Conversation conversation, BuildContext context) body: jsonEncode(conversationJson), ); - if (x.statusCode != 200) { + if (resp.statusCode != 200) { showMessage('Failed to create conversation', context); } } diff --git a/mobile/lib/views/main/friend/list.dart b/mobile/lib/views/main/friend/list.dart index ded6225..8f19a61 100644 --- a/mobile/lib/views/main/friend/list.dart +++ b/mobile/lib/views/main/friend/list.dart @@ -1,4 +1,5 @@ import 'package:Envelope/components/custom_title_bar.dart'; +import 'package:Envelope/components/qr_reader.dart'; import 'package:Envelope/views/main/friend/add_search.dart'; import 'package:Envelope/views/main/friend/request_list_item.dart'; import 'package:flutter/material.dart'; @@ -74,7 +75,11 @@ class _FriendListState extends State { distance: 90.0, children: [ ActionButton( - onPressed: () {}, + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute(builder: (context) => const QrReader()) + );//.then(onGoBack); // TODO + }, icon: const Icon(Icons.qr_code_2, size: 25), ), ActionButton( diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index cfa6a60..917ab66 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -247,6 +247,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.1.0" + qr_code_scanner: + dependency: "direct main" + description: + name: qr_code_scanner + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.1" qr_flutter: dependency: "direct main" description: @@ -414,5 +421,5 @@ packages: source: hosted version: "0.2.0+1" sdks: - dart: ">=2.17.0-0 <3.0.0" + dart: ">=2.17.0 <3.0.0" flutter: ">=2.8.0" diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index ea62a83..6007c51 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -23,6 +23,7 @@ dependencies: intl: ^0.17.0 uuid: ^3.0.6 qr_flutter: ^4.0.0 + qr_code_scanner: ^1.0.1 dev_dependencies: flutter_test: diff --git a/mobile/test/pajamasenergy_qr_code.png b/mobile/test/pajamasenergy_qr_code.png new file mode 100644 index 0000000000000000000000000000000000000000..84584a2f65e7973fe574ea71c028f02da273c96f GIT binary patch literal 13313 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