From c5cdb0f799bf31acff718a375efcbb5828e276b6 Mon Sep 17 00:00:00 2001 From: Tobias Klika Date: Tue, 13 Dec 2022 16:25:36 +0100 Subject: [PATCH] 2fa helpers --- .../Operations/RavenOperations.Delete.cs | 11 +- zero.Raven/Operations/RavenOperations.cs | 5 + zero/Extensions/StringExtensions.cs | 18 +++ zero/Identity/Models/TwoFactorKey.cs | 53 +++++++++ zero/Identity/UserManagerExtensions.cs | 104 ++++++++++++++++++ zero/Media/MediaCreator.cs | 25 +++-- zero/Media/MediaExtensions.cs | 15 ++- zero/Media/MediaManagement.cs | 6 +- zero/Media/MediaManagementExtensions.cs | 8 +- zero/Media/ZeroMediaModule.cs | 7 ++ .../ModelStateDictionaryExtensions.cs | 71 +++++++++++- 11 files changed, 297 insertions(+), 26 deletions(-) create mode 100644 zero/Identity/Models/TwoFactorKey.cs create mode 100644 zero/Identity/UserManagerExtensions.cs diff --git a/zero.Raven/Operations/RavenOperations.Delete.cs b/zero.Raven/Operations/RavenOperations.Delete.cs index 5bd164e4..6c2b8830 100644 --- a/zero.Raven/Operations/RavenOperations.Delete.cs +++ b/zero.Raven/Operations/RavenOperations.Delete.cs @@ -3,8 +3,15 @@ public partial class RavenOperations : IRavenOperations { /// - public virtual async Task> Delete(T model) where T : ZeroIdEntity, new() + public virtual Task> Delete(T model) where T : ZeroIdEntity, new() + => Delete(model.Id); + + + /// + public virtual async Task> Delete(string id) where T : ZeroIdEntity, new() { + T model = await Load(id); + if (model == null) { return Result.Fail("@errors.ondelete.idnotfound"); @@ -17,7 +24,7 @@ public partial class RavenOperations : IRavenOperations return instruction.Result; } - Session.Delete(model); + Session.Delete(model); await Session.SaveChangesAsync(); if (InterceptorBlocker == null) { diff --git a/zero.Raven/Operations/RavenOperations.cs b/zero.Raven/Operations/RavenOperations.cs index 501e71f0..b7548055 100644 --- a/zero.Raven/Operations/RavenOperations.cs +++ b/zero.Raven/Operations/RavenOperations.cs @@ -238,6 +238,11 @@ public interface IRavenOperations /// Deletes an entity /// Task> Delete(T model) where T : ZeroIdEntity, new(); + + /// + /// Deletes an entity + /// + Task> Delete(string id) where T : ZeroIdEntity, new(); /// /// Loads all children for an entity diff --git a/zero/Extensions/StringExtensions.cs b/zero/Extensions/StringExtensions.cs index 7d9327f3..497cc96c 100644 --- a/zero/Extensions/StringExtensions.cs +++ b/zero/Extensions/StringExtensions.cs @@ -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(); + } } diff --git a/zero/Identity/Models/TwoFactorKey.cs b/zero/Identity/Models/TwoFactorKey.cs new file mode 100644 index 00000000..f78205ee --- /dev/null +++ b/zero/Identity/Models/TwoFactorKey.cs @@ -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 +} \ No newline at end of file diff --git a/zero/Identity/UserManagerExtensions.cs b/zero/Identity/UserManagerExtensions.cs new file mode 100644 index 00000000..c773607e --- /dev/null +++ b/zero/Identity/UserManagerExtensions.cs @@ -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}"; + + + /// + /// Adds an implementation of identity information stores. + /// + public static Task GetTwoFactorKey(this UserManager userManager, T user, string issuer) + where T : class => GetTwoFactorKey(userManager, user, new TwoFactorKeyOptions() { Issuer = issuer }); + + + /// + /// Adds an implementation of identity information stores. + /// + public static async Task GetTwoFactorKey(this UserManager 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> SetupTwoFactorAuth(this UserManager 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.Fail(); + } + + // enable two-factor auth for account + await userManager.SetTwoFactorEnabledAsync(user, true); + + // generate recovery codes + if (await userManager.CountRecoveryCodesAsync(user) == 0) + { + IEnumerable recoveryCodes = await userManager.GenerateNewTwoFactorRecoveryCodesAsync(user, recoveryCodesToGenerate); + return Result.Success(recoveryCodes.ToArray()); + } + + return Result.Success(Array.Empty()); + } + + + 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(); + } +} \ No newline at end of file diff --git a/zero/Media/MediaCreator.cs b/zero/Media/MediaCreator.cs index 563a33be..04481bcb 100644 --- a/zero/Media/MediaCreator.cs +++ b/zero/Media/MediaCreator.cs @@ -33,7 +33,7 @@ public class MediaCreator : IMediaCreator if (!isImage && !isDocument) { // TODO error - return Result.Fail("ERROR"); + return Result.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 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 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; + } } } diff --git a/zero/Media/MediaExtensions.cs b/zero/Media/MediaExtensions.cs index dc8e3004..7d9777b7 100644 --- a/zero/Media/MediaExtensions.cs +++ b/zero/Media/MediaExtensions.cs @@ -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('/'); } diff --git a/zero/Media/MediaManagement.cs b/zero/Media/MediaManagement.cs index 094482a8..2da20e94 100644 --- a/zero/Media/MediaManagement.cs +++ b/zero/Media/MediaManagement.cs @@ -88,7 +88,7 @@ public class MediaManagement : IMediaManagement /// - public virtual async Task> UploadFile(Stream fileStream, string filename, string folderId = null, CancellationToken cancellationToken = default) + public virtual async Task> UploadFile(Stream fileStream, string filename, string folderId = null, Action onBeforeSave = null, CancellationToken cancellationToken = default) { Result 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 /// /// Uploads a file and persists it /// - Task> UploadFile(Stream fileStream, string filename, string folderId = null, CancellationToken cancellationToken = default); + Task> UploadFile(Stream fileStream, string filename, string folderId = null, Action onBeforeSave = null, CancellationToken cancellationToken = default); /// /// Get a media folder by id diff --git a/zero/Media/MediaManagementExtensions.cs b/zero/Media/MediaManagementExtensions.cs index b701b7e5..3fc4f3ef 100644 --- a/zero/Media/MediaManagementExtensions.cs +++ b/zero/Media/MediaManagementExtensions.cs @@ -36,20 +36,20 @@ namespace zero.Media /// /// Uploads a file and persists it /// - public static async Task> UploadFile(this IMediaManagement media, IFormFile formFile, string folderId = null, CancellationToken cancellationToken = default) + public static async Task> UploadFile(this IMediaManagement media, IFormFile formFile, string folderId = null, Action 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); } /// /// Uploads a file and persists it /// - public static async Task> UploadFile(this IMediaManagement media, byte[] fileBytes, string filename, string folderId = null, CancellationToken cancellationToken = default) + public static async Task> UploadFile(this IMediaManagement media, byte[] fileBytes, string filename, string folderId = null, Action 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); } diff --git a/zero/Media/ZeroMediaModule.cs b/zero/Media/ZeroMediaModule.cs index 5508a535..67cec15c 100644 --- a/zero/Media/ZeroMediaModule.cs +++ b/zero/Media/ZeroMediaModule.cs @@ -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(); // }); } + + public override void Configure(IApplicationBuilder app, IEndpointRouteBuilder routes, IServiceProvider serviceProvider) + { + app.UseImageSharp(); + } } \ No newline at end of file diff --git a/zero/Validation/ModelStateDictionaryExtensions.cs b/zero/Validation/ModelStateDictionaryExtensions.cs index 2e840c94..85d4e3e5 100644 --- a/zero/Validation/ModelStateDictionaryExtensions.cs +++ b/zero/Validation/ModelStateDictionaryExtensions.cs @@ -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(this Result result, ModelStateDictionary modelState, bool convertUnresolvedPropertiesToModel = true, string prefix = "Form", Dictionary aliases = default, IEnumerable 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); + } + } } \ No newline at end of file