Merge pull request #870 from LogicalPhallacy/betterauth

Better default authentication
This commit is contained in:
Bond-009 2019-03-07 19:11:36 +01:00 committed by GitHub
commit ae0ecc1b10
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 460 additions and 45 deletions

View File

@ -1,13 +1,49 @@
using System; using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO; using System.IO;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text; using System.Text;
using System.Linq;
using MediaBrowser.Model.Cryptography; using MediaBrowser.Model.Cryptography;
namespace Emby.Server.Implementations.Cryptography namespace Emby.Server.Implementations.Cryptography
{ {
public class CryptographyProvider : ICryptoProvider public class CryptographyProvider : ICryptoProvider
{ {
private static readonly HashSet<string> _supportedHashMethods = new HashSet<string>()
{
"MD5",
"System.Security.Cryptography.MD5",
"SHA",
"SHA1",
"System.Security.Cryptography.SHA1",
"SHA256",
"SHA-256",
"System.Security.Cryptography.SHA256",
"SHA384",
"SHA-384",
"System.Security.Cryptography.SHA384",
"SHA512",
"SHA-512",
"System.Security.Cryptography.SHA512"
};
public string DefaultHashMethod => "PBKDF2";
private RandomNumberGenerator _randomNumberGenerator;
private const int _defaultIterations = 1000;
public CryptographyProvider()
{
//FIXME: When we get DotNet Standard 2.1 we need to revisit how we do the crypto
//Currently supported hash methods from https://docs.microsoft.com/en-us/dotnet/api/system.security.cryptography.cryptoconfig?view=netcore-2.1
//there might be a better way to autogenerate this list as dotnet updates, but I couldn't find one
//Please note the default method of PBKDF2 is not included, it cannot be used to generate hashes cleanly as it is actually a pbkdf with sha1
_randomNumberGenerator = RandomNumberGenerator.Create();
}
public Guid GetMD5(string str) public Guid GetMD5(string str)
{ {
return new Guid(ComputeMD5(Encoding.Unicode.GetBytes(str))); return new Guid(ComputeMD5(Encoding.Unicode.GetBytes(str)));
@ -36,5 +72,98 @@ namespace Emby.Server.Implementations.Cryptography
return provider.ComputeHash(bytes); return provider.ComputeHash(bytes);
} }
} }
public IEnumerable<string> GetSupportedHashMethods()
{
return _supportedHashMethods;
}
private byte[] PBKDF2(string method, byte[] bytes, byte[] salt, int iterations)
{
//downgrading for now as we need this library to be dotnetstandard compliant
//with this downgrade we'll add a check to make sure we're on the downgrade method at the moment
if (method == DefaultHashMethod)
{
using (var r = new Rfc2898DeriveBytes(bytes, salt, iterations))
{
return r.GetBytes(32);
}
}
throw new CryptographicException($"Cannot currently use PBKDF2 with requested hash method: {method}");
}
public byte[] ComputeHash(string hashMethod, byte[] bytes)
{
return ComputeHash(hashMethod, bytes, Array.Empty<byte>());
}
public byte[] ComputeHashWithDefaultMethod(byte[] bytes)
{
return ComputeHash(DefaultHashMethod, bytes);
}
public byte[] ComputeHash(string hashMethod, byte[] bytes, byte[] salt)
{
if (hashMethod == DefaultHashMethod)
{
return PBKDF2(hashMethod, bytes, salt, _defaultIterations);
}
else if (_supportedHashMethods.Contains(hashMethod))
{
using (var h = HashAlgorithm.Create(hashMethod))
{
if (salt.Length == 0)
{
return h.ComputeHash(bytes);
}
else
{
byte[] salted = new byte[bytes.Length + salt.Length];
Array.Copy(bytes, salted, bytes.Length);
Array.Copy(salt, 0, salted, bytes.Length, salt.Length);
return h.ComputeHash(salted);
}
}
}
else
{
throw new CryptographicException($"Requested hash method is not supported: {hashMethod}");
}
}
public byte[] ComputeHashWithDefaultMethod(byte[] bytes, byte[] salt)
{
return PBKDF2(DefaultHashMethod, bytes, salt, _defaultIterations);
}
public byte[] ComputeHash(PasswordHash hash)
{
int iterations = _defaultIterations;
if (!hash.Parameters.ContainsKey("iterations"))
{
hash.Parameters.Add("iterations", _defaultIterations.ToString(CultureInfo.InvariantCulture));
}
else
{
try
{
iterations = int.Parse(hash.Parameters["iterations"]);
}
catch (Exception e)
{
throw new InvalidDataException($"Couldn't successfully parse iterations value from string: {hash.Parameters["iterations"]}", e);
}
}
return PBKDF2(hash.Id, hash.HashBytes, hash.SaltBytes, iterations);
}
public byte[] GenerateSalt()
{
byte[] salt = new byte[64];
_randomNumberGenerator.GetBytes(salt);
return salt;
}
} }
} }

View File

@ -55,6 +55,8 @@ namespace Emby.Server.Implementations.Data
{ {
TryMigrateToLocalUsersTable(connection); TryMigrateToLocalUsersTable(connection);
} }
RemoveEmptyPasswordHashes();
} }
} }
@ -73,6 +75,38 @@ namespace Emby.Server.Implementations.Data
} }
} }
private void RemoveEmptyPasswordHashes()
{
foreach (var user in RetrieveAllUsers())
{
// If the user password is the sha1 hash of the empty string, remove it
if (!string.Equals(user.Password, "DA39A3EE5E6B4B0D3255BFEF95601890AFD80709", StringComparison.Ordinal)
|| !string.Equals(user.Password, "$SHA1$DA39A3EE5E6B4B0D3255BFEF95601890AFD80709", StringComparison.Ordinal))
{
continue;
}
user.Password = null;
var serialized = _jsonSerializer.SerializeToBytes(user);
using (WriteLock.Write())
using (var connection = CreateConnection())
{
connection.RunInTransaction(db =>
{
using (var statement = db.PrepareStatement("update LocalUsersv2 set data=@data where Id=@InternalId"))
{
statement.TryBind("@InternalId", user.InternalId);
statement.TryBind("@data", serialized);
statement.MoveNext();
}
}, TransactionMode);
}
}
}
/// <summary> /// <summary>
/// Save a user in the repo /// Save a user in the repo
/// </summary> /// </summary>

View File

@ -1,4 +1,5 @@
using System; using System;
using System.Linq;
using System.Text; using System.Text;
using System.Threading.Tasks; using System.Threading.Tasks;
using MediaBrowser.Controller.Authentication; using MediaBrowser.Controller.Authentication;
@ -18,20 +19,64 @@ namespace Emby.Server.Implementations.Library
public string Name => "Default"; public string Name => "Default";
public bool IsEnabled => true; public bool IsEnabled => true;
// This is dumb and an artifact of the backwards way auth providers were designed.
// This version of authenticate was never meant to be called, but needs to be here for interface compat
// Only the providers that don't provide local user support use this
public Task<ProviderAuthenticationResult> Authenticate(string username, string password) public Task<ProviderAuthenticationResult> Authenticate(string username, string password)
{ {
throw new NotImplementedException(); throw new NotImplementedException();
} }
// This is the verson that we need to use for local users. Because reasons.
public Task<ProviderAuthenticationResult> Authenticate(string username, string password, User resolvedUser) public Task<ProviderAuthenticationResult> Authenticate(string username, string password, User resolvedUser)
{ {
bool success = false;
if (resolvedUser == null) if (resolvedUser == null)
{ {
throw new Exception("Invalid username or password"); throw new Exception("Invalid username or password");
} }
var success = string.Equals(GetPasswordHash(resolvedUser), GetHashedString(resolvedUser, password), StringComparison.OrdinalIgnoreCase); // As long as jellyfin supports passwordless users, we need this little block here to accomodate
if (IsPasswordEmpty(resolvedUser, password))
{
return Task.FromResult(new ProviderAuthenticationResult
{
Username = username
});
}
ConvertPasswordFormat(resolvedUser);
byte[] passwordbytes = Encoding.UTF8.GetBytes(password);
PasswordHash readyHash = new PasswordHash(resolvedUser.Password);
byte[] calculatedHash;
string calculatedHashString;
if (_cryptographyProvider.GetSupportedHashMethods().Contains(readyHash.Id))
{
if (string.IsNullOrEmpty(readyHash.Salt))
{
calculatedHash = _cryptographyProvider.ComputeHash(readyHash.Id, passwordbytes);
calculatedHashString = BitConverter.ToString(calculatedHash).Replace("-", string.Empty);
}
else
{
calculatedHash = _cryptographyProvider.ComputeHash(readyHash.Id, passwordbytes, readyHash.SaltBytes);
calculatedHashString = BitConverter.ToString(calculatedHash).Replace("-", string.Empty);
}
if (calculatedHashString == readyHash.Hash)
{
success = true;
// throw new Exception("Invalid username or password");
}
}
else
{
throw new Exception(string.Format($"Requested crypto method not available in provider: {readyHash.Id}"));
}
// var success = string.Equals(GetPasswordHash(resolvedUser), GetHashedString(resolvedUser, password), StringComparison.OrdinalIgnoreCase);
if (!success) if (!success)
{ {
@ -44,46 +89,86 @@ namespace Emby.Server.Implementations.Library
}); });
} }
// This allows us to move passwords forward to the newformat without breaking. They are still insecure, unsalted, and dumb before a password change
// but at least they are in the new format.
private void ConvertPasswordFormat(User user)
{
if (string.IsNullOrEmpty(user.Password))
{
return;
}
if (!user.Password.Contains("$"))
{
string hash = user.Password;
user.Password = string.Format("$SHA1${0}", hash);
}
if (user.EasyPassword != null && !user.EasyPassword.Contains("$"))
{
string hash = user.EasyPassword;
user.EasyPassword = string.Format("$SHA1${0}", hash);
}
}
public Task<bool> HasPassword(User user) public Task<bool> HasPassword(User user)
{ {
var hasConfiguredPassword = !IsPasswordEmpty(user, GetPasswordHash(user)); var hasConfiguredPassword = !IsPasswordEmpty(user, GetPasswordHash(user));
return Task.FromResult(hasConfiguredPassword); return Task.FromResult(hasConfiguredPassword);
} }
private bool IsPasswordEmpty(User user, string passwordHash) private bool IsPasswordEmpty(User user, string password)
{ {
return string.Equals(passwordHash, GetEmptyHashedString(user), StringComparison.OrdinalIgnoreCase); return (string.IsNullOrEmpty(user.Password) && string.IsNullOrEmpty(password));
} }
public Task ChangePassword(User user, string newPassword) public Task ChangePassword(User user, string newPassword)
{ {
string newPasswordHash = null; ConvertPasswordFormat(user);
// This is needed to support changing a no password user to a password user
if (newPassword != null) if (string.IsNullOrEmpty(user.Password))
{ {
newPasswordHash = GetHashedString(user, newPassword); PasswordHash newPasswordHash = new PasswordHash(_cryptographyProvider);
newPasswordHash.SaltBytes = _cryptographyProvider.GenerateSalt();
newPasswordHash.Salt = PasswordHash.ConvertToByteString(newPasswordHash.SaltBytes);
newPasswordHash.Id = _cryptographyProvider.DefaultHashMethod;
newPasswordHash.Hash = GetHashedStringChangeAuth(newPassword, newPasswordHash);
user.Password = newPasswordHash.ToString();
return Task.CompletedTask;
} }
if (string.IsNullOrWhiteSpace(newPasswordHash)) PasswordHash passwordHash = new PasswordHash(user.Password);
if (passwordHash.Id == "SHA1" && string.IsNullOrEmpty(passwordHash.Salt))
{ {
throw new ArgumentNullException(nameof(newPasswordHash)); passwordHash.SaltBytes = _cryptographyProvider.GenerateSalt();
passwordHash.Salt = PasswordHash.ConvertToByteString(passwordHash.SaltBytes);
passwordHash.Id = _cryptographyProvider.DefaultHashMethod;
passwordHash.Hash = GetHashedStringChangeAuth(newPassword, passwordHash);
}
else if (newPassword != null)
{
passwordHash.Hash = GetHashedString(user, newPassword);
} }
user.Password = newPasswordHash; if (string.IsNullOrWhiteSpace(passwordHash.Hash))
{
throw new ArgumentNullException(nameof(passwordHash.Hash));
}
user.Password = passwordHash.ToString();
return Task.CompletedTask; return Task.CompletedTask;
} }
public string GetPasswordHash(User user) public string GetPasswordHash(User user)
{ {
return string.IsNullOrEmpty(user.Password) return user.Password;
? GetEmptyHashedString(user)
: user.Password;
} }
public string GetEmptyHashedString(User user) public string GetHashedStringChangeAuth(string newPassword, PasswordHash passwordHash)
{ {
return GetHashedString(user, string.Empty); passwordHash.HashBytes = Encoding.UTF8.GetBytes(newPassword);
return PasswordHash.ConvertToByteString(_cryptographyProvider.ComputeHash(passwordHash));
} }
/// <summary> /// <summary>
@ -91,14 +176,28 @@ namespace Emby.Server.Implementations.Library
/// </summary> /// </summary>
public string GetHashedString(User user, string str) public string GetHashedString(User user, string str)
{ {
var salt = user.Salt; PasswordHash passwordHash;
if (salt != null) if (string.IsNullOrEmpty(user.Password))
{ {
// return BCrypt.HashPassword(str, salt); passwordHash = new PasswordHash(_cryptographyProvider);
}
else
{
ConvertPasswordFormat(user);
passwordHash = new PasswordHash(user.Password);
} }
// legacy if (passwordHash.SaltBytes != null)
return BitConverter.ToString(_cryptographyProvider.ComputeSHA1(Encoding.UTF8.GetBytes(str))).Replace("-", string.Empty); {
// the password is modern format with PBKDF and we should take advantage of that
passwordHash.HashBytes = Encoding.UTF8.GetBytes(str);
return PasswordHash.ConvertToByteString(_cryptographyProvider.ComputeHash(passwordHash));
}
else
{
// the password has no salt and should be called with the older method for safety
return PasswordHash.ConvertToByteString(_cryptographyProvider.ComputeHash(passwordHash.Id, Encoding.UTF8.GetBytes(str)));
}
} }
} }
} }

View File

@ -4,6 +4,7 @@ using System.Globalization;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Text; using System.Text;
using System.Text.RegularExpressions;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using MediaBrowser.Common.Events; using MediaBrowser.Common.Events;
@ -213,22 +214,17 @@ namespace Emby.Server.Implementations.Library
} }
} }
public bool IsValidUsername(string username) public static bool IsValidUsername(string username)
{ {
// Usernames can contain letters (a-z), numbers (0-9), dashes (-), underscores (_), apostrophes ('), and periods (.) //This is some regex that matches only on unicode "word" characters, as well as -, _ and @
foreach (var currentChar in username) //In theory this will cut out most if not all 'control' characters which should help minimize any weirdness
{ // Usernames can contain letters (a-z + whatever else unicode is cool with), numbers (0-9), dashes (-), underscores (_), apostrophes ('), and periods (.)
if (!IsValidUsernameCharacter(currentChar)) return Regex.IsMatch(username, "^[\\w-'._@]*$");
{
return false;
}
}
return true;
} }
private static bool IsValidUsernameCharacter(char i) private static bool IsValidUsernameCharacter(char i)
{ {
return !char.Equals(i, '<') && !char.Equals(i, '>'); return IsValidUsername(i.ToString());
} }
public string MakeValidUsername(string username) public string MakeValidUsername(string username)
@ -475,15 +471,10 @@ namespace Emby.Server.Implementations.Library
private string GetLocalPasswordHash(User user) private string GetLocalPasswordHash(User user)
{ {
return string.IsNullOrEmpty(user.EasyPassword) return string.IsNullOrEmpty(user.EasyPassword)
? _defaultAuthenticationProvider.GetEmptyHashedString(user) ? null
: user.EasyPassword; : user.EasyPassword;
} }
private bool IsPasswordEmpty(User user, string passwordHash)
{
return string.Equals(passwordHash, _defaultAuthenticationProvider.GetEmptyHashedString(user), StringComparison.OrdinalIgnoreCase);
}
/// <summary> /// <summary>
/// Loads the users from the repository /// Loads the users from the repository
/// </summary> /// </summary>
@ -526,14 +517,14 @@ namespace Emby.Server.Implementations.Library
throw new ArgumentNullException(nameof(user)); throw new ArgumentNullException(nameof(user));
} }
var hasConfiguredPassword = GetAuthenticationProvider(user).HasPassword(user).Result; bool hasConfiguredPassword = GetAuthenticationProvider(user).HasPassword(user).Result;
var hasConfiguredEasyPassword = !IsPasswordEmpty(user, GetLocalPasswordHash(user)); bool hasConfiguredEasyPassword = string.IsNullOrEmpty(GetLocalPasswordHash(user));
var hasPassword = user.Configuration.EnableLocalPassword && !string.IsNullOrEmpty(remoteEndPoint) && _networkManager.IsInLocalNetwork(remoteEndPoint) ? bool hasPassword = user.Configuration.EnableLocalPassword && !string.IsNullOrEmpty(remoteEndPoint) && _networkManager.IsInLocalNetwork(remoteEndPoint) ?
hasConfiguredEasyPassword : hasConfiguredEasyPassword :
hasConfiguredPassword; hasConfiguredPassword;
var dto = new UserDto UserDto dto = new UserDto
{ {
Id = user.Id, Id = user.Id,
Name = user.Name, Name = user.Name,
@ -552,7 +543,7 @@ namespace Emby.Server.Implementations.Library
dto.EnableAutoLogin = true; dto.EnableAutoLogin = true;
} }
var image = user.GetImageInfo(ImageType.Primary, 0); ItemImageInfo image = user.GetImageInfo(ImageType.Primary, 0);
if (image != null) if (image != null)
{ {
@ -688,7 +679,7 @@ namespace Emby.Server.Implementations.Library
if (!IsValidUsername(name)) if (!IsValidUsername(name))
{ {
throw new ArgumentException("Usernames can contain letters (a-z), numbers (0-9), dashes (-), underscores (_), apostrophes ('), and periods (.)"); throw new ArgumentException("Usernames can contain unicode symbols, numbers (0-9), dashes (-), underscores (_), apostrophes ('), and periods (.)");
} }
if (Users.Any(u => u.Name.Equals(name, StringComparison.OrdinalIgnoreCase))) if (Users.Any(u => u.Name.Equals(name, StringComparison.OrdinalIgnoreCase)))

View File

@ -1,5 +1,6 @@
using System; using System;
using System.IO; using System.IO;
using System.Collections.Generic;
namespace MediaBrowser.Model.Cryptography namespace MediaBrowser.Model.Cryptography
{ {
@ -9,5 +10,13 @@ namespace MediaBrowser.Model.Cryptography
byte[] ComputeMD5(Stream str); byte[] ComputeMD5(Stream str);
byte[] ComputeMD5(byte[] bytes); byte[] ComputeMD5(byte[] bytes);
byte[] ComputeSHA1(byte[] bytes); byte[] ComputeSHA1(byte[] bytes);
IEnumerable<string> GetSupportedHashMethods();
byte[] ComputeHash(string HashMethod, byte[] bytes);
byte[] ComputeHashWithDefaultMethod(byte[] bytes);
byte[] ComputeHash(string HashMethod, byte[] bytes, byte[] salt);
byte[] ComputeHashWithDefaultMethod(byte[] bytes, byte[] salt);
byte[] ComputeHash(PasswordHash hash);
byte[] GenerateSalt();
string DefaultHashMethod { get; }
} }
} }

View File

@ -0,0 +1,153 @@
using System;
using System.Collections.Generic;
using System.Text;
namespace MediaBrowser.Model.Cryptography
{
public class PasswordHash
{
// Defined from this hash storage spec
// https://github.com/P-H-C/phc-string-format/blob/master/phc-sf-spec.md
// $<id>[$<param>=<value>(,<param>=<value>)*][$<salt>[$<hash>]]
// with one slight amendment to ease the transition, we're writing out the bytes in hex
// rather than making them a BASE64 string with stripped padding
private string _id;
private Dictionary<string, string> _parameters = new Dictionary<string, string>();
private string _salt;
private byte[] _saltBytes;
private string _hash;
private byte[] _hashBytes;
public string Id { get => _id; set => _id = value; }
public Dictionary<string, string> Parameters { get => _parameters; set => _parameters = value; }
public string Salt { get => _salt; set => _salt = value; }
public byte[] SaltBytes { get => _saltBytes; set => _saltBytes = value; }
public string Hash { get => _hash; set => _hash = value; }
public byte[] HashBytes { get => _hashBytes; set => _hashBytes = value; }
public PasswordHash(string storageString)
{
string[] splitted = storageString.Split('$');
_id = splitted[1];
if (splitted[2].Contains("="))
{
foreach (string paramset in (splitted[2].Split(',')))
{
if (!string.IsNullOrEmpty(paramset))
{
string[] fields = paramset.Split('=');
if (fields.Length == 2)
{
_parameters.Add(fields[0], fields[1]);
}
else
{
throw new Exception($"Malformed parameter in password hash string {paramset}");
}
}
}
if (splitted.Length == 5)
{
_salt = splitted[3];
_saltBytes = ConvertFromByteString(_salt);
_hash = splitted[4];
_hashBytes = ConvertFromByteString(_hash);
}
else
{
_salt = string.Empty;
_hash = splitted[3];
_hashBytes = ConvertFromByteString(_hash);
}
}
else
{
if (splitted.Length == 4)
{
_salt = splitted[2];
_saltBytes = ConvertFromByteString(_salt);
_hash = splitted[3];
_hashBytes = ConvertFromByteString(_hash);
}
else
{
_salt = string.Empty;
_hash = splitted[2];
_hashBytes = ConvertFromByteString(_hash);
}
}
}
public PasswordHash(ICryptoProvider cryptoProvider)
{
_id = cryptoProvider.DefaultHashMethod;
_saltBytes = cryptoProvider.GenerateSalt();
_salt = ConvertToByteString(SaltBytes);
}
public static byte[] ConvertFromByteString(string byteString)
{
byte[] bytes = new byte[byteString.Length / 2];
for (int i = 0; i < byteString.Length; i += 2)
{
// TODO: NetStandard2.1 switch this to use a span instead of a substring.
bytes[i / 2] = Convert.ToByte(byteString.Substring(i, 2), 16);
}
return bytes;
}
public static string ConvertToByteString(byte[] bytes)
{
return BitConverter.ToString(bytes).Replace("-", "");
}
private string SerializeParameters()
{
string returnString = string.Empty;
foreach (var KVP in _parameters)
{
returnString += $",{KVP.Key}={KVP.Value}";
}
if ((!string.IsNullOrEmpty(returnString)) && returnString[0] == ',')
{
returnString = returnString.Remove(0, 1);
}
return returnString;
}
public override string ToString()
{
string outString = "$" + _id;
string paramstring = SerializeParameters();
if (!string.IsNullOrEmpty(paramstring))
{
outString += $"${paramstring}";
}
if (!string.IsNullOrEmpty(_salt))
{
outString += $"${_salt}";
}
outString += $"${_hash}";
return outString;
}
}
}