176 lines
9.0 KiB
C#
176 lines
9.0 KiB
C#
using System;
|
|
using System.Data;
|
|
using System.Data.SqlClient;
|
|
using Microsoft.AspNetCore.Cryptography.KeyDerivation;
|
|
|
|
namespace SecureCore.Authentication
|
|
{
|
|
public static class PasswordManager
|
|
{
|
|
private static string Pepper { get; set; }
|
|
private static KeyDerivationPrf KeyType { get; } = KeyDerivationPrf.HMACSHA512;
|
|
private static int KeySize { get; } = 512 / 8;
|
|
private static int SaltSize { get; } = 128 / 8; //128 bit salt
|
|
public static string SectionName { get; } = "PasswordSettings";
|
|
private static int MinPasswordLength { get; set; }
|
|
private static int MaxPasswordLength { get; set; }
|
|
private static int Iterations { get; set; }
|
|
|
|
//Application enforced absolutes that can not fall short of or be exceeded by the configuartion settings of this application.
|
|
private static int AbsoluteMinPasswordLength { get; } = 16;
|
|
private static int AbsoluteMaxPasswordLength { get; } = 128;
|
|
private static int AbsoluteMinPepperLength { get; } = 32;
|
|
private static int AbsoluteMinWorkFactor { get; set; } = 10000;
|
|
|
|
public static void InitializeSettings()
|
|
{
|
|
if (AppSettingsManager.TryGetSettingInt(SectionName, "MaxLength", out int maxPasswordLength))
|
|
{
|
|
//As noted in this article https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#maximum-password-lengths
|
|
//allowing passwords that are too long can result in a denial-of-service attack. So we must enforce an upper bound
|
|
//on the length of passwords. The recommended length is between 64 and 128, so if the length is set long than
|
|
//128 characters, just default to 128 as that's a safe upper limit.
|
|
if (maxPasswordLength > AbsoluteMaxPasswordLength || maxPasswordLength < AbsoluteMinPasswordLength) maxPasswordLength = AbsoluteMaxPasswordLength;
|
|
}
|
|
else maxPasswordLength = AbsoluteMaxPasswordLength;
|
|
|
|
if (AppSettingsManager.TryGetSettingInt(SectionName, "MinLength", out int minPasswordLength))
|
|
{
|
|
//There was no mention of a min password length in the above article, so I've chosen on a whim that 16 should
|
|
//be a safe enough min on a password's length. So as usual, just ignore settings that are out of bounds and
|
|
//instead stick to safe values that are in bounds.
|
|
if (minPasswordLength < AbsoluteMinPasswordLength || minPasswordLength > AbsoluteMaxPasswordLength) minPasswordLength = AbsoluteMinPasswordLength;
|
|
}
|
|
else minPasswordLength = AbsoluteMinPasswordLength;
|
|
// Validate the settings further.
|
|
if (minPasswordLength == maxPasswordLength || minPasswordLength > maxPasswordLength)
|
|
{
|
|
minPasswordLength = AbsoluteMinPasswordLength;
|
|
maxPasswordLength = AbsoluteMaxPasswordLength;
|
|
}
|
|
|
|
MinPasswordLength = minPasswordLength;
|
|
MaxPasswordLength = maxPasswordLength;
|
|
//Now read in the pepper. A pepper being a string of characters at least 32 characters long that is NOT stored in the database and is used in conjunction with hashing sensitive user data.
|
|
if (AppSettingsManager.TryGetSettingString(SectionName, "Pepper", out string pepper))
|
|
{
|
|
//As noted here https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html a pepper should be at least 32 bytes in size.
|
|
if (pepper.Length < AbsoluteMinPepperLength) throw new Exception("A pepper must be at least 32 characters long for security reasons.");
|
|
}
|
|
else
|
|
throw new Exception($"A pepper, generated by a cryptographically secure random number generator, that is at least 32 characters long, must be supplied in the appsettings.json file (Key path: {SectionName}.Pepper).");
|
|
|
|
Pepper = pepper;
|
|
//Finally, the work factor (A.K.A. iterations) for the hashing algorithm.
|
|
if (AppSettingsManager.TryGetSettingInt(SectionName, "Iterations", out int iterations))
|
|
{
|
|
//The work factor must be of a certain strength and if it fails this check then we will be forced to ignore it and use the recommended work factor
|
|
//as stated here: https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#pbkdf2
|
|
//As stated in the above link, 10,000 iterations is the lowest we should ever go. So for security's sake, that's the lower bounds that will be allowed.
|
|
if (iterations < AbsoluteMinWorkFactor) iterations = AbsoluteMinWorkFactor;
|
|
}
|
|
//By default, we'll go with the highest security setting if one isn't provided by an admin.
|
|
//To quote the above link:
|
|
//"The work factor for PBKDF2 is implemented through the iteration count, which should be at least 10,000
|
|
//(although values of up to 100,000 may be appropriate in higher security environments)."
|
|
else iterations = 100000;
|
|
|
|
Iterations = iterations;
|
|
}
|
|
|
|
public static (string Hash, string Salt) HashPassword(string password)
|
|
{
|
|
var salt = new byte[SaltSize];
|
|
|
|
ByteGenerator.GetRandomBytes(ref salt);
|
|
|
|
return (GetHash(password, salt), Convert.ToBase64String(salt));
|
|
}
|
|
|
|
public static (bool IsValid, string Message) IsPasswordValid(string password)
|
|
{
|
|
if (string.IsNullOrEmpty(password)) return (false, "No password has been supplied, and thus is not valid.");
|
|
if (password.Length < MinPasswordLength) return (false, $"Your password is too short, it must be at least {MinPasswordLength} characters long and not exceed {MaxPasswordLength} characters.");
|
|
if (password.Length > MaxPasswordLength) return (false, $"Your password is too long, it must not exceed {MaxPasswordLength} characters and must contain at least {MinPasswordLength} characters.");
|
|
|
|
return (true, string.Empty);
|
|
}
|
|
|
|
public static bool IsPasswordAMatch(string password, string salt, string passwordHash)
|
|
{
|
|
var saltBytes = Convert.FromBase64String(salt);
|
|
|
|
return passwordHash == GetHash(password, saltBytes);
|
|
}
|
|
|
|
public static void InsertPasswordResetRequest(string email, string sessionToken, DateTime expirationDate, string userAgent, string ipAddress, string connectionString)
|
|
{
|
|
using var connection = new SqlConnection(connectionString);
|
|
using var command = new SqlCommand("LogPasswordResetRequest", connection) { CommandType = CommandType.StoredProcedure };
|
|
|
|
command.Parameters.AddWithValue("SessionToken", sessionToken);
|
|
command.Parameters.AddWithValue("ExpirationDate", expirationDate);
|
|
command.Parameters.AddWithValue("Email", email);
|
|
command.Parameters.AddWithValue("UserAgent", userAgent);
|
|
command.Parameters.AddWithValue("IpAddress", ipAddress);
|
|
|
|
connection.Open();
|
|
|
|
command.ExecuteNonQuery();
|
|
}
|
|
|
|
public static void ResetPassword(string passwordHash, string salt, string sessionToken, string connectionString)
|
|
{
|
|
using var connection = new SqlConnection(connectionString);
|
|
using var command = new SqlCommand("ResetPassword", connection) { CommandType = CommandType.StoredProcedure };
|
|
|
|
command.Parameters.AddWithValue("PasswordHash", passwordHash);
|
|
command.Parameters.AddWithValue("Salt", salt);
|
|
command.Parameters.AddWithValue("SessionToken", sessionToken);
|
|
|
|
connection.Open();
|
|
|
|
command.ExecuteNonQuery();
|
|
}
|
|
|
|
public static (string PasswordHash, string Salt) GetPasswordHashAndSalt(string username, string connectionString)
|
|
{
|
|
using var connection = new SqlConnection(connectionString);
|
|
using var command = new SqlCommand("SELECT [Password Hash], [Salt] FROM dbo.GetUserPasswordHashAndSalt(@Username)", connection);
|
|
|
|
command.Parameters.AddWithValue("Username", username);
|
|
|
|
connection.Open();
|
|
|
|
var reader = command.ExecuteReader();
|
|
|
|
reader.Read();
|
|
|
|
if (!reader.HasRows) return (string.Empty, string.Empty);
|
|
|
|
return (reader["Password Hash"].ToString(), reader["Salt"].ToString());
|
|
}
|
|
|
|
public static string HashStringData(string data, string salt = "")
|
|
{
|
|
_ = Array.Empty<byte>();
|
|
byte[] saltBytes;
|
|
|
|
if (!string.IsNullOrEmpty(salt))
|
|
saltBytes = Convert.FromBase64String(salt);
|
|
else
|
|
{
|
|
saltBytes = new byte[SaltSize];
|
|
ByteGenerator.GetRandomBytes(ref saltBytes);
|
|
}
|
|
|
|
return GetHash(data, saltBytes);
|
|
}
|
|
|
|
private static string GetHash(string password, byte[] salt)
|
|
{
|
|
return Convert.ToBase64String(KeyDerivation.Pbkdf2($"{password}{Pepper}", salt, KeyType, Iterations, KeySize));
|
|
}
|
|
}
|
|
}
|