Skip to content

Commit

Permalink
Implemented OIDC
Browse files Browse the repository at this point in the history
  • Loading branch information
miawinter98 committed Mar 11, 2024
1 parent 116dd93 commit a9269ec
Show file tree
Hide file tree
Showing 28 changed files with 1,891 additions and 546 deletions.
2 changes: 1 addition & 1 deletion Wave/Components/Account/IdentityUserAccessor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ internal sealed class IdentityUserAccessor(
IdentityRedirectManager redirectManager) {
public async Task<ApplicationUser> GetRequiredUserAsync(HttpContext context) {
var user = await userManager.GetUserAsync(context.User);

if (user is null) {
redirectManager.RedirectToWithStatus("Account/InvalidUser",
$"Error: Unable to load user with ID '{userManager.GetUserId(context.User)}'.", context);
Expand Down
3 changes: 2 additions & 1 deletion Wave/Components/Account/Pages/ConfirmEmail.razor
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@

<BoardComponent CenterContent="true">
<BoardCardComponent Heading="@Localizer["Title"]">
<StatusMessage Message="@Message" />
<StatusMessage Message="@Message" />
<a class="btn btn-primary w-full mt-3" href="/Account/Login">@Localizer["Login_Label"]</a>
</BoardCardComponent>
</BoardComponent>

Expand Down
351 changes: 178 additions & 173 deletions Wave/Components/Account/Pages/ExternalLogin.razor
Original file line number Diff line number Diff line change
Expand Up @@ -4,192 +4,197 @@
@using System.Security.Claims
@using System.Text
@using System.Text.Encodings.Web
@using Humanizer
@using Microsoft.AspNetCore.Identity
@using Microsoft.AspNetCore.WebUtilities
@using Wave.Data

@inject SignInManager<ApplicationUser> SignInManager
@inject UserManager<ApplicationUser> UserManager
@inject RoleManager<IdentityRole> RoleManager
@inject IUserStore<ApplicationUser> UserStore
@inject IEmailSender<ApplicationUser> EmailSender
@inject NavigationManager NavigationManager
@inject IdentityRedirectManager RedirectManager
@inject ILogger<ExternalLogin> Logger
@inject IStringLocalizer<ExternalLogin> Localizer

<PageTitle>Register</PageTitle>
<PageTitle>@Localizer["Title"]</PageTitle>

<StatusMessage Message="@message" />
<h1>Register</h1>
<h2>Associate your @ProviderDisplayName account.</h2>
<hr />

<div class="alert alert-info">
You've successfully authenticated with <strong>@ProviderDisplayName</strong>.
Please enter an email address for this site below and click the Register button to finish
logging in.
</div>

<div class="row">
<div class="col-md-4">
<EditForm Model="Input" OnValidSubmit="OnValidSubmitAsync" FormName="confirmation" method="post">
<DataAnnotationsValidator />
<ValidationSummary class="text-danger" role="alert" />
<div class="form-floating mb-3">
<InputText @bind-Value="Input.Email" class="form-control" autocomplete="email" placeholder="Please enter your email." />
<label for="email" class="form-label">Email</label>
<ValidationMessage For="() => Input.Email" />
</div>
<button type="submit" class="w-100 btn btn-lg btn-primary">Register</button>
</EditForm>
</div>
</div>

<BoardComponent CenterContent="true">
<BoardCardComponent Heading="@Localizer["Title"]">
<Alert CanRemove="false" Type="Alert.MessageType.Information">
<p>
@string.Format(Localizer["Message"], ProviderDisplayName)
</p>
</Alert>
<EditForm Model="Input" OnValidSubmit="OnValidSubmitAsync" FormName="confirmation" method="post">
<DataAnnotationsValidator/>
<InputLabelComponent LabelText="@Localizer["Email_Label"]" For="() => Input.Email">
<InputText @bind-Value="Input.Email" class="input input-bordered w-full"
autocomplete="email" placeholder="@Localizer["Email_Placeholder"]" />
</InputLabelComponent>

<button type="submit" class="btn btn-primary w-full">
@Localizer["Submit"]
</button>
</EditForm>
</BoardCardComponent>
</BoardComponent>

@code {
public const string LoginCallbackAction = "LoginCallback";

private string? message;
private ExternalLoginInfo externalLoginInfo = default!;

[CascadingParameter]
private HttpContext HttpContext { get; set; } = default!;

[SupplyParameterFromForm]
private InputModel Input { get; set; } = new();

[SupplyParameterFromQuery]
private string? RemoteError { get; set; }

[SupplyParameterFromQuery]
private string? ReturnUrl { get; set; }

[SupplyParameterFromQuery]
private string? Action { get; set; }

private string? ProviderDisplayName => externalLoginInfo.ProviderDisplayName;

protected override async Task OnInitializedAsync()
{
if (RemoteError is not null)
{
RedirectManager.RedirectToWithStatus("Account/Login", $"Error from external provider: {RemoteError}", HttpContext);
}

var info = await SignInManager.GetExternalLoginInfoAsync();
if (info is null)
{
RedirectManager.RedirectToWithStatus("Account/Login", "Error loading external login information.", HttpContext);
}

externalLoginInfo = info;

if (HttpMethods.IsGet(HttpContext.Request.Method))
{
if (Action == LoginCallbackAction)
{
await OnLoginCallbackAsync();
return;
}

// We should only reach this page via the login callback, so redirect back to
// the login page if we get here some other way.
RedirectManager.RedirectTo("Account/Login");
}
}

private async Task OnLoginCallbackAsync()
{
// Sign in the user with this external login provider if the user already has a login.
var result = await SignInManager.ExternalLoginSignInAsync(
externalLoginInfo.LoginProvider,
externalLoginInfo.ProviderKey,
isPersistent: false,
bypassTwoFactor: true);

if (result.Succeeded)
{
Logger.LogInformation(
"{Name} logged in with {LoginProvider} provider.",
externalLoginInfo.Principal.Identity?.Name,
externalLoginInfo.LoginProvider);
RedirectManager.RedirectTo(ReturnUrl);
}
else if (result.IsLockedOut)
{
RedirectManager.RedirectTo("Account/Lockout");
}

// If the user does not have an account, then ask the user to create an account.
if (externalLoginInfo.Principal.HasClaim(c => c.Type == ClaimTypes.Email))
{
Input.Email = externalLoginInfo.Principal.FindFirstValue(ClaimTypes.Email) ?? "";
}
}

private async Task OnValidSubmitAsync()
{
var emailStore = GetEmailStore();
var user = CreateUser();

await UserStore.SetUserNameAsync(user, Input.Email, CancellationToken.None);
await emailStore.SetEmailAsync(user, Input.Email, CancellationToken.None);

var result = await UserManager.CreateAsync(user);
if (result.Succeeded)
{
result = await UserManager.AddLoginAsync(user, externalLoginInfo);
if (result.Succeeded)
{
Logger.LogInformation("User created an account using {Name} provider.", externalLoginInfo.LoginProvider);

var userId = await UserManager.GetUserIdAsync(user);
var code = await UserManager.GenerateEmailConfirmationTokenAsync(user);
code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));

var callbackUrl = NavigationManager.GetUriWithQueryParameters(
NavigationManager.ToAbsoluteUri("Account/ConfirmEmail").AbsoluteUri,
new Dictionary<string, object?> { ["userId"] = userId, ["code"] = code });
await EmailSender.SendConfirmationLinkAsync(user, Input.Email, HtmlEncoder.Default.Encode(callbackUrl));

// If account confirmation is required, we need to show the link if we don't have a real email sender
if (UserManager.Options.SignIn.RequireConfirmedAccount)
{
RedirectManager.RedirectTo("Account/RegisterConfirmation", new() { ["email"] = Input.Email });
}

await SignInManager.SignInAsync(user, isPersistent: false, externalLoginInfo.LoginProvider);
RedirectManager.RedirectTo(ReturnUrl);
}
}

message = $"Error: {string.Join(",", result.Errors.Select(error => error.Description))}";
}

private ApplicationUser CreateUser()
{
try
{
return Activator.CreateInstance<ApplicationUser>();
}
catch
{
throw new InvalidOperationException($"Can't create an instance of '{nameof(ApplicationUser)}'. " +
$"Ensure that '{nameof(ApplicationUser)}' is not an abstract class and has a parameterless constructor");
}
}

private IUserEmailStore<ApplicationUser> GetEmailStore()
{
if (!UserManager.SupportsUserEmail)
{
throw new NotSupportedException("The default UI requires a user store with email support.");
}
return (IUserEmailStore<ApplicationUser>)UserStore;
}

private sealed class InputModel
{
[Required]
[EmailAddress]
public string Email { get; set; } = "";
}
}
public const string LoginCallbackAction = "LoginCallback";

private string? message;
private ExternalLoginInfo externalLoginInfo = default!;

[CascadingParameter]
private HttpContext HttpContext { get; set; } = default!;

[SupplyParameterFromForm]
private InputModel Input { get; set; } = new();

[SupplyParameterFromQuery]
private string? RemoteError { get; set; }

[SupplyParameterFromQuery]
private string? ReturnUrl { get; set; }

[SupplyParameterFromQuery]
private string? Action { get; set; }

private string? ProviderDisplayName => externalLoginInfo.ProviderDisplayName;

protected override async Task OnInitializedAsync() {
if (RemoteError is not null) {
RedirectManager.RedirectToWithStatus("Account/Login", $"Error from external provider: {RemoteError}", HttpContext);
}

var info = await SignInManager.GetExternalLoginInfoAsync();
if (info is null) {
RedirectManager.RedirectToWithStatus("Account/Login", "Error loading external login information.", HttpContext);
}

externalLoginInfo = info;

if (HttpMethods.IsGet(HttpContext.Request.Method)) {
if (Action == LoginCallbackAction) {
await OnLoginCallbackAsync();
return;
}

// We should only reach this page via the login callback, so redirect back to
// the login page if we get here some other way.
RedirectManager.RedirectTo("Account/Login");
}
}

private async Task OnLoginCallbackAsync() {
// Sign in the user with this external login provider if the user already has a login.
var result = await SignInManager.ExternalLoginSignInAsync(
externalLoginInfo.LoginProvider,
externalLoginInfo.ProviderKey,
isPersistent: false,
bypassTwoFactor: true);

if (result.Succeeded) {
Logger.LogInformation(
"{Name} logged in with {LoginProvider} provider.",
externalLoginInfo.Principal.Identity?.Name,
externalLoginInfo.LoginProvider);
RedirectManager.RedirectTo(ReturnUrl);
} else if (result.IsLockedOut) {
RedirectManager.RedirectTo("Account/Lockout");
}

// If the user does not have an account, then ask the user to create an account.
if (externalLoginInfo.Principal.Claims.FirstOrDefault(c => c.Type is ClaimTypes.Email or "email") is {} claim) {
Input.Email = claim.Value;
}
}

private async Task OnValidSubmitAsync() {
var emailStore = GetEmailStore();
var user = CreateUser();

await UserStore.SetUserNameAsync(user, Input.Email, CancellationToken.None);
await emailStore.SetEmailAsync(user, Input.Email, CancellationToken.None);

var info = await SignInManager.GetExternalLoginInfoAsync();
if (info?.Principal.Claims.FirstOrDefault(c => c.Type is ClaimTypes.Name or ClaimTypes.GivenName or "name") is {} nameClaim) {
user.FullName = nameClaim.Value;
}

var result = await UserManager.CreateAsync(user);
if (result.Succeeded) {
result = await UserManager.AddLoginAsync(user, externalLoginInfo);
if (result.Succeeded) {
Logger.LogInformation("User created an account using {Name} provider.", externalLoginInfo.LoginProvider);

string userId = await UserManager.GetUserIdAsync(user);
string code = await UserManager.GenerateEmailConfirmationTokenAsync(user);
code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));

string callbackUrl = NavigationManager.GetUriWithQueryParameters(
NavigationManager.ToAbsoluteUri("Account/ConfirmEmail").AbsoluteUri,
new Dictionary<string, object?> {["userId"] = userId, ["code"] = code});
await EmailSender.SendConfirmationLinkAsync(user, Input.Email, HtmlEncoder.Default.Encode(callbackUrl));

if (info?.Principal.Claims.FirstOrDefault(c => c.Type is ClaimTypes.Role or "roles") is { } roleClaim) {
var roles = roleClaim.Value.Split(",").Select(s => s.Trim());

foreach (string role in roles) {
try {
string r = role.Titleize();
if (r is "Author" or "Reviewer" or "Moderator" or "Admin" &&
!await RoleManager.RoleExistsAsync(r) &&
!(await RoleManager.CreateAsync(new IdentityRole(r))).Succeeded) {
Logger.LogError("Failed to create role {role}. Failed to assign it to user {name}.", r, user.UserName);
continue;
}

await UserManager.AddToRoleAsync(user, r);
} catch (Exception ex) {
Logger.LogWarning(ex, "Failed to add newly created user {name} to role {role}.", user.UserName, role);
}
}
}

// If account confirmation is required, we need to show the link if we don't have a real email sender
if (UserManager.Options.SignIn.RequireConfirmedAccount) {
RedirectManager.RedirectTo("Account/RegisterConfirmation", new() { ["email"] = Input.Email });
}

await SignInManager.SignInAsync(user, isPersistent: false, externalLoginInfo.LoginProvider);
RedirectManager.RedirectTo(ReturnUrl);
}
}

message = $"Error: {string.Join(",", result.Errors.Select(error => error.Description))}";
}

private ApplicationUser CreateUser() {
try {
return Activator.CreateInstance<ApplicationUser>();
} catch {
throw new InvalidOperationException($"Can't create an instance of '{nameof(ApplicationUser)}'. " +
$"Ensure that '{nameof(ApplicationUser)}' is not an abstract class and has a parameterless constructor");
}
}

private IUserEmailStore<ApplicationUser> GetEmailStore() {
if (!UserManager.SupportsUserEmail) {
throw new NotSupportedException("The default UI requires a user store with email support.");
}

return (IUserEmailStore<ApplicationUser>) UserStore;
}

private sealed class InputModel {
[Required] [EmailAddress]
public string Email { get; set; } = "";
}

}
Loading

0 comments on commit a9269ec

Please sign in to comment.