diff --git a/Backend/Api/Auth/AddProfileImage.go b/Backend/Api/Auth/AddProfileImage.go new file mode 100644 index 0000000..31c7f64 --- /dev/null +++ b/Backend/Api/Auth/AddProfileImage.go @@ -0,0 +1,50 @@ +package Auth + +import ( + "encoding/base64" + "encoding/json" + "net/http" + + "git.tovijaeschke.xyz/tovi/Envelope/Backend/Database" + "git.tovijaeschke.xyz/tovi/Envelope/Backend/Models" + "git.tovijaeschke.xyz/tovi/Envelope/Backend/Util" +) + +// AddProfileImage adds a profile image +func AddProfileImage(w http.ResponseWriter, r *http.Request) { + var ( + user Models.User + attachment Models.Attachment + decodedFile []byte + fileName string + err error + ) + + // Ignore error here, as middleware should handle auth + user, _ = CheckCookieCurrentUser(w, r) + + err = json.NewDecoder(r.Body).Decode(&attachment) + if err != nil { + http.Error(w, "Error", http.StatusInternalServerError) + return + } + + if attachment.Data == "" { + http.Error(w, "Error", http.StatusInternalServerError) + return + } + + decodedFile, err = base64.StdEncoding.DecodeString(attachment.Data) + fileName, err = Util.WriteFile(decodedFile) + attachment.FilePath = fileName + + user.Attachment = attachment + + err = Database.UpdateUser(user.ID.String(), &user) + if err != nil { + http.Error(w, "Error", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/Backend/Api/Auth/ChangeMessageExpiry.go b/Backend/Api/Auth/ChangeMessageExpiry.go index 8f8721f..acad218 100644 --- a/Backend/Api/Auth/ChangeMessageExpiry.go +++ b/Backend/Api/Auth/ChangeMessageExpiry.go @@ -10,7 +10,7 @@ import ( ) type rawChangeMessageExpiry struct { - MessageExpiry string `json:"message_exipry"` + MessageExpiry string `json:"message_expiry"` } // ChangeMessageExpiry handles changing default message expiry for user @@ -37,7 +37,7 @@ func ChangeMessageExpiry(w http.ResponseWriter, r *http.Request) { return } - user.AsymmetricPrivateKey = changeMessageExpiry.MessageExpiry + user.MessageExpiryDefault.Scan(changeMessageExpiry.MessageExpiry) err = Database.UpdateUser( user.ID.String(), diff --git a/Backend/Api/Auth/Login.go b/Backend/Api/Auth/Login.go index 61225af..eb8b516 100644 --- a/Backend/Api/Auth/Login.go +++ b/Backend/Api/Auth/Login.go @@ -3,6 +3,7 @@ package Auth import ( "database/sql/driver" "encoding/json" + "fmt" "net/http" "time" @@ -16,73 +17,43 @@ type credentials struct { } type loginResponse struct { - Status string `json:"status"` - Message string `json:"message"` - AsymmetricPublicKey string `json:"asymmetric_public_key"` - AsymmetricPrivateKey string `json:"asymmetric_private_key"` UserID string `json:"user_id"` Username string `json:"username"` + AsymmetricPublicKey string `json:"asymmetric_public_key"` + AsymmetricPrivateKey string `json:"asymmetric_private_key"` + SymmetricKey string `json:"symmetric_key"` MessageExpiryDefault string `json:"message_expiry_default"` + ImageLink string `json:"image_link"` } -func makeLoginResponse(w http.ResponseWriter, code int, message, pubKey, privKey string, user Models.User) { +// Login logs the user into the system +func Login(w http.ResponseWriter, r *http.Request) { var ( - status = "error" + creds credentials + user Models.User + session Models.Session + expiresAt time.Time messageExpiryRaw driver.Value messageExpiry string + imageLink string returnJSON []byte err error ) - if code >= 200 && code <= 300 { - status = "success" - } - - messageExpiryRaw, _ = user.MessageExpiryDefault.Value() - messageExpiry, _ = messageExpiryRaw.(string) - - returnJSON, err = json.MarshalIndent(loginResponse{ - Status: status, - Message: message, - AsymmetricPublicKey: pubKey, - AsymmetricPrivateKey: privKey, - UserID: user.ID.String(), - Username: user.Username, - MessageExpiryDefault: messageExpiry, - }, "", " ") - if err != nil { - http.Error(w, "Error", http.StatusInternalServerError) - return - } - - // Return updated json - w.WriteHeader(code) - w.Write(returnJSON) -} - -// Login logs the user into the system -func Login(w http.ResponseWriter, r *http.Request) { - var ( - creds credentials - userData Models.User - session Models.Session - expiresAt time.Time - err error - ) err = json.NewDecoder(r.Body).Decode(&creds) if err != nil { - makeLoginResponse(w, http.StatusInternalServerError, "An error occurred", "", "", userData) + http.Error(w, "Unauthorized", http.StatusUnauthorized) return } - userData, err = Database.GetUserByUsername(creds.Username) + user, err = Database.GetUserByUsername(creds.Username) if err != nil { - makeLoginResponse(w, http.StatusUnauthorized, "An error occurred", "", "", userData) + http.Error(w, "Unauthorized", http.StatusUnauthorized) return } - if !CheckPasswordHash(creds.Password, userData.Password) { - makeLoginResponse(w, http.StatusUnauthorized, "An error occurred", "", "", userData) + if !CheckPasswordHash(creds.Password, user.Password) { + http.Error(w, "Unauthorized", http.StatusUnauthorized) return } @@ -90,13 +61,13 @@ func Login(w http.ResponseWriter, r *http.Request) { expiresAt = time.Now().Add(12 * time.Hour) session = Models.Session{ - UserID: userData.ID, + UserID: user.ID, Expiry: expiresAt, } err = Database.CreateSession(&session) if err != nil { - makeLoginResponse(w, http.StatusUnauthorized, "An error occurred", "", "", userData) + http.Error(w, "Unauthorized", http.StatusUnauthorized) return } @@ -106,12 +77,32 @@ func Login(w http.ResponseWriter, r *http.Request) { Expires: expiresAt, }) - makeLoginResponse( - w, - http.StatusOK, - "Successfully logged in", - userData.AsymmetricPublicKey, - userData.AsymmetricPrivateKey, - userData, - ) + if user.AttachmentID != nil { + imageLink = fmt.Sprintf( + "http://192.168.1.5:8080/files/%s", + user.Attachment.FilePath, + ) + } + + messageExpiryRaw, _ = user.MessageExpiryDefault.Value() + messageExpiry, _ = messageExpiryRaw.(string) + + returnJSON, err = json.MarshalIndent(loginResponse{ + UserID: user.ID.String(), + Username: user.Username, + AsymmetricPublicKey: user.AsymmetricPublicKey, + AsymmetricPrivateKey: user.AsymmetricPrivateKey, + SymmetricKey: user.SymmetricKey, + MessageExpiryDefault: messageExpiry, + ImageLink: imageLink, + }, "", " ") + + if err != nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + // Return updated json + w.WriteHeader(http.StatusOK) + w.Write(returnJSON) } diff --git a/Backend/Api/Messages/Conversations.go b/Backend/Api/Messages/Conversations.go index dde7583..4e7b0cc 100644 --- a/Backend/Api/Messages/Conversations.go +++ b/Backend/Api/Messages/Conversations.go @@ -65,7 +65,6 @@ func EncryptedConversationDetailsList(w http.ResponseWriter, r *http.Request) { return } - // TODO: Fix error handling here conversationIds = strings.Split(conversationIds[0], ",") conversationDetails, err = Database.GetConversationDetailsByIds( diff --git a/Backend/Api/Routes.go b/Backend/Api/Routes.go index 90f0ed8..8b0c280 100644 --- a/Backend/Api/Routes.go +++ b/Backend/Api/Routes.go @@ -64,6 +64,7 @@ func InitAPIEndpoints(router *mux.Router) { authAPI.HandleFunc("/change_password", Auth.ChangePassword).Methods("POST") authAPI.HandleFunc("/message_expiry", Auth.ChangeMessageExpiry).Methods("POST") + authAPI.HandleFunc("/image", Auth.AddProfileImage).Methods("POST") authAPI.HandleFunc("/users", Users.SearchUsers).Methods("GET") diff --git a/Backend/Database/Seeder/UserSeeder.go b/Backend/Database/Seeder/UserSeeder.go index ce13b2a..c65a94e 100644 --- a/Backend/Database/Seeder/UserSeeder.go +++ b/Backend/Database/Seeder/UserSeeder.go @@ -1,6 +1,8 @@ package Seeder import ( + "encoding/base64" + "git.tovijaeschke.xyz/tovi/Envelope/Backend/Api/Auth" "git.tovijaeschke.xyz/tovi/Envelope/Backend/Database" "git.tovijaeschke.xyz/tovi/Envelope/Backend/Models" @@ -23,10 +25,16 @@ var userNames = []string{ func createUser(username string) (Models.User, error) { var ( userData Models.User + userKey aesKey password string err error ) + userKey, err = generateAesKey() + if err != nil { + panic(err) + } + password, err = Auth.HashPassword("password") if err != nil { return Models.User{}, err @@ -37,12 +45,16 @@ func createUser(username string) (Models.User, error) { Password: password, AsymmetricPrivateKey: encryptedPrivateKey, AsymmetricPublicKey: publicKey, + SymmetricKey: base64.StdEncoding.EncodeToString( + encryptWithPublicKey(userKey.Key, decodedPublicKey), + ), } err = Database.CreateUser(&userData) return userData, err } +// SeedUsers used to create dummy users for testing & development func SeedUsers() { var ( i int diff --git a/Backend/Models/Users.go b/Backend/Models/Users.go index 685b774..811c3ab 100644 --- a/Backend/Models/Users.go +++ b/Backend/Models/Users.go @@ -3,6 +3,7 @@ package Models import ( "database/sql/driver" + "github.com/gofrs/uuid" "gorm.io/gorm" ) @@ -58,6 +59,17 @@ type User struct { ConfirmPassword string `gorm:"-" json:"confirm_password"` AsymmetricPrivateKey string `gorm:"not null" json:"asymmetric_private_key"` // Stored encrypted AsymmetricPublicKey string `gorm:"not null" json:"asymmetric_public_key"` - MessageExpiryDefault MessageExpiry `gorm:"default:no_expiry" json:"-" sql:"type:ENUM('fifteen_min', 'thirty_min', 'one_hour', 'three_hour', 'six_hour', 'twelve_hour', 'one_day', 'three_day')"` // Stored encrypted - + SymmetricKey string `gorm:"not null" json:"symmetric_key"` // Stored encrypted + AttachmentID *uuid.UUID ` json:"attachment_id"` + Attachment Attachment ` json:"attachment"` + MessageExpiryDefault MessageExpiry `gorm:"default:no_expiry" json:"-" sql:"type:ENUM( + 'fifteen_min', + 'thirty_min', + 'one_hour', + 'three_hour', + 'six_hour', + 'twelve_hour', + 'one_day', + 'three_day' + )"` // Stored encrypted } diff --git a/mobile/lib/models/my_profile.dart b/mobile/lib/models/my_profile.dart index 526e668..9db8655 100644 --- a/mobile/lib/models/my_profile.dart +++ b/mobile/lib/models/my_profile.dart @@ -1,6 +1,8 @@ import 'dart:convert'; +import 'dart:io'; -import 'package:Envelope/components/select_message_ttl.dart'; +import 'package:Envelope/utils/storage/get_file.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:pointycastle/impl.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -17,7 +19,9 @@ class MyProfile { String? friendId; RSAPrivateKey? privateKey; RSAPublicKey? publicKey; + String? symmetricKey; DateTime? loggedInAt; + File? image; String messageExpiryDefault = 'no_expiry'; MyProfile({ @@ -26,7 +30,9 @@ class MyProfile { this.friendId, this.privateKey, this.publicKey, + this.symmetricKey, this.loggedInAt, + this.image, required this.messageExpiryDefault, }); @@ -44,8 +50,10 @@ class MyProfile { username: json['username'], privateKey: privateKey, publicKey: publicKey, + symmetricKey: json['symmetric_key'], loggedInAt: loggedInAt, - messageExpiryDefault: json['message_expiry_default'] + messageExpiryDefault: json['message_expiry_default'], + image: json['file'] != null ? File(json['file']) : null, ); } @@ -57,7 +65,7 @@ class MyProfile { logged_in_at: $loggedInAt public_key: $publicKey private_key: $privateKey - '''; + '''; } String toJson() { @@ -70,8 +78,10 @@ class MyProfile { 'asymmetric_public_key': publicKey != null ? CryptoUtils.encodeRSAPublicKeyToPem(publicKey!) : null, + 'symmetric_key': symmetricKey, 'logged_in_at': loggedInAt?.toIso8601String(), 'message_expiry_default': messageExpiryDefault, + 'file': image?.path, }); } @@ -80,7 +90,24 @@ class MyProfile { password, base64.decode(json['asymmetric_private_key']) ); + + json['symmetric_key'] = base64.encode(CryptoUtils.rsaDecrypt( + base64.decode(json['symmetric_key']), + CryptoUtils.rsaPrivateKeyFromPem(json['asymmetric_private_key']), + )); + + if (json['image_link'] != '') { + File profileIcon = await getFile( + json['image_link'], + json['user_id'], + json['symmetric_key'], + ); + + json['file'] = profileIcon.path; + } + MyProfile profile = MyProfile._fromJson(json); + final preferences = await SharedPreferences.getInstance(); preferences.setString('profile', profile.toJson()); return profile; diff --git a/mobile/lib/views/authentication/login.dart b/mobile/lib/views/authentication/login.dart index dd8e869..6dce978 100644 --- a/mobile/lib/views/authentication/login.dart +++ b/mobile/lib/views/authentication/login.dart @@ -8,30 +8,30 @@ import '/models/my_profile.dart'; import '/utils/storage/session_cookie.dart'; class LoginResponse { - final String status; - final String message; - final String publicKey; - final String privateKey; final String userId; final String username; + final String publicKey; + final String privateKey; + final String symmetricKey; + final String? imageLink; const LoginResponse({ - required this.status, - required this.message, required this.publicKey, required this.privateKey, + required this.symmetricKey, required this.userId, required this.username, + this.imageLink, }); factory LoginResponse.fromJson(Map json) { return LoginResponse( - status: json['status'], - message: json['message'], - publicKey: json['asymmetric_public_key'], - privateKey: json['asymmetric_private_key'], userId: json['user_id'], username: json['username'], + publicKey: json['asymmetric_public_key'], + privateKey: json['asymmetric_private_key'], + symmetricKey: json['symmetric_key'], + imageLink: json['image_link'], ); } } @@ -175,6 +175,7 @@ class _LoginWidgetState extends State { ModalRoute.withName('/home'), ); }).catchError((error) { + print(error); showMessage( 'Could not login to Envelope, please try again later.', context, diff --git a/mobile/lib/views/main/conversation/message.dart b/mobile/lib/views/main/conversation/message.dart index ee98aa5..5bdc982 100644 --- a/mobile/lib/views/main/conversation/message.dart +++ b/mobile/lib/views/main/conversation/message.dart @@ -150,8 +150,6 @@ class _ConversationMessageState extends State { if (delta == null) { return; } - - print(delta); }); } diff --git a/mobile/lib/views/main/profile/profile.dart b/mobile/lib/views/main/profile/profile.dart index 45cfa68..b40f4a0 100644 --- a/mobile/lib/views/main/profile/profile.dart +++ b/mobile/lib/views/main/profile/profile.dart @@ -1,10 +1,18 @@ import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; +import 'package:Envelope/components/file_picker.dart'; import 'package:Envelope/components/flash_message.dart'; +import 'package:Envelope/utils/encryption/aes_helper.dart'; import 'package:Envelope/utils/storage/session_cookie.dart'; +import 'package:Envelope/utils/storage/write_file.dart'; import 'package:flutter/material.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:mime/mime.dart'; import 'package:qr_flutter/qr_flutter.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'package:sliding_up_panel/sliding_up_panel.dart'; import 'package:http/http.dart' as http; @@ -31,6 +39,8 @@ class Profile extends StatefulWidget { class _ProfileState extends State { final PanelController _panelController = PanelController(); + bool showFileSelector = false; + @override Widget build(BuildContext context) { return Scaffold( @@ -63,7 +73,8 @@ class _ProfileState extends State { child: Column( children: [ usernameHeading(), - const SizedBox(height: 30), + fileSelector(), + SizedBox(height: showFileSelector ? 10 : 30), settings(), const SizedBox(height: 30), logout(), @@ -77,11 +88,20 @@ class _ProfileState extends State { Widget usernameHeading() { return Row( children: [ - const CustomCircleAvatar( - icon: Icon(Icons.person, size: 40), + + CustomCircleAvatar( + image: widget.profile.image, + icon: const Icon(Icons.person, size: 40), radius: 30, + editImageCallback: () { + setState(() { + showFileSelector = true; + }); + }, ), + const SizedBox(width: 20), + Expanded( flex: 1, child: Text( @@ -92,6 +112,7 @@ class _ProfileState extends State { ), ), ), + IconButton( onPressed: () => _panelController.open(), icon: const Icon(Icons.qr_code_2), @@ -100,6 +121,59 @@ class _ProfileState extends State { ); } + Widget fileSelector() { + if (!showFileSelector) { + return const SizedBox.shrink(); + } + + return Padding( + padding: const EdgeInsets.only(top: 10), + child: FilePicker( + cameraHandle: _setProfileImage, + galleryHandleSingle: _setProfileImage, + ) + ); + } + + Future _setProfileImage(XFile image) async { + widget.profile.image = await writeImage( + widget.profile.id, + File(image.path).readAsBytesSync(), + ); + + setState(() { + showFileSelector = false; + }); + + saveProfile(); + + Map payload = { + 'data': AesHelper.aesEncrypt( + widget.profile.symmetricKey!, + Uint8List.fromList(widget.profile.image!.readAsBytesSync()) + ), + 'mimetype': lookupMimeType(widget.profile.image!.path), + 'extension': getExtension(widget.profile.image!.path), + }; + + http.post( + await MyProfile.getServerUrl('api/v1/auth/image'), + headers: { + 'cookie': await getSessionCookie(), + }, + body: jsonEncode(payload), + ).then((http.Response response) { + if (response.statusCode == 204) { + return; + } + + showMessage( + 'Could not change your default message expiry, please try again later.', + context, + ); + }); + } + Widget logout() { bool isTesting = dotenv.env['ENVIRONMENT'] == 'development'; @@ -190,6 +264,8 @@ class _ProfileState extends State { context, ); }); + + saveProfile(); }, )) ); @@ -241,6 +317,7 @@ class _ProfileState extends State { privateKey: widget.profile.privateKey!, )) ); + saveProfile(); } ), ], @@ -281,4 +358,9 @@ class _ProfileState extends State { ] ); } + + Future saveProfile() async { + final preferences = await SharedPreferences.getInstance(); + preferences.setString('profile', widget.profile.toJson()); + } }