using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using SecureBank.API.Helpers; using SecureBank.API.Authentication; using SecureBank.Authentication; using SecureBank.Common; using SecureBank.Common.Accounts; using SecureBank.Database; using SecureBank.Extensions; using System; using System.Collections.Generic; using System.Linq; using System.Net.Mail; using System.Runtime.InteropServices; using System.Runtime.Intrinsics.Arm; using System.Security.Cryptography; using System.Text; using System.Threading.Tasks; using SecureBank.API.Encryption; using Microsoft.AspNetCore.Mvc; using System.Security.Claims; using Microsoft.Identity.Client; namespace SecureBank.API.Services { public interface IAccountsService { Task> CreateAccount(CreateAccountRequest data); Task> GetPasswordVariant(int accountId); Task> Authentication(AuthenticationRequest data); Task> AuthenticationRefresh(Claims claims); Task ChangePassword(Claims claims, ChangePasswordRequest data); Task>> GetAccounts(string? iban, int? id, Claims claims); Task ResetPassword(int accountId); Task UnlockAccount(int accountId); } public class AccountsService : IAccountsService { #region SERVICES private AuthenticationHelper _authenticationHelper; private EncryptionHelper _encryptionHelper; private DatabaseContext _database; private ILogger _logger; #endregion #region CONSTRUCTORS public AccountsService(AuthenticationHelper authenticationHelper, EncryptionHelper encryptionHelper, DatabaseContext database, ILogger logger) { _authenticationHelper = authenticationHelper; _encryptionHelper = encryptionHelper; _database = database; _logger = logger; } #endregion #region PUBLIC METHODS public async Task> CreateAccount(CreateAccountRequest data) { Check[] checks = new Check[] { new Check { CheckAction = new Predicate((x) => x is null), Message = "Body cannot be empty" }, new Check { CheckAction = new Predicate((x) => string.IsNullOrWhiteSpace(x.FirstName)), Message = "First name cannot be empty" }, new Check { CheckAction = new Predicate((x) => string.IsNullOrWhiteSpace(x.LastName)), Message = "Last name cannot be empty" }, new Check { CheckAction = new Predicate((x) => string.IsNullOrWhiteSpace(x.Email)), Message = "Email cannot be empty" }, new Check { CheckAction = new Predicate((x) => { try { MailAddress m = new MailAddress(x.Email); } catch (FormatException ex) { return true; } return false; }), Message = "Invalid email" }, new Check { CheckAction = new Predicate((x) => string.IsNullOrWhiteSpace(x.PhoneNumber)), Message = "Phone number cannot be empty" }, new Check { CheckAction = new Predicate((x) => string.IsNullOrWhiteSpace(x.Address)), Message = "Address cannot be empty" }, new Check { CheckAction = new Predicate((x) => string.IsNullOrWhiteSpace(x.PESEL)), Message = "PESEL cannot be empty" }, new Check { CheckAction = new Predicate((x) => x.PESEL.Length != 11), Message = "PESEL must be 11 charaters long" }, new Check { CheckAction = new Predicate((x) => string.IsNullOrWhiteSpace(x.IdCardNumber)), Message = "Id card number cannot be empty" }, new Check { CheckAction = new Predicate((x) => x.IdCardNumber.Length != 9), Message = "Id card number must be 9 characters long" }, }; foreach (Check check in checks) { if (check.CheckAction.Invoke(data)) { return new APIResponse { Message = check.Message, Status = ResponseStatus.BadRequest, }; } } byte[] pesel = _encryptionHelper.Encrypt(data.PESEL); byte[] idCardNumber = _encryptionHelper.Encrypt(data.IdCardNumber); byte[] cardCVV = _encryptionHelper.Encrypt(StringExtensions.CreateRandom(3, "1234567890")); byte[] cardExpirationDate = _encryptionHelper.Encrypt(DateTime.Now.AddYears(5).ToString("MM/yy")); Account account = new Account { FirstName = data.FirstName, LastName = data.LastName, Email = data.Email, PhoneNumber = data.PhoneNumber.Replace(" ", string.Empty), Address = data.Address, PESEL = pesel, IdCardNumber = idCardNumber, IBAN = string.Empty, CardNumber = new byte[0], CardCVV = cardCVV, CardExpirationDate = cardExpirationDate }; await _database.Accounts.AddAsync(account); await _database.SaveChangesAsync(); string ibanGen = $"549745{StringExtensions.CreateRandom(12, "1234567890")}{account.Id:00000000}"; string cardNumberGen = $"49{StringExtensions.CreateRandom(6, "1234567890")}{account.Id:00000000}"; byte[] cardNumber = _encryptionHelper.Encrypt(cardNumberGen); account.IBAN = ibanGen; account.CardNumber = cardNumber; await _database.SaveChangesAsync(); string password = GeneratePassword(); await GeneratePasswordVariants(password, account.Id); //Send client code and temporary password to client by mail _logger.LogInformation($"INFO DIRECTLY TO CLIENT: Your client code is {account.Id:00000000}. Your temporary password is {password}. You will be prompted to change it at first login"); return new APIResponse { Data = account.Id }; } public async Task> GetPasswordVariant(int accountId) { Account? account = await _database.Accounts.FirstOrDefaultAsync(x => x.Id == accountId); if (account is null) { return new APIResponse { Status = ResponseStatus.BadRequest, Message = $"Account does not exists" }; } if (account.LoginFailedCount >= 3) { return new APIResponse { Status = ResponseStatus.BadRequest, Message = $"The number of failed login attempts for this account has exceeded 3. Contact your bank to confirm your identity and unlock your account." }; } if (account.LockReason is not null) { return new APIResponse { Status = ResponseStatus.BadRequest, Message = $"Account is locked. Contact your bank to confirm your identity and unlock your account." }; } IEnumerable accountPasswords = await _database.AccountPasswords.Where(x => x.AccountId == accountId).ToArrayAsync(); int randomIndex = Random.Shared.Next(0, accountPasswords.Count()); AccountPassword passwordVariant = accountPasswords.ElementAt(randomIndex); AccountPasswordIndex[] indexes = await _database.AccountPasswordIndexes.Where(x => x.AccountPasswordId == passwordVariant.Id).ToArrayAsync(); DateTime validTo = DateTime.Now.AddMinutes(5); AccountLoginRequest loginRequest = new AccountLoginRequest { AccountPasswordId = passwordVariant.Id, ValidTo = validTo, }; await _database.AccountLoginRequests.AddAsync(loginRequest); await _database.SaveChangesAsync(); return new APIResponse { Data = new GetPasswordVariantResponse { LoginRequestId = loginRequest.Id, Indexes = indexes.Select(x => (int)x.Index).ToArray(), ValidTo = validTo } }; } public async Task> Authentication(AuthenticationRequest data) { AccountLoginRequest? loginRequest = await _database.AccountLoginRequests.FirstOrDefaultAsync(x => x.Id == data.LoginRequestId); if (loginRequest is null) { return new APIResponse { Status = ResponseStatus.BadRequest, Message = $"Login request does not exist" }; } AccountPassword password = loginRequest.AccountPassword; Account loginRequestAccount = password.Account; APIResponse? accountCheck = CheckAccount(loginRequestAccount); if (accountCheck is not null) { return accountCheck; } if (loginRequest.ValidTo < DateTime.Now) { return new APIResponse { Status = ResponseStatus.BadRequest, ActionCode = 1, Message = $"Login request has expired. Go back and try again." }; } byte[] passwordDb = password.Password; byte[] passwordProvided = HashPassword(data.Password, password.LeftSalt, password.RightSalt); if (!Enumerable.SequenceEqual(passwordDb, passwordProvided)) { loginRequestAccount.LoginFailedCount++; await _database.SaveChangesAsync(); return new APIResponse { Status = ResponseStatus.BadRequest, Message = $"Incorrect password" }; } loginRequestAccount.LoginFailedCount = 0; await _database.SaveChangesAsync(); string token = _authenticationHelper.GenerateToken(Guid.NewGuid(), loginRequestAccount, loginRequestAccount.TemporaryPassword); return new APIResponse { ActionCode = loginRequestAccount.TemporaryPassword ? 2 : 0, Data = token }; } public async Task> AuthenticationRefresh(Claims claims) { if (claims.IsOneTimeToken) { return new APIResponse { Status = ResponseStatus.BadRequest, Message = $"One time token cannot be refreshed." }; } Account? account = await _database.Accounts.FirstOrDefaultAsync(x => x.Id == claims.AccountId); APIResponse? accountCheck = CheckAccount(account); if (accountCheck is not null) { return accountCheck; } string token = _authenticationHelper.GenerateToken(Guid.NewGuid(), account, false); return new APIResponse { Data = token }; } public async Task ChangePassword(Claims claims, ChangePasswordRequest data) { string password = data.Password; Account? account = await _database.Accounts.FirstOrDefaultAsync(x => x.Id == claims.AccountId); if (account is null) { return new APIResponse { Status = ResponseStatus.BadRequest, Message = $"Account does not exists" }; } IEnumerable passwordChecks = CheckPassword(password); if (passwordChecks.Any()) { StringBuilder sb = new StringBuilder(); sb.AppendLine("Provided password does not meet the security requirements:"); foreach (string check in passwordChecks) { sb.AppendLine(check); } return new APIResponse { Status = ResponseStatus.BadRequest, Message = sb.ToString() }; } IEnumerable indexes = await _database.AccountPasswordIndexes.Where(x => x.AccountPassword.AccountId == claims.AccountId).ToListAsync(); _database.AccountPasswordIndexes.AttachRange(indexes); _database.AccountPasswordIndexes.RemoveRange(indexes); await _database.SaveChangesAsync(); IEnumerable variants = await _database.AccountPasswords.Where(x => x.AccountId == claims.AccountId).ToListAsync(); _database.AccountPasswords.AttachRange(variants); _database.AccountPasswords.RemoveRange(variants); await _database.SaveChangesAsync(); await GeneratePasswordVariants(password, claims.AccountId); account.TemporaryPassword = false; await _database.SaveChangesAsync(); return new APIResponse(); } public async Task>> GetAccounts(string? iban, int? id, Claims claims) { IEnumerable accounts = await _database.Accounts.ToListAsync(); if (id is not null) { accounts = accounts.Where(x => x.Id == id); } if (iban is not null) { accounts = accounts.Where(x => x.IBAN == iban); } if (accounts.Any(x => x.Id != claims.AccountId) && !claims.IsAdmin) { return new APIResponse> { Status = ResponseStatus.Unauthorized, Message = $"You don't have permission to get information about accounts that aren't yours" }; } List data = new List(); foreach (Account account in accounts) { data.Add(new AccountResponse { Id = account.Id, FirstName = account.FirstName, LastName = account.LastName, Email = account.Email, PhoneNumber = account.PhoneNumber, Address = account.Address, PESEL = _encryptionHelper.Decrypt(account.PESEL), IdCardNumber = _encryptionHelper.Decrypt(account.IdCardNumber), IBAN = account.IBAN, CardNumber = _encryptionHelper.Decrypt(account.CardNumber), CardExpirationDate = _encryptionHelper.Decrypt(account.CardExpirationDate), CardCVV = _encryptionHelper.Decrypt(account.CardCVV), IsAdmin = account.IsAdmin, LoginFailedCount = account.LoginFailedCount, TemporaryPassword = account.TemporaryPassword, LockReason = account.LockReason, }); } return new APIResponse> { Data = data }; } public async Task ResetPassword(int accountId) { Account? account = await _database.Accounts.FirstOrDefaultAsync(x => x.Id == accountId); if (account is null) { return new APIResponse { Status = ResponseStatus.BadRequest, Message = $"Account does not exists" }; } await PasswordReset(account); return new APIResponse { Data = account.Id }; } public async Task UnlockAccount(int accountId) { Account? account = await _database.Accounts.FirstOrDefaultAsync(x => x.Id == accountId); if (account is null) { return new APIResponse { Status = ResponseStatus.BadRequest, Message = $"Account does not exists" }; } await PasswordReset(account); account.LockReason = null; account.LoginFailedCount = 0; await _database.SaveChangesAsync(); return new APIResponse { Data = account.Id }; } #endregion #region PRIVATE METHODS protected async Task PasswordReset(Account account) { IEnumerable indexes = await _database.AccountPasswordIndexes.Where(x => x.AccountPassword.AccountId == account.Id).ToListAsync(); _database.AccountPasswordIndexes.AttachRange(indexes); _database.AccountPasswordIndexes.RemoveRange(indexes); await _database.SaveChangesAsync(); IEnumerable variants = await _database.AccountPasswords.Where(x => x.AccountId == account.Id).ToListAsync(); _database.AccountPasswords.AttachRange(variants); _database.AccountPasswords.RemoveRange(variants); await _database.SaveChangesAsync(); string password = GeneratePassword(); await GeneratePasswordVariants(password, account.Id); account.TemporaryPassword = true; await _database.SaveChangesAsync(); _logger.LogInformation($"INFO DIRECTLY TO CLIENT: Your new temporary password is {password}. You will be prompted to change it at first login"); } protected APIResponse? CheckAccount(Account? account) { if (account is null) { return new APIResponse { Status = ResponseStatus.BadRequest, Message = $"Account does not exists." }; } if (account.LockReason is not null) { return new APIResponse { Status = ResponseStatus.BadRequest, Message = $"Account is locked. Contact your bank to confirm your identity and unlock your account." }; } if (account.LoginFailedCount >= 3) { return new APIResponse { Status = ResponseStatus.BadRequest, Message = $"The number of failed login attempts for this account has exceeded 3. Contact your bank to confirm your identity and unlock your account." }; } return null; } protected byte[] HashPassword(string password, string leftSalt, string rightSalt) { SHA512 sha = SHA512.Create(); string toHash = password; for (int c = 0; c < 100; c++) { string before = $"{leftSalt}{toHash}{rightSalt}"; byte[] bytes = sha.ComputeHash(Encoding.UTF8.GetBytes(before)); toHash = Encoding.UTF8.GetString(bytes); } return Encoding.UTF8.GetBytes(toHash); } protected async Task GeneratePasswordVariants(string password, int accountId) { int charCount = password.Length / 2; IEnumerable charIndexes = Enumerable.Range(0, password.Length); IEnumerable> indexesVariants = charIndexes.GetCombinations(charCount).OrderBy(x => Random.Shared.Next()).Take(50); foreach (IEnumerable indexes in indexesVariants) { List chars = new List(); foreach (int i in indexes) { chars.Add(password[i]); } string leftSalt = StringExtensions.CreateRandom(20); string rightSalt = StringExtensions.CreateRandom(20); string toHash = string.Join(string.Empty, chars); byte[] hashed = HashPassword(toHash, leftSalt, rightSalt); AccountPassword accountPassword = new AccountPassword { AccountId = accountId, LeftSalt = leftSalt, RightSalt = rightSalt, Password = hashed, }; await _database.AccountPasswords.AddAsync(accountPassword); await _database.SaveChangesAsync(); IEnumerable indexesDB = indexes.Select(x => new AccountPasswordIndex { AccountPasswordId = accountPassword.Id, Index = (byte)x }); await _database.AccountPasswordIndexes.AddRangeAsync(indexesDB); await _database.SaveChangesAsync(); } } protected string GeneratePassword() { string passwordDigits = StringExtensions.CreateRandom(2, "1234567890"); string passwordSymbols = StringExtensions.CreateRandom(2, "`~!@#$%^&*()-_=+[{]};:'\"\\|,<.>/?"); string passwordSmall = StringExtensions.CreateRandom(2, "qwertyuiopasdfghjklzxcvbnm"); string passwordBig = StringExtensions.CreateRandom(2, "QWERTYUIOPASDFGHJKLZXCVBNM"); return string.Concat(passwordDigits, passwordSymbols, passwordSmall, passwordBig).Shuffle(); } protected IEnumerable CheckPassword(string password) { int minLength = 8; uint maxLength = 20; if (password.Length < minLength) { yield return $"Password must be at least {minLength} characters long"; } if (password.Length > maxLength) { yield return $"Password cannot be longer than {maxLength} characters"; } if (!password.Any(x => Char.IsUpper(x))) { yield return $"Password must contain at least one uppercase character"; } if (!password.Any(x => Char.IsLower(x))) { yield return $"Password must contain at least one lowercase character"; } if (!password.Any(x => Char.IsDigit(x))) { yield return $"Password must contain at least one digit"; } if (!password.Any(x => !Char.IsDigit(x) && !Char.IsUpper(x) && !Char.IsLower(x))) { yield return $"Password must contain at least one special character"; } } #endregion } }