diff --git a/Selector.Model/Selector.Model.csproj b/Selector.Model/Selector.Model.csproj index cb80652..61b0a55 100644 --- a/Selector.Model/Selector.Model.csproj +++ b/Selector.Model/Selector.Model.csproj @@ -25,4 +25,8 @@ + + + + diff --git a/Selector.Web/Areas/Identity/Data/IdentityDataContext.cs b/Selector.Web/Areas/Identity/Data/IdentityDataContext.cs new file mode 100644 index 0000000..97a9887 --- /dev/null +++ b/Selector.Web/Areas/Identity/Data/IdentityDataContext.cs @@ -0,0 +1,28 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; + +using Selector.Model; + +namespace Selector.Web.Areas.Identity.Data +{ + public class IdentityDataContext : IdentityDbContext + { + public IdentityDataContext(DbContextOptions options) + : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder builder) + { + base.OnModelCreating(builder); + // Customize the ASP.NET Identity model and override the defaults if needed. + // For example, you can rename the ASP.NET Identity table names and more. + // Add your customizations after calling base.OnModelCreating(builder); + } + } +} diff --git a/Selector.Web/Areas/Identity/Pages/Account/AccessDenied.cshtml b/Selector.Web/Areas/Identity/Pages/Account/AccessDenied.cshtml new file mode 100644 index 0000000..017f6ff --- /dev/null +++ b/Selector.Web/Areas/Identity/Pages/Account/AccessDenied.cshtml @@ -0,0 +1,10 @@ +@page +@model AccessDeniedModel +@{ + ViewData["Title"] = "Access denied"; +} + +
+

@ViewData["Title"]

+

You do not have access to this resource.

+
diff --git a/Selector.Web/Areas/Identity/Pages/Account/AccessDenied.cshtml.cs b/Selector.Web/Areas/Identity/Pages/Account/AccessDenied.cshtml.cs new file mode 100644 index 0000000..65d436e --- /dev/null +++ b/Selector.Web/Areas/Identity/Pages/Account/AccessDenied.cshtml.cs @@ -0,0 +1,17 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace Selector.Web.Areas.Identity.Pages.Account +{ + public class AccessDeniedModel : PageModel + { + public void OnGet() + { + + } + } +} + diff --git a/Selector.Web/Areas/Identity/Pages/Account/Lockout.cshtml b/Selector.Web/Areas/Identity/Pages/Account/Lockout.cshtml new file mode 100644 index 0000000..4eded88 --- /dev/null +++ b/Selector.Web/Areas/Identity/Pages/Account/Lockout.cshtml @@ -0,0 +1,10 @@ +@page +@model LockoutModel +@{ + ViewData["Title"] = "Locked out"; +} + +
+

@ViewData["Title"]

+

This account has been locked out, please try again later.

+
diff --git a/Selector.Web/Areas/Identity/Pages/Account/Lockout.cshtml.cs b/Selector.Web/Areas/Identity/Pages/Account/Lockout.cshtml.cs new file mode 100644 index 0000000..51dc69a --- /dev/null +++ b/Selector.Web/Areas/Identity/Pages/Account/Lockout.cshtml.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace Selector.Web.Areas.Identity.Pages.Account +{ + [AllowAnonymous] + public class LockoutModel : PageModel + { + public void OnGet() + { + + } + } +} diff --git a/Selector.Web/Areas/Identity/Pages/Account/Manage/ChangePassword.cshtml b/Selector.Web/Areas/Identity/Pages/Account/Manage/ChangePassword.cshtml new file mode 100644 index 0000000..31a2ea5 --- /dev/null +++ b/Selector.Web/Areas/Identity/Pages/Account/Manage/ChangePassword.cshtml @@ -0,0 +1,36 @@ +@page +@model ChangePasswordModel +@{ + ViewData["Title"] = "Change password"; + ViewData["ActivePage"] = ManageNavPages.ChangePassword; +} + +

@ViewData["Title"]

+ +
+
+
+
+
+ + + +
+
+ + + +
+
+ + + +
+ +
+
+
+ +@section Scripts { + +} \ No newline at end of file diff --git a/Selector.Web/Areas/Identity/Pages/Account/Manage/ChangePassword.cshtml.cs b/Selector.Web/Areas/Identity/Pages/Account/Manage/ChangePassword.cshtml.cs new file mode 100644 index 0000000..4d9efa9 --- /dev/null +++ b/Selector.Web/Areas/Identity/Pages/Account/Manage/ChangePassword.cshtml.cs @@ -0,0 +1,104 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.Extensions.Logging; +using Selector.Web.Areas.Identity.Data; + +using Selector.Model; + +namespace Selector.Web.Areas.Identity.Pages.Account.Manage +{ + public class ChangePasswordModel : PageModel + { + private readonly UserManager _userManager; + private readonly SignInManager _signInManager; + private readonly ILogger _logger; + + public ChangePasswordModel( + UserManager userManager, + SignInManager signInManager, + ILogger logger) + { + _userManager = userManager; + _signInManager = signInManager; + _logger = logger; + } + + [BindProperty] + public InputModel Input { get; set; } + + [TempData] + public string StatusMessage { get; set; } + + public class InputModel + { + [Required] + [DataType(DataType.Password)] + [Display(Name = "Current password")] + public string OldPassword { get; set; } + + [Required] + [StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)] + [DataType(DataType.Password)] + [Display(Name = "New password")] + public string NewPassword { get; set; } + + [DataType(DataType.Password)] + [Display(Name = "Confirm new password")] + [Compare("NewPassword", ErrorMessage = "The new password and confirmation password do not match.")] + public string ConfirmPassword { get; set; } + } + + public async Task OnGetAsync() + { + var user = await _userManager.GetUserAsync(User); + if (user == null) + { + return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + var hasPassword = await _userManager.HasPasswordAsync(user); + if (!hasPassword) + { + return RedirectToPage("./SetPassword"); + } + + return Page(); + } + + public async Task OnPostAsync() + { + if (!ModelState.IsValid) + { + return Page(); + } + + var user = await _userManager.GetUserAsync(User); + if (user == null) + { + return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + var changePasswordResult = await _userManager.ChangePasswordAsync(user, Input.OldPassword, Input.NewPassword); + if (!changePasswordResult.Succeeded) + { + foreach (var error in changePasswordResult.Errors) + { + ModelState.AddModelError(string.Empty, error.Description); + } + return Page(); + } + + await _signInManager.RefreshSignInAsync(user); + _logger.LogInformation("User changed their password successfully."); + StatusMessage = "Your password has been changed."; + + return RedirectToPage(); + } + } +} diff --git a/Selector.Web/Areas/Identity/Pages/Account/Manage/DeletePersonalData.cshtml b/Selector.Web/Areas/Identity/Pages/Account/Manage/DeletePersonalData.cshtml new file mode 100644 index 0000000..c95ab92 --- /dev/null +++ b/Selector.Web/Areas/Identity/Pages/Account/Manage/DeletePersonalData.cshtml @@ -0,0 +1,33 @@ +@page +@model DeletePersonalDataModel +@{ + ViewData["Title"] = "Delete Personal Data"; + ViewData["ActivePage"] = ManageNavPages.PersonalData; +} + +

@ViewData["Title"]

+ + + +
+
+
+ @if (Model.RequirePassword) + { +
+ + + +
+ } + +
+
+ +@section Scripts { + +} \ No newline at end of file diff --git a/Selector.Web/Areas/Identity/Pages/Account/Manage/DeletePersonalData.cshtml.cs b/Selector.Web/Areas/Identity/Pages/Account/Manage/DeletePersonalData.cshtml.cs new file mode 100644 index 0000000..7a706e9 --- /dev/null +++ b/Selector.Web/Areas/Identity/Pages/Account/Manage/DeletePersonalData.cshtml.cs @@ -0,0 +1,86 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.Extensions.Logging; +using Selector.Web.Areas.Identity.Data; + +using Selector.Model; + +namespace Selector.Web.Areas.Identity.Pages.Account.Manage +{ + public class DeletePersonalDataModel : PageModel + { + private readonly UserManager _userManager; + private readonly SignInManager _signInManager; + private readonly ILogger _logger; + + public DeletePersonalDataModel( + UserManager userManager, + SignInManager signInManager, + ILogger logger) + { + _userManager = userManager; + _signInManager = signInManager; + _logger = logger; + } + + [BindProperty] + public InputModel Input { get; set; } + + public class InputModel + { + [Required] + [DataType(DataType.Password)] + public string Password { get; set; } + } + + public bool RequirePassword { get; set; } + + public async Task OnGet() + { + var user = await _userManager.GetUserAsync(User); + if (user == null) + { + return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + RequirePassword = await _userManager.HasPasswordAsync(user); + return Page(); + } + + public async Task OnPostAsync() + { + var user = await _userManager.GetUserAsync(User); + if (user == null) + { + return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + RequirePassword = await _userManager.HasPasswordAsync(user); + if (RequirePassword) + { + if (!await _userManager.CheckPasswordAsync(user, Input.Password)) + { + ModelState.AddModelError(string.Empty, "Incorrect password."); + return Page(); + } + } + + var result = await _userManager.DeleteAsync(user); + var userId = await _userManager.GetUserIdAsync(user); + if (!result.Succeeded) + { + throw new InvalidOperationException($"Unexpected error occurred deleting user with ID '{userId}'."); + } + + await _signInManager.SignOutAsync(); + + _logger.LogInformation("User with ID '{UserId}' deleted themselves.", userId); + + return Redirect("~/"); + } + } +} diff --git a/Selector.Web/Areas/Identity/Pages/Account/Manage/Disable2fa.cshtml b/Selector.Web/Areas/Identity/Pages/Account/Manage/Disable2fa.cshtml new file mode 100644 index 0000000..96df752 --- /dev/null +++ b/Selector.Web/Areas/Identity/Pages/Account/Manage/Disable2fa.cshtml @@ -0,0 +1,25 @@ +@page +@model Disable2faModel +@{ + ViewData["Title"] = "Disable two-factor authentication (2FA)"; + ViewData["ActivePage"] = ManageNavPages.TwoFactorAuthentication; +} + + +

@ViewData["Title"]

+ + + +
+
+ +
+
diff --git a/Selector.Web/Areas/Identity/Pages/Account/Manage/Disable2fa.cshtml.cs b/Selector.Web/Areas/Identity/Pages/Account/Manage/Disable2fa.cshtml.cs new file mode 100644 index 0000000..1a4dfeb --- /dev/null +++ b/Selector.Web/Areas/Identity/Pages/Account/Manage/Disable2fa.cshtml.cs @@ -0,0 +1,66 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.Extensions.Logging; +using Selector.Web.Areas.Identity.Data; + +using Selector.Model; + +namespace Selector.Web.Areas.Identity.Pages.Account.Manage +{ + public class Disable2faModel : PageModel + { + private readonly UserManager _userManager; + private readonly ILogger _logger; + + public Disable2faModel( + UserManager userManager, + ILogger logger) + { + _userManager = userManager; + _logger = logger; + } + + [TempData] + public string StatusMessage { get; set; } + + public async Task OnGet() + { + var user = await _userManager.GetUserAsync(User); + if (user == null) + { + return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + if (!await _userManager.GetTwoFactorEnabledAsync(user)) + { + throw new InvalidOperationException($"Cannot disable 2FA for user with ID '{_userManager.GetUserId(User)}' as it's not currently enabled."); + } + + return Page(); + } + + public async Task OnPostAsync() + { + var user = await _userManager.GetUserAsync(User); + if (user == null) + { + return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + var disable2faResult = await _userManager.SetTwoFactorEnabledAsync(user, false); + if (!disable2faResult.Succeeded) + { + throw new InvalidOperationException($"Unexpected error occurred disabling 2FA for user with ID '{_userManager.GetUserId(User)}'."); + } + + _logger.LogInformation("User with ID '{UserId}' has disabled 2fa.", _userManager.GetUserId(User)); + StatusMessage = "2fa has been disabled. You can reenable 2fa when you setup an authenticator app"; + return RedirectToPage("./TwoFactorAuthentication"); + } + } +} \ No newline at end of file diff --git a/Selector.Web/Areas/Identity/Pages/Account/Manage/DownloadPersonalData.cshtml b/Selector.Web/Areas/Identity/Pages/Account/Manage/DownloadPersonalData.cshtml new file mode 100644 index 0000000..87470c2 --- /dev/null +++ b/Selector.Web/Areas/Identity/Pages/Account/Manage/DownloadPersonalData.cshtml @@ -0,0 +1,12 @@ +@page +@model DownloadPersonalDataModel +@{ + ViewData["Title"] = "Download Your Data"; + ViewData["ActivePage"] = ManageNavPages.PersonalData; +} + +

@ViewData["Title"]

+ +@section Scripts { + +} \ No newline at end of file diff --git a/Selector.Web/Areas/Identity/Pages/Account/Manage/DownloadPersonalData.cshtml.cs b/Selector.Web/Areas/Identity/Pages/Account/Manage/DownloadPersonalData.cshtml.cs new file mode 100644 index 0000000..6f87fdd --- /dev/null +++ b/Selector.Web/Areas/Identity/Pages/Account/Manage/DownloadPersonalData.cshtml.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.Extensions.Logging; +using Selector.Web.Areas.Identity.Data; + +using Selector.Model; + +namespace Selector.Web.Areas.Identity.Pages.Account.Manage +{ + public class DownloadPersonalDataModel : PageModel + { + private readonly UserManager _userManager; + private readonly ILogger _logger; + + public DownloadPersonalDataModel( + UserManager userManager, + ILogger logger) + { + _userManager = userManager; + _logger = logger; + } + + public async Task OnPostAsync() + { + var user = await _userManager.GetUserAsync(User); + if (user == null) + { + return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + _logger.LogInformation("User with ID '{UserId}' asked for their personal data.", _userManager.GetUserId(User)); + + // Only include personal data for download + var personalData = new Dictionary(); + var personalDataProps = typeof(ApplicationUser).GetProperties().Where( + prop => Attribute.IsDefined(prop, typeof(PersonalDataAttribute))); + foreach (var p in personalDataProps) + { + personalData.Add(p.Name, p.GetValue(user)?.ToString() ?? "null"); + } + + var logins = await _userManager.GetLoginsAsync(user); + foreach (var l in logins) + { + personalData.Add($"{l.LoginProvider} external login provider key", l.ProviderKey); + } + + Response.Headers.Add("Content-Disposition", "attachment; filename=PersonalData.json"); + return new FileContentResult(JsonSerializer.SerializeToUtf8Bytes(personalData), "application/json"); + } + } +} diff --git a/Selector.Web/Areas/Identity/Pages/Account/Manage/Email.cshtml b/Selector.Web/Areas/Identity/Pages/Account/Manage/Email.cshtml new file mode 100644 index 0000000..8ff8e39 --- /dev/null +++ b/Selector.Web/Areas/Identity/Pages/Account/Manage/Email.cshtml @@ -0,0 +1,43 @@ +@page +@model EmailModel +@{ + ViewData["Title"] = "Manage Email"; + ViewData["ActivePage"] = ManageNavPages.Email; +} + +

@ViewData["Title"]

+ +
+
+
+
+
+ + @if (Model.IsEmailConfirmed) + { +
+ +
+ +
+
+ } + else + { + + + } +
+
+ + + +
+ +
+
+
+ +@section Scripts { + +} diff --git a/Selector.Web/Areas/Identity/Pages/Account/Manage/Email.cshtml.cs b/Selector.Web/Areas/Identity/Pages/Account/Manage/Email.cshtml.cs new file mode 100644 index 0000000..3f6899f --- /dev/null +++ b/Selector.Web/Areas/Identity/Pages/Account/Manage/Email.cshtml.cs @@ -0,0 +1,150 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Text; +using System.Text.Encodings.Web; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Identity.UI.Services; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.AspNetCore.WebUtilities; +using Selector.Web.Areas.Identity.Data; + +using Selector.Model; + +namespace Selector.Web.Areas.Identity.Pages.Account.Manage +{ + public partial class EmailModel : PageModel + { + private readonly UserManager _userManager; + private readonly SignInManager _signInManager; + private readonly IEmailSender _emailSender; + + public EmailModel( + UserManager userManager, + SignInManager signInManager, + IEmailSender emailSender) + { + _userManager = userManager; + _signInManager = signInManager; + _emailSender = emailSender; + } + + public string Username { get; set; } + + public string Email { get; set; } + + public bool IsEmailConfirmed { get; set; } + + [TempData] + public string StatusMessage { get; set; } + + [BindProperty] + public InputModel Input { get; set; } + + public class InputModel + { + [Required] + [EmailAddress] + [Display(Name = "New email")] + public string NewEmail { get; set; } + } + + private async Task LoadAsync(ApplicationUser user) + { + var email = await _userManager.GetEmailAsync(user); + Email = email; + + Input = new InputModel + { + NewEmail = email, + }; + + IsEmailConfirmed = await _userManager.IsEmailConfirmedAsync(user); + } + + public async Task OnGetAsync() + { + var user = await _userManager.GetUserAsync(User); + if (user == null) + { + return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + await LoadAsync(user); + return Page(); + } + + public async Task OnPostChangeEmailAsync() + { + var user = await _userManager.GetUserAsync(User); + if (user == null) + { + return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + if (!ModelState.IsValid) + { + await LoadAsync(user); + return Page(); + } + + var email = await _userManager.GetEmailAsync(user); + if (Input.NewEmail != email) + { + var userId = await _userManager.GetUserIdAsync(user); + var code = await _userManager.GenerateChangeEmailTokenAsync(user, Input.NewEmail); + code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code)); + var callbackUrl = Url.Page( + "/Account/ConfirmEmailChange", + pageHandler: null, + values: new { userId = userId, email = Input.NewEmail, code = code }, + protocol: Request.Scheme); + await _emailSender.SendEmailAsync( + Input.NewEmail, + "Confirm your email", + $"Please confirm your account by clicking here."); + + StatusMessage = "Confirmation link to change email sent. Please check your email."; + return RedirectToPage(); + } + + StatusMessage = "Your email is unchanged."; + return RedirectToPage(); + } + + public async Task OnPostSendVerificationEmailAsync() + { + var user = await _userManager.GetUserAsync(User); + if (user == null) + { + return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + if (!ModelState.IsValid) + { + await LoadAsync(user); + return Page(); + } + + var userId = await _userManager.GetUserIdAsync(user); + var email = await _userManager.GetEmailAsync(user); + var code = await _userManager.GenerateEmailConfirmationTokenAsync(user); + code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code)); + var callbackUrl = Url.Page( + "/Account/ConfirmEmail", + pageHandler: null, + values: new { area = "Identity", userId = userId, code = code }, + protocol: Request.Scheme); + await _emailSender.SendEmailAsync( + email, + "Confirm your email", + $"Please confirm your account by clicking here."); + + StatusMessage = "Verification email sent. Please check your email."; + return RedirectToPage(); + } + } +} diff --git a/Selector.Web/Areas/Identity/Pages/Account/Manage/EnableAuthenticator.cshtml b/Selector.Web/Areas/Identity/Pages/Account/Manage/EnableAuthenticator.cshtml new file mode 100644 index 0000000..6ea8510 --- /dev/null +++ b/Selector.Web/Areas/Identity/Pages/Account/Manage/EnableAuthenticator.cshtml @@ -0,0 +1,53 @@ +@page +@model EnableAuthenticatorModel +@{ + ViewData["Title"] = "Configure authenticator app"; + ViewData["ActivePage"] = ManageNavPages.TwoFactorAuthentication; +} + + +

@ViewData["Title"]

+
+

To use an authenticator app go through the following steps:

+
    +
  1. +

    + Download a two-factor authenticator app like Microsoft Authenticator for + Android and + iOS or + Google Authenticator for + Android and + iOS. +

    +
  2. +
  3. +

    Scan the QR Code or enter this key @Model.SharedKey into your two factor authenticator app. Spaces and casing do not matter.

    + +
    +
    +
  4. +
  5. +

    + Once you have scanned the QR code or input the key above, your two factor authentication app will provide you + with a unique code. Enter the code in the confirmation box below. +

    +
    +
    +
    +
    + + + +
    + +
    +
    +
    +
    +
  6. +
+
+ +@section Scripts { + +} diff --git a/Selector.Web/Areas/Identity/Pages/Account/Manage/EnableAuthenticator.cshtml.cs b/Selector.Web/Areas/Identity/Pages/Account/Manage/EnableAuthenticator.cshtml.cs new file mode 100644 index 0000000..60ef4df --- /dev/null +++ b/Selector.Web/Areas/Identity/Pages/Account/Manage/EnableAuthenticator.cshtml.cs @@ -0,0 +1,159 @@ +using System; +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; +using System.Collections.Generic; +using System.Text; +using System.Text.Encodings.Web; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.Extensions.Logging; +using Selector.Web.Areas.Identity.Data; + +using Selector.Model; + +namespace Selector.Web.Areas.Identity.Pages.Account.Manage +{ + public class EnableAuthenticatorModel : PageModel + { + private readonly UserManager _userManager; + private readonly ILogger _logger; + private readonly UrlEncoder _urlEncoder; + + private const string AuthenticatorUriFormat = "otpauth://totp/{0}:{1}?secret={2}&issuer={0}&digits=6"; + + public EnableAuthenticatorModel( + UserManager userManager, + ILogger logger, + UrlEncoder urlEncoder) + { + _userManager = userManager; + _logger = logger; + _urlEncoder = urlEncoder; + } + + public string SharedKey { get; set; } + + public string AuthenticatorUri { get; set; } + + [TempData] + public string[] RecoveryCodes { get; set; } + + [TempData] + public string StatusMessage { get; set; } + + [BindProperty] + public InputModel Input { get; set; } + + public class InputModel + { + [Required] + [StringLength(7, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)] + [DataType(DataType.Text)] + [Display(Name = "Verification Code")] + public string Code { get; set; } + } + + public async Task OnGetAsync() + { + var user = await _userManager.GetUserAsync(User); + if (user == null) + { + return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + await LoadSharedKeyAndQrCodeUriAsync(user); + + return Page(); + } + + public async Task OnPostAsync() + { + var user = await _userManager.GetUserAsync(User); + if (user == null) + { + return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + if (!ModelState.IsValid) + { + await LoadSharedKeyAndQrCodeUriAsync(user); + return Page(); + } + + // Strip spaces and hypens + var verificationCode = Input.Code.Replace(" ", string.Empty).Replace("-", string.Empty); + + var is2faTokenValid = await _userManager.VerifyTwoFactorTokenAsync( + user, _userManager.Options.Tokens.AuthenticatorTokenProvider, verificationCode); + + if (!is2faTokenValid) + { + ModelState.AddModelError("Input.Code", "Verification code is invalid."); + await LoadSharedKeyAndQrCodeUriAsync(user); + return Page(); + } + + await _userManager.SetTwoFactorEnabledAsync(user, true); + var userId = await _userManager.GetUserIdAsync(user); + _logger.LogInformation("User with ID '{UserId}' has enabled 2FA with an authenticator app.", userId); + + StatusMessage = "Your authenticator app has been verified."; + + if (await _userManager.CountRecoveryCodesAsync(user) == 0) + { + var recoveryCodes = await _userManager.GenerateNewTwoFactorRecoveryCodesAsync(user, 10); + RecoveryCodes = recoveryCodes.ToArray(); + return RedirectToPage("./ShowRecoveryCodes"); + } + else + { + return RedirectToPage("./TwoFactorAuthentication"); + } + } + + private async Task LoadSharedKeyAndQrCodeUriAsync(ApplicationUser user) + { + // Load the authenticator key & QR code URI to display on the form + var unformattedKey = await _userManager.GetAuthenticatorKeyAsync(user); + if (string.IsNullOrEmpty(unformattedKey)) + { + await _userManager.ResetAuthenticatorKeyAsync(user); + unformattedKey = await _userManager.GetAuthenticatorKeyAsync(user); + } + + SharedKey = FormatKey(unformattedKey); + + var email = await _userManager.GetEmailAsync(user); + AuthenticatorUri = GenerateQrCodeUri(email, unformattedKey); + } + + private string FormatKey(string unformattedKey) + { + var result = new StringBuilder(); + int currentPosition = 0; + while (currentPosition + 4 < unformattedKey.Length) + { + result.Append(unformattedKey.Substring(currentPosition, 4)).Append(" "); + currentPosition += 4; + } + if (currentPosition < unformattedKey.Length) + { + result.Append(unformattedKey.Substring(currentPosition)); + } + + return result.ToString().ToLowerInvariant(); + } + + private string GenerateQrCodeUri(string email, string unformattedKey) + { + return string.Format( + AuthenticatorUriFormat, + _urlEncoder.Encode("Selector.Web"), + _urlEncoder.Encode(email), + unformattedKey); + } + } +} diff --git a/Selector.Web/Areas/Identity/Pages/Account/Manage/ExternalLogins.cshtml b/Selector.Web/Areas/Identity/Pages/Account/Manage/ExternalLogins.cshtml new file mode 100644 index 0000000..d7a3c42 --- /dev/null +++ b/Selector.Web/Areas/Identity/Pages/Account/Manage/ExternalLogins.cshtml @@ -0,0 +1,53 @@ +@page +@model ExternalLoginsModel +@{ + ViewData["Title"] = "Manage your external logins"; + ViewData["ActivePage"] = ManageNavPages.ExternalLogins; +} + + +@if (Model.CurrentLogins?.Count > 0) +{ +

Registered Logins

+ + + @foreach (var login in Model.CurrentLogins) + { + + + + + } + +
@login.ProviderDisplayName + @if (Model.ShowRemoveButton) + { +
+
+ + + +
+
+ } + else + { + @:   + } +
+} +@if (Model.OtherLogins?.Count > 0) +{ +

Add another service to log in.

+
+ +} diff --git a/Selector.Web/Areas/Identity/Pages/Account/Manage/ExternalLogins.cshtml.cs b/Selector.Web/Areas/Identity/Pages/Account/Manage/ExternalLogins.cshtml.cs new file mode 100644 index 0000000..8775640 --- /dev/null +++ b/Selector.Web/Areas/Identity/Pages/Account/Manage/ExternalLogins.cshtml.cs @@ -0,0 +1,112 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Selector.Web.Areas.Identity.Data; + +using Selector.Model; + +namespace Selector.Web.Areas.Identity.Pages.Account.Manage +{ + public class ExternalLoginsModel : PageModel + { + private readonly UserManager _userManager; + private readonly SignInManager _signInManager; + + public ExternalLoginsModel( + UserManager userManager, + SignInManager signInManager) + { + _userManager = userManager; + _signInManager = signInManager; + } + + public IList CurrentLogins { get; set; } + + public IList OtherLogins { get; set; } + + public bool ShowRemoveButton { get; set; } + + [TempData] + public string StatusMessage { get; set; } + + public async Task OnGetAsync() + { + var user = await _userManager.GetUserAsync(User); + if (user == null) + { + return NotFound($"Unable to load user with ID 'user.Id'."); + } + + CurrentLogins = await _userManager.GetLoginsAsync(user); + OtherLogins = (await _signInManager.GetExternalAuthenticationSchemesAsync()) + .Where(auth => CurrentLogins.All(ul => auth.Name != ul.LoginProvider)) + .ToList(); + ShowRemoveButton = user.PasswordHash != null || CurrentLogins.Count > 1; + return Page(); + } + + public async Task OnPostRemoveLoginAsync(string loginProvider, string providerKey) + { + var user = await _userManager.GetUserAsync(User); + if (user == null) + { + return NotFound($"Unable to load user with ID 'user.Id'."); + } + + var result = await _userManager.RemoveLoginAsync(user, loginProvider, providerKey); + if (!result.Succeeded) + { + StatusMessage = "The external login was not removed."; + return RedirectToPage(); + } + + await _signInManager.RefreshSignInAsync(user); + StatusMessage = "The external login was removed."; + return RedirectToPage(); + } + + public async Task OnPostLinkLoginAsync(string provider) + { + // Clear the existing external cookie to ensure a clean login process + await HttpContext.SignOutAsync(IdentityConstants.ExternalScheme); + + // Request a redirect to the external login provider to link a login for the current user + var redirectUrl = Url.Page("./ExternalLogins", pageHandler: "LinkLoginCallback"); + var properties = _signInManager.ConfigureExternalAuthenticationProperties(provider, redirectUrl, _userManager.GetUserId(User)); + return new ChallengeResult(provider, properties); + } + + public async Task OnGetLinkLoginCallbackAsync() + { + var user = await _userManager.GetUserAsync(User); + if (user == null) + { + return NotFound($"Unable to load user with ID 'user.Id'."); + } + + var info = await _signInManager.GetExternalLoginInfoAsync(user.Id); + if (info == null) + { + throw new InvalidOperationException($"Unexpected error occurred loading external login info for user with ID '{user.Id}'."); + } + + var result = await _userManager.AddLoginAsync(user, info); + if (!result.Succeeded) + { + StatusMessage = "The external login was not added. External logins can only be associated with one account."; + return RedirectToPage(); + } + + // Clear the existing external cookie to ensure a clean login process + await HttpContext.SignOutAsync(IdentityConstants.ExternalScheme); + + StatusMessage = "The external login was added."; + return RedirectToPage(); + } + } +} diff --git a/Selector.Web/Areas/Identity/Pages/Account/Manage/GenerateRecoveryCodes.cshtml b/Selector.Web/Areas/Identity/Pages/Account/Manage/GenerateRecoveryCodes.cshtml new file mode 100644 index 0000000..284ab59 --- /dev/null +++ b/Selector.Web/Areas/Identity/Pages/Account/Manage/GenerateRecoveryCodes.cshtml @@ -0,0 +1,27 @@ +@page +@model GenerateRecoveryCodesModel +@{ + ViewData["Title"] = "Generate two-factor authentication (2FA) recovery codes"; + ViewData["ActivePage"] = ManageNavPages.TwoFactorAuthentication; +} + + +

@ViewData["Title"]

+ +
+
+ +
+
\ No newline at end of file diff --git a/Selector.Web/Areas/Identity/Pages/Account/Manage/GenerateRecoveryCodes.cshtml.cs b/Selector.Web/Areas/Identity/Pages/Account/Manage/GenerateRecoveryCodes.cshtml.cs new file mode 100644 index 0000000..e21022a --- /dev/null +++ b/Selector.Web/Areas/Identity/Pages/Account/Manage/GenerateRecoveryCodes.cshtml.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.Extensions.Logging; +using Selector.Web.Areas.Identity.Data; + +using Selector.Model; + +namespace Selector.Web.Areas.Identity.Pages.Account.Manage +{ + public class GenerateRecoveryCodesModel : PageModel + { + private readonly UserManager _userManager; + private readonly ILogger _logger; + + public GenerateRecoveryCodesModel( + UserManager userManager, + ILogger logger) + { + _userManager = userManager; + _logger = logger; + } + + [TempData] + public string[] RecoveryCodes { get; set; } + + [TempData] + public string StatusMessage { get; set; } + + public async Task OnGetAsync() + { + var user = await _userManager.GetUserAsync(User); + if (user == null) + { + return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + var isTwoFactorEnabled = await _userManager.GetTwoFactorEnabledAsync(user); + if (!isTwoFactorEnabled) + { + var userId = await _userManager.GetUserIdAsync(user); + throw new InvalidOperationException($"Cannot generate recovery codes for user with ID '{userId}' because they do not have 2FA enabled."); + } + + return Page(); + } + + public async Task OnPostAsync() + { + var user = await _userManager.GetUserAsync(User); + if (user == null) + { + return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + var isTwoFactorEnabled = await _userManager.GetTwoFactorEnabledAsync(user); + var userId = await _userManager.GetUserIdAsync(user); + if (!isTwoFactorEnabled) + { + throw new InvalidOperationException($"Cannot generate recovery codes for user with ID '{userId}' as they do not have 2FA enabled."); + } + + var recoveryCodes = await _userManager.GenerateNewTwoFactorRecoveryCodesAsync(user, 10); + RecoveryCodes = recoveryCodes.ToArray(); + + _logger.LogInformation("User with ID '{UserId}' has generated new 2FA recovery codes.", userId); + StatusMessage = "You have generated new recovery codes."; + return RedirectToPage("./ShowRecoveryCodes"); + } + } +} \ No newline at end of file diff --git a/Selector.Web/Areas/Identity/Pages/Account/Manage/Index.cshtml b/Selector.Web/Areas/Identity/Pages/Account/Manage/Index.cshtml new file mode 100644 index 0000000..2a18fdb --- /dev/null +++ b/Selector.Web/Areas/Identity/Pages/Account/Manage/Index.cshtml @@ -0,0 +1,30 @@ +@page +@model IndexModel +@{ + ViewData["Title"] = "Profile"; + ViewData["ActivePage"] = ManageNavPages.Index; +} + +

@ViewData["Title"]

+ +
+
+
+
+
+ + +
+
+ + + +
+ +
+
+
+ +@section Scripts { + +} \ No newline at end of file diff --git a/Selector.Web/Areas/Identity/Pages/Account/Manage/Index.cshtml.cs b/Selector.Web/Areas/Identity/Pages/Account/Manage/Index.cshtml.cs new file mode 100644 index 0000000..a262199 --- /dev/null +++ b/Selector.Web/Areas/Identity/Pages/Account/Manage/Index.cshtml.cs @@ -0,0 +1,98 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Selector.Web.Areas.Identity.Data; + +using Selector.Model; + +namespace Selector.Web.Areas.Identity.Pages.Account.Manage +{ + public partial class IndexModel : PageModel + { + private readonly UserManager _userManager; + private readonly SignInManager _signInManager; + + public IndexModel( + UserManager userManager, + SignInManager signInManager) + { + _userManager = userManager; + _signInManager = signInManager; + } + + public string Username { get; set; } + + [TempData] + public string StatusMessage { get; set; } + + [BindProperty] + public InputModel Input { get; set; } + + public class InputModel + { + [Phone] + [Display(Name = "Phone number")] + public string PhoneNumber { get; set; } + } + + private async Task LoadAsync(ApplicationUser user) + { + var userName = await _userManager.GetUserNameAsync(user); + var phoneNumber = await _userManager.GetPhoneNumberAsync(user); + + Username = userName; + + Input = new InputModel + { + PhoneNumber = phoneNumber + }; + } + + public async Task OnGetAsync() + { + var user = await _userManager.GetUserAsync(User); + if (user == null) + { + return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + await LoadAsync(user); + return Page(); + } + + public async Task OnPostAsync() + { + var user = await _userManager.GetUserAsync(User); + if (user == null) + { + return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + if (!ModelState.IsValid) + { + await LoadAsync(user); + return Page(); + } + + var phoneNumber = await _userManager.GetPhoneNumberAsync(user); + if (Input.PhoneNumber != phoneNumber) + { + var setPhoneResult = await _userManager.SetPhoneNumberAsync(user, Input.PhoneNumber); + if (!setPhoneResult.Succeeded) + { + StatusMessage = "Unexpected error when trying to set phone number."; + return RedirectToPage(); + } + } + + await _signInManager.RefreshSignInAsync(user); + StatusMessage = "Your profile has been updated"; + return RedirectToPage(); + } + } +} diff --git a/Selector.Web/Areas/Identity/Pages/Account/Manage/Lastfm.cshtml b/Selector.Web/Areas/Identity/Pages/Account/Manage/Lastfm.cshtml new file mode 100644 index 0000000..1c2166b --- /dev/null +++ b/Selector.Web/Areas/Identity/Pages/Account/Manage/Lastfm.cshtml @@ -0,0 +1,26 @@ +@page +@model LastFmModel +@{ + ViewData["Title"] = "Last.fm"; + ViewData["ActivePage"] = ManageNavPages.LastFm; +} + +

@ViewData["Title"]

+ +
+
+
+
+
+ + + +
+ +
+
+
+ +@section Scripts { + +} diff --git a/Selector.Web/Areas/Identity/Pages/Account/Manage/Lastfm.cshtml.cs b/Selector.Web/Areas/Identity/Pages/Account/Manage/Lastfm.cshtml.cs new file mode 100644 index 0000000..e9d84f5 --- /dev/null +++ b/Selector.Web/Areas/Identity/Pages/Account/Manage/Lastfm.cshtml.cs @@ -0,0 +1,88 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Text; +using System.Text.Encodings.Web; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Identity.UI.Services; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.AspNetCore.WebUtilities; + +using Selector.Model; + +namespace Selector.Web.Areas.Identity.Pages.Account.Manage +{ + public partial class LastFmModel : PageModel + { + private readonly UserManager _userManager; + + public LastFmModel( + UserManager userManager) + { + _userManager = userManager; + } + + [TempData] + public string StatusMessage { get; set; } + + [BindProperty] + public InputModel Input { get; set; } + + public class InputModel + { + [Required] + [Display(Name = "Username")] + public string Username { get; set; } + } + + private async Task LoadAsync(ApplicationUser user) + { + Input = new InputModel + { + Username = user.LastFmUsername, + }; + } + + public async Task OnGetAsync() + { + var user = await _userManager.GetUserAsync(User); + if (user == null) + { + return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + await LoadAsync(user); + return Page(); + } + + public async Task OnPostChangeUsernameAsync() + { + var user = await _userManager.GetUserAsync(User); + if (user == null) + { + return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + if (!ModelState.IsValid) + { + await LoadAsync(user); + return Page(); + } + + if (Input.Username != user.LastFmUsername) + { + user.LastFmUsername = Input.Username; + _userManager.UpdateAsync(user); + + StatusMessage = "Username changed"; + return RedirectToPage(); + } + + StatusMessage = "Username unchanged"; + return RedirectToPage(); + } + } +} diff --git a/Selector.Web/Areas/Identity/Pages/Account/Manage/ManageNavPages.cs b/Selector.Web/Areas/Identity/Pages/Account/Manage/ManageNavPages.cs new file mode 100644 index 0000000..2fb4a76 --- /dev/null +++ b/Selector.Web/Areas/Identity/Pages/Account/Manage/ManageNavPages.cs @@ -0,0 +1,56 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc.Rendering; + +namespace Selector.Web.Areas.Identity.Pages.Account.Manage +{ + public static class ManageNavPages + { + public static string Index => "Index"; + + public static string Email => "Email"; + + public static string ChangePassword => "ChangePassword"; + + public static string DownloadPersonalData => "DownloadPersonalData"; + + public static string DeletePersonalData => "DeletePersonalData"; + + public static string ExternalLogins => "ExternalLogins"; + + public static string PersonalData => "PersonalData"; + + public static string TwoFactorAuthentication => "TwoFactorAuthentication"; + + public static string LastFm => "LastFm"; + + public static string Spotify => "Spotify"; + + public static string IndexNavClass(ViewContext viewContext) => PageNavClass(viewContext, Index); + + public static string EmailNavClass(ViewContext viewContext) => PageNavClass(viewContext, Email); + + public static string ChangePasswordNavClass(ViewContext viewContext) => PageNavClass(viewContext, ChangePassword); + + public static string DownloadPersonalDataNavClass(ViewContext viewContext) => PageNavClass(viewContext, DownloadPersonalData); + + public static string DeletePersonalDataNavClass(ViewContext viewContext) => PageNavClass(viewContext, DeletePersonalData); + + public static string ExternalLoginsNavClass(ViewContext viewContext) => PageNavClass(viewContext, ExternalLogins); + + public static string PersonalDataNavClass(ViewContext viewContext) => PageNavClass(viewContext, PersonalData); + + public static string TwoFactorAuthenticationNavClass(ViewContext viewContext) => PageNavClass(viewContext, TwoFactorAuthentication); + public static string LastFmNavClass(ViewContext viewContext) => PageNavClass(viewContext, LastFm); + public static string SpotifyNavClass(ViewContext viewContext) => PageNavClass(viewContext, Spotify); + + private static string PageNavClass(ViewContext viewContext, string page) + { + var activePage = viewContext.ViewData["ActivePage"] as string + ?? System.IO.Path.GetFileNameWithoutExtension(viewContext.ActionDescriptor.DisplayName); + return string.Equals(activePage, page, StringComparison.OrdinalIgnoreCase) ? "active" : null; + } + } +} diff --git a/Selector.Web/Areas/Identity/Pages/Account/Manage/PersonalData.cshtml b/Selector.Web/Areas/Identity/Pages/Account/Manage/PersonalData.cshtml new file mode 100644 index 0000000..d64bd82 --- /dev/null +++ b/Selector.Web/Areas/Identity/Pages/Account/Manage/PersonalData.cshtml @@ -0,0 +1,27 @@ +@page +@model PersonalDataModel +@{ + ViewData["Title"] = "Personal Data"; + ViewData["ActivePage"] = ManageNavPages.PersonalData; +} + +

@ViewData["Title"]

+ +
+
+

Your account contains personal data that you have given us. This page allows you to download or delete that data.

+

+ Deleting this data will permanently remove your account, and this cannot be recovered. +

+
+ +
+

+ Delete +

+
+
+ +@section Scripts { + +} diff --git a/Selector.Web/Areas/Identity/Pages/Account/Manage/PersonalData.cshtml.cs b/Selector.Web/Areas/Identity/Pages/Account/Manage/PersonalData.cshtml.cs new file mode 100644 index 0000000..64212d4 --- /dev/null +++ b/Selector.Web/Areas/Identity/Pages/Account/Manage/PersonalData.cshtml.cs @@ -0,0 +1,36 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.Extensions.Logging; +using Selector.Web.Areas.Identity.Data; + +using Selector.Model; + +namespace Selector.Web.Areas.Identity.Pages.Account.Manage +{ + public class PersonalDataModel : PageModel + { + private readonly UserManager _userManager; + private readonly ILogger _logger; + + public PersonalDataModel( + UserManager userManager, + ILogger logger) + { + _userManager = userManager; + _logger = logger; + } + + public async Task OnGet() + { + var user = await _userManager.GetUserAsync(User); + if (user == null) + { + return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + return Page(); + } + } +} \ No newline at end of file diff --git a/Selector.Web/Areas/Identity/Pages/Account/Manage/ResetAuthenticator.cshtml b/Selector.Web/Areas/Identity/Pages/Account/Manage/ResetAuthenticator.cshtml new file mode 100644 index 0000000..081c824 --- /dev/null +++ b/Selector.Web/Areas/Identity/Pages/Account/Manage/ResetAuthenticator.cshtml @@ -0,0 +1,24 @@ +@page +@model ResetAuthenticatorModel +@{ + ViewData["Title"] = "Reset authenticator key"; + ViewData["ActivePage"] = ManageNavPages.TwoFactorAuthentication; +} + + +

@ViewData["Title"]

+ +
+
+ +
+
\ No newline at end of file diff --git a/Selector.Web/Areas/Identity/Pages/Account/Manage/ResetAuthenticator.cshtml.cs b/Selector.Web/Areas/Identity/Pages/Account/Manage/ResetAuthenticator.cshtml.cs new file mode 100644 index 0000000..8eaa950 --- /dev/null +++ b/Selector.Web/Areas/Identity/Pages/Account/Manage/ResetAuthenticator.cshtml.cs @@ -0,0 +1,63 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.Extensions.Logging; +using Selector.Web.Areas.Identity.Data; + +using Selector.Model; + +namespace Selector.Web.Areas.Identity.Pages.Account.Manage +{ + public class ResetAuthenticatorModel : PageModel + { + UserManager _userManager; + private readonly SignInManager _signInManager; + ILogger _logger; + + public ResetAuthenticatorModel( + UserManager userManager, + SignInManager signInManager, + ILogger logger) + { + _userManager = userManager; + _signInManager = signInManager; + _logger = logger; + } + + [TempData] + public string StatusMessage { get; set; } + + public async Task OnGet() + { + var user = await _userManager.GetUserAsync(User); + if (user == null) + { + return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + return Page(); + } + + public async Task OnPostAsync() + { + var user = await _userManager.GetUserAsync(User); + if (user == null) + { + return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + await _userManager.SetTwoFactorEnabledAsync(user, false); + await _userManager.ResetAuthenticatorKeyAsync(user); + _logger.LogInformation("User with ID '{UserId}' has reset their authentication app key.", user.Id); + + await _signInManager.RefreshSignInAsync(user); + StatusMessage = "Your authenticator app key has been reset, you will need to configure your authenticator app using the new key."; + + return RedirectToPage("./EnableAuthenticator"); + } + } +} \ No newline at end of file diff --git a/Selector.Web/Areas/Identity/Pages/Account/Manage/SetPassword.cshtml b/Selector.Web/Areas/Identity/Pages/Account/Manage/SetPassword.cshtml new file mode 100644 index 0000000..f1817aa --- /dev/null +++ b/Selector.Web/Areas/Identity/Pages/Account/Manage/SetPassword.cshtml @@ -0,0 +1,35 @@ +@page +@model SetPasswordModel +@{ + ViewData["Title"] = "Set password"; + ViewData["ActivePage"] = ManageNavPages.ChangePassword; +} + +

Set your password

+ +

+ You do not have a local username/password for this site. Add a local + account so you can log in without an external login. +

+
+
+
+
+
+ + + +
+
+ + + +
+ +
+
+
+ +@section Scripts { + +} \ No newline at end of file diff --git a/Selector.Web/Areas/Identity/Pages/Account/Manage/SetPassword.cshtml.cs b/Selector.Web/Areas/Identity/Pages/Account/Manage/SetPassword.cshtml.cs new file mode 100644 index 0000000..9a1724b --- /dev/null +++ b/Selector.Web/Areas/Identity/Pages/Account/Manage/SetPassword.cshtml.cs @@ -0,0 +1,95 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Selector.Web.Areas.Identity.Data; + +using Selector.Model; + +namespace Selector.Web.Areas.Identity.Pages.Account.Manage +{ + public class SetPasswordModel : PageModel + { + private readonly UserManager _userManager; + private readonly SignInManager _signInManager; + + public SetPasswordModel( + UserManager userManager, + SignInManager signInManager) + { + _userManager = userManager; + _signInManager = signInManager; + } + + [BindProperty] + public InputModel Input { get; set; } + + [TempData] + public string StatusMessage { get; set; } + + public class InputModel + { + [Required] + [StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)] + [DataType(DataType.Password)] + [Display(Name = "New password")] + public string NewPassword { get; set; } + + [DataType(DataType.Password)] + [Display(Name = "Confirm new password")] + [Compare("NewPassword", ErrorMessage = "The new password and confirmation password do not match.")] + public string ConfirmPassword { get; set; } + } + + public async Task OnGetAsync() + { + var user = await _userManager.GetUserAsync(User); + if (user == null) + { + return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + var hasPassword = await _userManager.HasPasswordAsync(user); + + if (hasPassword) + { + return RedirectToPage("./ChangePassword"); + } + + return Page(); + } + + public async Task OnPostAsync() + { + if (!ModelState.IsValid) + { + return Page(); + } + + var user = await _userManager.GetUserAsync(User); + if (user == null) + { + return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + var addPasswordResult = await _userManager.AddPasswordAsync(user, Input.NewPassword); + if (!addPasswordResult.Succeeded) + { + foreach (var error in addPasswordResult.Errors) + { + ModelState.AddModelError(string.Empty, error.Description); + } + return Page(); + } + + await _signInManager.RefreshSignInAsync(user); + StatusMessage = "Your password has been set."; + + return RedirectToPage(); + } + } +} diff --git a/Selector.Web/Areas/Identity/Pages/Account/Manage/ShowRecoveryCodes.cshtml b/Selector.Web/Areas/Identity/Pages/Account/Manage/ShowRecoveryCodes.cshtml new file mode 100644 index 0000000..23fa27b --- /dev/null +++ b/Selector.Web/Areas/Identity/Pages/Account/Manage/ShowRecoveryCodes.cshtml @@ -0,0 +1,25 @@ +@page +@model ShowRecoveryCodesModel +@{ + ViewData["Title"] = "Recovery codes"; + ViewData["ActivePage"] = "TwoFactorAuthentication"; +} + + +

@ViewData["Title"]

+ +
+
+ @for (var row = 0; row < Model.RecoveryCodes.Length; row += 2) + { + @Model.RecoveryCodes[row] @Model.RecoveryCodes[row + 1]
+ } +
+
\ No newline at end of file diff --git a/Selector.Web/Areas/Identity/Pages/Account/Manage/ShowRecoveryCodes.cshtml.cs b/Selector.Web/Areas/Identity/Pages/Account/Manage/ShowRecoveryCodes.cshtml.cs new file mode 100644 index 0000000..2dc2b02 --- /dev/null +++ b/Selector.Web/Areas/Identity/Pages/Account/Manage/ShowRecoveryCodes.cshtml.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.Extensions.Logging; +using Selector.Web.Areas.Identity.Data; + +namespace Selector.Web.Areas.Identity.Pages.Account.Manage +{ + public class ShowRecoveryCodesModel : PageModel + { + [TempData] + public string[] RecoveryCodes { get; set; } + + [TempData] + public string StatusMessage { get; set; } + + public IActionResult OnGet() + { + if (RecoveryCodes == null || RecoveryCodes.Length == 0) + { + return RedirectToPage("./TwoFactorAuthentication"); + } + + return Page(); + } + } +} diff --git a/Selector.Web/Areas/Identity/Pages/Account/Manage/Spotify.cshtml b/Selector.Web/Areas/Identity/Pages/Account/Manage/Spotify.cshtml new file mode 100644 index 0000000..10e7a81 --- /dev/null +++ b/Selector.Web/Areas/Identity/Pages/Account/Manage/Spotify.cshtml @@ -0,0 +1,27 @@ +@page +@model SpotifyModel +@{ + ViewData["Title"] = "Spotify"; + ViewData["ActivePage"] = ManageNavPages.Spotify; +} + +

@ViewData["Title"]

+ +
+
+
+ @if (Model.SpotifyIsLinked) + { + + } + else + { + + } +
+
+
+ +@section Scripts { + +} diff --git a/Selector.Web/Areas/Identity/Pages/Account/Manage/Spotify.cshtml.cs b/Selector.Web/Areas/Identity/Pages/Account/Manage/Spotify.cshtml.cs new file mode 100644 index 0000000..0495eac --- /dev/null +++ b/Selector.Web/Areas/Identity/Pages/Account/Manage/Spotify.cshtml.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Text; +using System.Text.Encodings.Web; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Identity.UI.Services; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.AspNetCore.WebUtilities; + +using Selector.Model; + +namespace Selector.Web.Areas.Identity.Pages.Account.Manage +{ + public partial class SpotifyModel : PageModel + { + private readonly UserManager _userManager; + + public SpotifyModel( + UserManager userManager) + { + _userManager = userManager; + } + + [BindProperty] + public bool SpotifyIsLinked { get; set; } + + [TempData] + public string StatusMessage { get; set; } + + public async Task OnGetAsync() + { + var user = await _userManager.GetUserAsync(User); + if (user == null) + { + return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + SpotifyIsLinked = user.SpotifyIsLinked; + + return Page(); + } + + public async Task OnPostLinkAsync() + { + StatusMessage = "Spotify Linked"; + return RedirectToPage(); + } + + public async Task OnPostUnlinkAsync() + { + StatusMessage = "Spotify Unlinked"; + return RedirectToPage(); + } + } +} diff --git a/Selector.Web/Areas/Identity/Pages/Account/Manage/TwoFactorAuthentication.cshtml b/Selector.Web/Areas/Identity/Pages/Account/Manage/TwoFactorAuthentication.cshtml new file mode 100644 index 0000000..a1729eb --- /dev/null +++ b/Selector.Web/Areas/Identity/Pages/Account/Manage/TwoFactorAuthentication.cshtml @@ -0,0 +1,57 @@ +@page +@model TwoFactorAuthenticationModel +@{ + ViewData["Title"] = "Two-factor authentication (2FA)"; + ViewData["ActivePage"] = ManageNavPages.TwoFactorAuthentication; +} + + +

@ViewData["Title"]

+@if (Model.Is2faEnabled) +{ + if (Model.RecoveryCodesLeft == 0) + { +
+ You have no recovery codes left. +

You must generate a new set of recovery codes before you can log in with a recovery code.

+
+ } + else if (Model.RecoveryCodesLeft == 1) + { +
+ You have 1 recovery code left. +

You can generate a new set of recovery codes.

+
+ } + else if (Model.RecoveryCodesLeft <= 3) + { +
+ You have @Model.RecoveryCodesLeft recovery codes left. +

You should generate a new set of recovery codes.

+
+ } + + if (Model.IsMachineRemembered) + { +
+ +
+ } + Disable 2FA + Reset recovery codes +} + +
Authenticator app
+@if (!Model.HasAuthenticator) +{ + Add authenticator app +} +else +{ + Setup authenticator app + Reset authenticator app +} + +@section Scripts { + +} \ No newline at end of file diff --git a/Selector.Web/Areas/Identity/Pages/Account/Manage/TwoFactorAuthentication.cshtml.cs b/Selector.Web/Areas/Identity/Pages/Account/Manage/TwoFactorAuthentication.cshtml.cs new file mode 100644 index 0000000..ff6a546 --- /dev/null +++ b/Selector.Web/Areas/Identity/Pages/Account/Manage/TwoFactorAuthentication.cshtml.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.Extensions.Logging; +using Selector.Web.Areas.Identity.Data; + +using Selector.Model; + +namespace Selector.Web.Areas.Identity.Pages.Account.Manage +{ + public class TwoFactorAuthenticationModel : PageModel + { + private const string AuthenicatorUriFormat = "otpauth://totp/{0}:{1}?secret={2}&issuer={0}"; + + private readonly UserManager _userManager; + private readonly SignInManager _signInManager; + private readonly ILogger _logger; + + public TwoFactorAuthenticationModel( + UserManager userManager, + SignInManager signInManager, + ILogger logger) + { + _userManager = userManager; + _signInManager = signInManager; + _logger = logger; + } + + public bool HasAuthenticator { get; set; } + + public int RecoveryCodesLeft { get; set; } + + [BindProperty] + public bool Is2faEnabled { get; set; } + + public bool IsMachineRemembered { get; set; } + + [TempData] + public string StatusMessage { get; set; } + + public async Task OnGet() + { + var user = await _userManager.GetUserAsync(User); + if (user == null) + { + return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + HasAuthenticator = await _userManager.GetAuthenticatorKeyAsync(user) != null; + Is2faEnabled = await _userManager.GetTwoFactorEnabledAsync(user); + IsMachineRemembered = await _signInManager.IsTwoFactorClientRememberedAsync(user); + RecoveryCodesLeft = await _userManager.CountRecoveryCodesAsync(user); + + return Page(); + } + + public async Task OnPost() + { + var user = await _userManager.GetUserAsync(User); + if (user == null) + { + return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + await _signInManager.ForgetTwoFactorClientAsync(); + StatusMessage = "The current browser has been forgotten. When you login again from this browser you will be prompted for your 2fa code."; + return RedirectToPage(); + } + } +} \ No newline at end of file diff --git a/Selector.Web/Areas/Identity/Pages/Account/Manage/_Layout.cshtml b/Selector.Web/Areas/Identity/Pages/Account/Manage/_Layout.cshtml new file mode 100644 index 0000000..3d882cc --- /dev/null +++ b/Selector.Web/Areas/Identity/Pages/Account/Manage/_Layout.cshtml @@ -0,0 +1,29 @@ +@{ + if (ViewData.TryGetValue("ParentLayout", out var parentLayout)) + { + Layout = (string)parentLayout; + } + else + { + Layout = "/Areas/Identity/Pages/_Layout.cshtml"; + } +} + +

Manage your account

+ +
+

Change your account settings

+
+
+
+ +
+
+ @RenderBody() +
+
+
+ +@section Scripts { + @RenderSection("Scripts", required: false) +} diff --git a/Selector.Web/Areas/Identity/Pages/Account/Manage/_ManageNav.cshtml b/Selector.Web/Areas/Identity/Pages/Account/Manage/_ManageNav.cshtml new file mode 100644 index 0000000..05e9f00 --- /dev/null +++ b/Selector.Web/Areas/Identity/Pages/Account/Manage/_ManageNav.cshtml @@ -0,0 +1,18 @@ +@using Selector.Model; +@inject SignInManager SignInManager +@{ + var hasExternalLogins = (await SignInManager.GetExternalAuthenticationSchemesAsync()).Any(); +} + diff --git a/Selector.Web/Areas/Identity/Pages/Account/Manage/_StatusMessage.cshtml b/Selector.Web/Areas/Identity/Pages/Account/Manage/_StatusMessage.cshtml new file mode 100644 index 0000000..208a424 --- /dev/null +++ b/Selector.Web/Areas/Identity/Pages/Account/Manage/_StatusMessage.cshtml @@ -0,0 +1,10 @@ +@model string + +@if (!String.IsNullOrEmpty(Model)) +{ + var statusMessageClass = Model.StartsWith("Error") ? "danger" : "success"; + +} \ No newline at end of file diff --git a/Selector.Web/Areas/Identity/Pages/Account/Manage/_ViewImports.cshtml b/Selector.Web/Areas/Identity/Pages/Account/Manage/_ViewImports.cshtml new file mode 100644 index 0000000..3e4378c --- /dev/null +++ b/Selector.Web/Areas/Identity/Pages/Account/Manage/_ViewImports.cshtml @@ -0,0 +1 @@ +@using Selector.Web.Areas.Identity.Pages.Account.Manage diff --git a/Selector.Web/Areas/Identity/Pages/Account/_StatusMessage.cshtml b/Selector.Web/Areas/Identity/Pages/Account/_StatusMessage.cshtml new file mode 100644 index 0000000..e996841 --- /dev/null +++ b/Selector.Web/Areas/Identity/Pages/Account/_StatusMessage.cshtml @@ -0,0 +1,10 @@ +@model string + +@if (!String.IsNullOrEmpty(Model)) +{ + var statusMessageClass = Model.StartsWith("Error") ? "danger" : "success"; + +}