2fa helpers
This commit is contained in:
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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('/');
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user