2fa helpers

This commit is contained in:
2022-12-13 16:25:36 +01:00
parent 8f3a20e5ba
commit c5cdb0f799
11 changed files with 297 additions and 26 deletions
@@ -3,8 +3,15 @@
public partial class RavenOperations : IRavenOperations
{
/// <inheritdoc />
public virtual async Task<Result<T>> Delete<T>(T model) where T : ZeroIdEntity, new()
public virtual Task<Result<T>> Delete<T>(T model) where T : ZeroIdEntity, new()
=> Delete<T>(model.Id);
/// <inheritdoc />
public virtual async Task<Result<T>> Delete<T>(string id) where T : ZeroIdEntity, new()
{
T model = await Load<T>(id);
if (model == null)
{
return Result<T>.Fail("@errors.ondelete.idnotfound");
@@ -17,7 +24,7 @@ public partial class RavenOperations : IRavenOperations
return instruction.Result;
}
Session.Delete(model);
Session.Delete<T>(model);
await Session.SaveChangesAsync();
if (InterceptorBlocker == null)
{
+5
View File
@@ -238,6 +238,11 @@ public interface IRavenOperations
/// Deletes an entity
/// </summary>
Task<Result<T>> Delete<T>(T model) where T : ZeroIdEntity, new();
/// <summary>
/// Deletes an entity
/// </summary>
Task<Result<T>> Delete<T>(string id) where T : ZeroIdEntity, new();
/// <summary>
/// Loads all children for an entity
+18
View File
@@ -212,4 +212,22 @@ public static class StringExtensions
return builder.ToString();
}
public static string FormatTwoFactorAuthenticationKey(this string unformattedKey)
{
var result = new StringBuilder();
int currentPosition = 0;
while (currentPosition + 4 < unformattedKey.Length)
{
result.Append(unformattedKey.AsSpan(currentPosition, 4)).Append(' ');
currentPosition += 4;
}
if (currentPosition < unformattedKey.Length)
{
result.Append(unformattedKey.AsSpan(currentPosition));
}
return result.ToString().ToLowerInvariant();
}
}
+53
View File
@@ -0,0 +1,53 @@
using System.Security.Claims;
namespace zero.Identity;
public class TwoFactorKey
{
public string UserEmail { get; init; }
public string UnformattedKey { get; init; }
public string FormattedKey { get; init; }
public string AuthenticatorUrl { get; init; }
public string QrCodeSvg { get; set; }
public TwoFactorKeyOptions Options { get; init; }
}
public class TwoFactorKeyOptions
{
public string Issuer { get; set; }
public TwoFactorKeyLength Digits { get; set; } = TwoFactorKeyLength.Six;
public TwoFactorKeyAlgorithm Algorithm { get; set; } = TwoFactorKeyAlgorithm.SHA1;
public TwoFactorKeyRefreshPeriod Period { get; set; } = TwoFactorKeyRefreshPeriod.ThirtySeconds;
}
public enum TwoFactorKeyLength
{
Six = 6,
Seven = 7,
Eight = 8
}
public enum TwoFactorKeyAlgorithm
{
// ReSharper disable once InconsistentNaming
SHA1 = 0,
// ReSharper disable once InconsistentNaming
SHA256 = 1,
// ReSharper disable once InconsistentNaming
SHA512 = 2
}
public enum TwoFactorKeyRefreshPeriod
{
FifteenSeconds = 15,
ThirtySeconds = 30,
SixtySeconds = 60
}
+104
View File
@@ -0,0 +1,104 @@
using System.Globalization;
using System.Text;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using zero.Rendering.QrCode;
namespace zero.Identity;
public static class UserManagerExtensions
{
private const string AuthenticatorUriFormat = "otpauth://totp/{0}:{1}?secret={2}&issuer={0}&digits={3}&algorithm={4}&period={5}";
/// <summary>
/// Adds an implementation of identity information stores.
/// </summary>
public static Task<TwoFactorKey> GetTwoFactorKey<T>(this UserManager<T> userManager, T user, string issuer)
where T : class => GetTwoFactorKey(userManager, user, new TwoFactorKeyOptions() { Issuer = issuer });
/// <summary>
/// Adds an implementation of identity information stores.
/// </summary>
public static async Task<TwoFactorKey> GetTwoFactorKey<T>(this UserManager<T> userManager, T user, TwoFactorKeyOptions options)
where T : class
{
string unformattedKey = await userManager.GetAuthenticatorKeyAsync(user);
string email = await userManager.GetEmailAsync(user);
if (unformattedKey.IsNullOrEmpty())
{
await userManager.ResetAuthenticatorKeyAsync(user);
unformattedKey = await userManager.GetAuthenticatorKeyAsync(user);
}
string url = string.Format(
CultureInfo.InvariantCulture,
AuthenticatorUriFormat,
UrlEncoder.Default.Encode(options.Issuer),
UrlEncoder.Default.Encode(email),
unformattedKey,
(int)options.Digits,
options.Algorithm.ToString(),
(int)options.Period);
QrCode qr = QrCode.EncodeText(url, QrCode.Ecc.Medium);
string svg = qr.ToSvgString(0);
return new TwoFactorKey()
{
UserEmail = email,
UnformattedKey = unformattedKey,
FormattedKey = FormatTwoFactorAuthenticationKey(unformattedKey),
AuthenticatorUrl = url,
QrCodeSvg = svg,
Options = options
};
}
public static async Task<Result<string[]>> SetupTwoFactorAuth<T>(this UserManager<T> userManager, T user,
string inputCode, int recoveryCodesToGenerate = 10) where T : class
{
string token = inputCode.Replace(" ", string.Empty).Replace("-", string.Empty);
bool is2FaTokenValid = await userManager.VerifyTwoFactorTokenAsync(user, userManager.Options.Tokens.AuthenticatorTokenProvider, token);
if (!is2FaTokenValid)
{
return Result<string[]>.Fail();
}
// enable two-factor auth for account
await userManager.SetTwoFactorEnabledAsync(user, true);
// generate recovery codes
if (await userManager.CountRecoveryCodesAsync(user) == 0)
{
IEnumerable<string> recoveryCodes = await userManager.GenerateNewTwoFactorRecoveryCodesAsync(user, recoveryCodesToGenerate);
return Result<string[]>.Success(recoveryCodes.ToArray());
}
return Result<string[]>.Success(Array.Empty<string>());
}
private static string FormatTwoFactorAuthenticationKey(string unformattedKey)
{
var result = new StringBuilder();
int currentPosition = 0;
while (currentPosition + 4 < unformattedKey.Length)
{
result.Append(unformattedKey.AsSpan(currentPosition, 4)).Append(' ');
currentPosition += 4;
}
if (currentPosition < unformattedKey.Length)
{
result.Append(unformattedKey.AsSpan(currentPosition));
}
return result.ToString().ToLowerInvariant();
}
}
+14 -11
View File
@@ -33,7 +33,7 @@ public class MediaCreator : IMediaCreator
if (!isImage && !isDocument)
{
// TODO error
return Result<Media>.Fail("ERROR");
return Result<Media>.Fail("This file type is not supported");
}
Media model = new();
@@ -65,22 +65,25 @@ public class MediaCreator : IMediaCreator
string extension = Path.GetExtension(model.Path);
foreach ((string key, ResizeOptions opts) in Options.Thumbnails)
if (Options.Thumbnails != null)
{
Image<Rgba32> imageFrame = image.Frames.Count > 1 ? image.Frames.CloneFrame(0) : image.Clone();
imageFrame.Mutate(x => x.Resize(opts));
foreach ((string key, ResizeOptions opts) in Options.Thumbnails)
{
Image<Rgba32> imageFrame = image.Frames.Count > 1 ? image.Frames.CloneFrame(0) : image.Clone();
imageFrame.Mutate(x => x.Resize(opts));
using MemoryStream stream = new();
await imageFrame.SaveAsync(stream, new PngEncoder(), cancellationToken);
using MemoryStream stream = new();
await imageFrame.SaveAsync(stream, new PngEncoder(), cancellationToken);
stream.Position = 0;
stream.Position = 0;
string thumbFilename = normalizedFilename.TrimEnd(extension) + "." + Safenames.File(key) + ".png";
string path = directory + '/' + thumbFilename;
string thumbFilename = normalizedFilename.TrimEnd(extension) + "." + Safenames.File(key) + ".png";
string path = directory + '/' + thumbFilename;
await FileSystem.CreateFile(path, stream, cancellationToken: cancellationToken);
await FileSystem.CreateFile(path, stream, cancellationToken: cancellationToken);
model.Thumbnails[key] = path;
model.Thumbnails[key] = path;
}
}
}
+12 -3
View File
@@ -31,10 +31,11 @@ public static class MediaExtensions
return values;
}
public static string Resize(this Media media, string preset) => media?.Path.Resize(preset, media?.Metadata?.FocalPoint, true);
public static string Resize(this string path, string preset, bool isZeroMedia) => path.Resize(preset, null, isZeroMedia);
public static string Resize(this string path, string preset) => path.Resize(preset, null);
public static string Resize(this string path, string preset, MediaFocalPoint focalPoint = null)
public static string Resize(this string path, string preset, MediaFocalPoint focalPoint = null, bool isZeroMedia = false)
{
if (path.IsNullOrEmpty())
{
@@ -49,6 +50,14 @@ public static class MediaExtensions
return String.Join('/', parts);
}
if (isZeroMedia)
{
// TODO this is bullshit because we need to get the base path from options
return parts[1] == "media" || parts[0] == "media"
? String.Join('/', parts)
: ("/media" + String.Join('/', parts).EnsureStartsWith('/'));
}
return String.Join('/', parts).EnsureStartsWith('/');
}
+4 -2
View File
@@ -88,7 +88,7 @@ public class MediaManagement : IMediaManagement
/// <inheritdoc />
public virtual async Task<Result<Media>> UploadFile(Stream fileStream, string filename, string folderId = null, CancellationToken cancellationToken = default)
public virtual async Task<Result<Media>> UploadFile(Stream fileStream, string filename, string folderId = null, Action<Media> onBeforeSave = null, CancellationToken cancellationToken = default)
{
Result<Media> result = await Creator.UploadFile(fileStream, filename, folderId, cancellationToken);
@@ -97,6 +97,8 @@ public class MediaManagement : IMediaManagement
return result;
}
onBeforeSave?.Invoke(result.Model);
return await Db.Create(result.Model);
}
@@ -176,7 +178,7 @@ public interface IMediaManagement
/// <summary>
/// Uploads a file and persists it
/// </summary>
Task<Result<Media>> UploadFile(Stream fileStream, string filename, string folderId = null, CancellationToken cancellationToken = default);
Task<Result<Media>> UploadFile(Stream fileStream, string filename, string folderId = null, Action<Media> onBeforeSave = null, CancellationToken cancellationToken = default);
/// <summary>
/// Get a media folder by id
+4 -4
View File
@@ -36,20 +36,20 @@ namespace zero.Media
/// <summary>
/// Uploads a file and persists it
/// </summary>
public static async Task<Result<Media>> UploadFile(this IMediaManagement media, IFormFile formFile, string folderId = null, CancellationToken cancellationToken = default)
public static async Task<Result<Media>> UploadFile(this IMediaManagement media, IFormFile formFile, string folderId = null, Action<Media> onBeforeSave = null, CancellationToken cancellationToken = default)
{
using Stream stream = formFile.OpenReadStream();
return await media.UploadFile(stream, formFile.FileName, folderId, cancellationToken);
return await media.UploadFile(stream, formFile.FileName, folderId, onBeforeSave, cancellationToken);
}
/// <summary>
/// Uploads a file and persists it
/// </summary>
public static async Task<Result<Media>> UploadFile(this IMediaManagement media, byte[] fileBytes, string filename, string folderId = null, CancellationToken cancellationToken = default)
public static async Task<Result<Media>> UploadFile(this IMediaManagement media, byte[] fileBytes, string filename, string folderId = null, Action<Media> onBeforeSave = null, CancellationToken cancellationToken = default)
{
using Stream stream = new MemoryStream(fileBytes);
return await media.UploadFile(stream, filename, folderId, cancellationToken);
return await media.UploadFile(stream, filename, folderId, onBeforeSave, cancellationToken);
}
+7
View File
@@ -4,6 +4,8 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using SixLabors.ImageSharp.Processing;
using System.IO;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Routing;
using SixLabors.ImageSharp.Web.Caching;
using SixLabors.ImageSharp.Web.DependencyInjection;
using zero.Media.ImageSharp;
@@ -53,4 +55,9 @@ internal class ZeroMediaModule : ZeroModule
// raven.Indexes.Add<Media_ByHierarchy>();
// });
}
public override void Configure(IApplicationBuilder app, IEndpointRouteBuilder routes, IServiceProvider serviceProvider)
{
app.UseImageSharp();
}
}
@@ -1,4 +1,5 @@
using FluentValidation.Results;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc.ModelBinding;
namespace zero.Validation;
@@ -22,7 +23,7 @@ public static class ModelStateDictionaryExtensions
if (propertyName != null && removePrefixes != null && removePrefixes.Any())
{
foreach (var removePrefix in removePrefixes)
foreach (string removePrefix in removePrefixes)
{
if (propertyName.StartsWith(removePrefix, StringComparison.InvariantCultureIgnoreCase))
{
@@ -32,7 +33,7 @@ public static class ModelStateDictionaryExtensions
}
}
string key = prefix.IsNullOrEmpty() ? propertyName : (propertyName.IsNullOrEmpty() ? String.Empty : prefix + DOT + propertyName);
string key = prefix.IsNullOrEmpty() ? propertyName : (propertyName.IsNullOrEmpty() ? string.Empty : prefix + DOT + propertyName);
string message = error.ErrorMessage;
bool found = formProperties.Contains(key, StringComparer.InvariantCultureIgnoreCase);
@@ -40,17 +41,79 @@ public static class ModelStateDictionaryExtensions
if (!found && aliases != null && aliases.ContainsKey(propertyName))
{
key = aliases[propertyName];
key = prefix.IsNullOrEmpty() ? key : (key.IsNullOrEmpty() ? String.Empty : prefix + DOT + key);
key = prefix.IsNullOrEmpty() ? key : (key.IsNullOrEmpty() ? string.Empty : prefix + DOT + key);
found = formProperties.Contains(key, StringComparer.InvariantCultureIgnoreCase);
}
// move property errors to model if they are not part of the form
if (!found && convertUnresolvedPropertiesToModel)
{
key = String.Empty;
key = string.Empty;
}
modelState.AddModelError(key, message);
}
}
public static void AddToModelState<T>(this Result<T> result, ModelStateDictionary modelState, bool convertUnresolvedPropertiesToModel = true, string prefix = "Form", Dictionary<string, string> aliases = default, IEnumerable<string> removePrefixes = default)
{
if (result == null || result.IsSuccess)
{
return;
}
string[] formProperties = modelState.Keys.ToArray();
foreach (var error in result.Errors)
{
string propertyName = error.Property;
if (propertyName != null && removePrefixes != null && removePrefixes.Any())
{
foreach (string removePrefix in removePrefixes)
{
if (propertyName.StartsWith(removePrefix, StringComparison.InvariantCultureIgnoreCase))
{
propertyName = propertyName.Substring(removePrefix.Length);
break;
}
}
}
string key = prefix.IsNullOrEmpty() ? propertyName : (propertyName.IsNullOrEmpty() ? string.Empty : prefix + DOT + propertyName);
string message = error.Message;
bool found = formProperties.Contains(key, StringComparer.InvariantCultureIgnoreCase);
// find property by alias
if (!found && aliases != null && aliases.ContainsKey(propertyName))
{
key = aliases[propertyName];
key = prefix.IsNullOrEmpty() ? key : (key.IsNullOrEmpty() ? string.Empty : prefix + DOT + key);
found = formProperties.Contains(key, StringComparer.InvariantCultureIgnoreCase);
}
// move property errors to model if they are not part of the form
if (!found && convertUnresolvedPropertiesToModel)
{
key = string.Empty;
}
modelState.AddModelError(key, message);
}
}
public static void AddToModelState(this IdentityResult result, ModelStateDictionary modelState)
{
if (result == null || result.Succeeded)
{
return;
}
foreach (IdentityError error in result.Errors)
{
modelState.AddModelError(string.Empty, error.Description);
}
}
}