Change login UI and improve email setup flow (#619)

* Rework login UI design and change email setup flow

* Remove unused middleware

* Fix button not lining up with input fields
This commit is contained in:
Josh 2023-01-09 19:43:56 -06:00 committed by GitHub
parent 20b2ef5700
commit 7d187ee982
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 207 additions and 179 deletions

View file

@ -25,7 +25,7 @@
<value>Password</value>
</data>
<data name="register" xml:space="preserve">
<value>Register</value>
<value>Create an account</value>
</data>
<data name="forgot_password" xml:space="preserve">
<value>Forgot Password?</value>

View file

@ -1,26 +0,0 @@
using LBPUnion.ProjectLighthouse.Middlewares;
namespace LBPUnion.ProjectLighthouse.Servers.Website.Middlewares;
public class HandlePageErrorMiddleware : Middleware
{
public HandlePageErrorMiddleware(RequestDelegate next) : base(next)
{}
public override async Task InvokeAsync(HttpContext ctx)
{
await this.next(ctx);
if (ctx.Response.StatusCode == 404 && !ctx.Request.Path.StartsWithSegments("/gameAssets"))
{
try
{
ctx.Request.Path = "/404";
}
finally
{
// not much we can do to save us, carry on anyways
await this.next(ctx);
}
}
}
}

View file

@ -1,5 +1,7 @@
#nullable enable
using LBPUnion.ProjectLighthouse.Configuration;
using LBPUnion.ProjectLighthouse.Helpers;
using LBPUnion.ProjectLighthouse.PlayerData;
using LBPUnion.ProjectLighthouse.PlayerData.Profiles;
using LBPUnion.ProjectLighthouse.PlayerData.Profiles.Email;
using LBPUnion.ProjectLighthouse.Servers.Website.Pages.Layouts;
@ -19,9 +21,6 @@ public class CompleteEmailVerificationPage : BaseLayout
{
if (!ServerConfiguration.Instance.Mail.MailEnabled) return this.NotFound();
User? user = this.Database.UserFromWebRequest(this.Request);
if (user == null) return this.Redirect("~/login");
EmailVerificationToken? emailVerifyToken = await this.Database.EmailVerificationTokens.FirstOrDefaultAsync(e => e.EmailToken == token);
if (emailVerifyToken == null)
{
@ -29,6 +28,8 @@ public class CompleteEmailVerificationPage : BaseLayout
return this.Page();
}
User user = await this.Database.Users.FirstAsync(u => u.UserId == emailVerifyToken.UserId);
if (DateTime.Now > emailVerifyToken.ExpiresAt)
{
this.Error = "This token has expired";
@ -44,9 +45,27 @@ public class CompleteEmailVerificationPage : BaseLayout
this.Database.EmailVerificationTokens.Remove(emailVerifyToken);
user.EmailAddressVerified = true;
await this.Database.SaveChangesAsync();
return this.Page();
if (user.Password != null) return this.Page();
// if user's account was created automatically
WebToken webToken = new()
{
ExpiresAt = DateTime.Now.AddDays(7),
Verified = true,
UserId = user.UserId,
UserToken = CryptoHelper.GenerateAuthToken(),
};
user.PasswordResetRequired = true;
this.Database.WebTokens.Add(webToken);
await this.Database.SaveChangesAsync();
this.Response.Cookies.Append("LighthouseToken",
webToken.UserToken,
new CookieOptions
{
Expires = DateTimeOffset.Now.AddDays(7),
});
return this.Redirect("/passwordReset");
}
}

View file

@ -1,13 +1,9 @@
#nullable enable
using System.Collections.Concurrent;
using LBPUnion.ProjectLighthouse.Configuration;
using LBPUnion.ProjectLighthouse.Extensions;
using LBPUnion.ProjectLighthouse.Helpers;
using LBPUnion.ProjectLighthouse.PlayerData.Profiles;
using LBPUnion.ProjectLighthouse.PlayerData.Profiles.Email;
using LBPUnion.ProjectLighthouse.Servers.Website.Pages.Layouts;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace LBPUnion.ProjectLighthouse.Servers.Website.Pages.Email;
@ -16,9 +12,6 @@ public class SendVerificationEmailPage : BaseLayout
public SendVerificationEmailPage(Database database) : base(database)
{}
// (User id, timestamp of last request + 30 seconds)
private static readonly ConcurrentDictionary<int, long> recentlySentEmail = new();
public bool Success { get; set; }
public async Task<IActionResult> OnGet()
@ -28,70 +21,9 @@ public class SendVerificationEmailPage : BaseLayout
User? user = this.Database.UserFromWebRequest(this.Request);
if (user == null) return this.Redirect("/login");
// `using` weirdness here. I tried to fix it, but I couldn't.
// The user should never see this page once they've been verified, so assert here.
System.Diagnostics.Debug.Assert(!user.EmailAddressVerified);
if (user.EmailAddressVerified) return this.Redirect("/");
// Othewise, on a release build, just silently redirect them to the landing page.
#if !DEBUG
if (user.EmailAddressVerified)
{
return this.Redirect("/");
}
#endif
// Remove expired entries
for (int i = recentlySentEmail.Count - 1; i >= 0; i--)
{
KeyValuePair<int, long> entry = recentlySentEmail.ElementAt(i);
bool valueExists = recentlySentEmail.TryGetValue(entry.Key, out long timestamp);
if (!valueExists)
{
recentlySentEmail.TryRemove(entry.Key, out _);
continue;
}
if (TimeHelper.TimestampMillis > timestamp) recentlySentEmail.TryRemove(entry.Key, out _);
}
if (recentlySentEmail.ContainsKey(user.UserId))
{
bool valueExists = recentlySentEmail.TryGetValue(user.UserId, out long timestamp);
if (!valueExists)
{
recentlySentEmail.TryRemove(user.UserId, out _);
}
else if (timestamp > TimeHelper.TimestampMillis)
{
this.Success = true;
return this.Page();
}
}
string? existingToken = await this.Database.EmailVerificationTokens.Where(v => v.UserId == user.UserId).Select(v => v.EmailToken).FirstOrDefaultAsync();
if (existingToken != null)
this.Database.EmailVerificationTokens.RemoveWhere(t => t.EmailToken == existingToken);
EmailVerificationToken verifyToken = new()
{
UserId = user.UserId,
User = user,
EmailToken = CryptoHelper.GenerateAuthToken(),
ExpiresAt = DateTime.Now.AddHours(6),
};
this.Database.EmailVerificationTokens.Add(verifyToken);
await this.Database.SaveChangesAsync();
string body = "Hello,\n\n" +
$"This email is a request to verify this email for your (likely new!) Project Lighthouse account ({user.Username}).\n\n" +
$"To verify your account, click the following link: {ServerConfiguration.Instance.ExternalUrl}/verifyEmail?token={verifyToken.EmailToken}\n\n\n" +
"If this wasn't you, feel free to ignore this email.";
this.Success = await SMTPHelper.SendEmailAsync(user.EmailAddress, "Project Lighthouse Email Verification", body);
// Don't send another email for 30 seconds
recentlySentEmail.TryAdd(user.UserId, TimeHelper.TimestampMillis + 30 * 1000);
this.Success = await SMTPHelper.SendVerificationEmail(this.Database, user);
return this.Page();
}

View file

@ -1,5 +1,5 @@
using LBPUnion.ProjectLighthouse.Servers.Website.Pages.Layouts;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.AspNetCore.Mvc;
namespace LBPUnion.ProjectLighthouse.Servers.Website.Pages.Errors;
@ -8,8 +8,5 @@ public class NotFoundPage : BaseLayout
public NotFoundPage(Database database) : base(database)
{}
public void OnGet()
{
}
public IActionResult OnGet() => this.Page();
}

View file

@ -6,6 +6,7 @@
@{
Layout = "Layouts/BaseLayout";
Model.Title = Model.Translate(GeneralStrings.LogIn);
Model.ShowTitleInPage = false;
}
<script src="https://geraintluff.github.io/sha256/sha256.min.js"></script>
@ -26,20 +27,28 @@
}
</script>
@if (!string.IsNullOrWhiteSpace(Model.Error))
{
<div class="ui middle aligned center aligned grid">
<form class="ui form" onsubmit="return onSubmit(this)" method="post">
@if (!string.IsNullOrWhiteSpace(Model.Error))
{
<div class="ui negative message">
<div class="header">
@Model.Translate(GeneralStrings.Error)
</div>
<p style="white-space: pre-line">@Model.Error</p>
</div>
}
<form class="ui form" onsubmit="return onSubmit(this)" method="post">
}
@Html.AntiForgeryToken()
<input type="hidden" id="redirect" name="redirect">
<div class="column">
<h2 class="ui black image header centered">
<img src="~/logo-color.png" class="image" style="width: 128px;">
<div class="content">
@Model.Title
</div>
</h2>
<div class="field">
@{
string username = ServerConfiguration.Instance.Mail.MailEnabled ? Model.Translate(GeneralStrings.Email) : Model.Translate(GeneralStrings.Username);
@ -63,24 +72,25 @@
@if (ServerConfiguration.Instance.Captcha.CaptchaEnabled)
{
@await Html.PartialAsync("Partials/CaptchaPartial")
<div class="ui divider fitted hidden"></div>
}
<input type="submit" value="@Model.Translate(GeneralStrings.LogIn)" id="submit" class="ui fluid blue button" style="width: 25em; margin-right: 0">
<a href="/passwordResetRequest">
<div class="ui fluid button" style="background-color: rgba(0, 0, 0, 0)">
@Model.Translate(GeneralStrings.ForgotPassword)
</div>
</a>
<div class="row">
<input type="submit" value="@Model.Translate(GeneralStrings.LogIn)" id="submit" class="ui blue button">
@if (ServerConfiguration.Instance.Authentication.RegistrationEnabled)
{
<a href="/register">
<div class="ui button">
<i class="user alternate add icon"></i>
<div class="ui divider"></div>
<a href="/register" style="text-align: center;">
<div class="ui fluid button" style="background-color: rgba(0, 0, 0, 0)">
@Model.Translate(GeneralStrings.Register)
</div>
</a>
}
</div>
<br/>
<a href="/passwordResetRequest">
<div class="ui button">
@Model.Translate(GeneralStrings.ForgotPassword)
</div>
</a>
</form>
</form>
</div>

View file

@ -5,6 +5,7 @@
@{
Layout = "Layouts/BaseLayout";
Model.Title = "Password Reset";
Model.ShowTitleInPage = false;
}
<script src="https://geraintluff.github.io/sha256/sha256.min.js"></script>
@ -33,20 +34,39 @@
</div>
}
<div class="ui middle aligned center aligned grid">
<form onsubmit="return onSubmit(this)" method="post">
@Html.AntiForgeryToken()
<div class="ui left labeled input">
<label for="password" class="ui blue label">Password: </label>
<input type="password" id="password">
<input type="hidden" id="password-submit" name="password">
</div><br><br>
<div class="column">
<h2 class="ui black image header centered">
<img src="~/logo-color.png" class="image" style="width: 128px;">
<div class="content">
@Model.Title
</div>
</h2>
<div class="ui left labeled input">
<label for="password" class="ui blue label">Confirm Password: </label>
<input type="password" id="confirmPassword">
<input type="hidden" id="confirmPassword-submit" name="confirmPassword">
</div><br><br><br>
<div class="ui form">
<div class="ui field">
<label>Password</label>
<div class="ui left icon input">
<input type="password" id="password" placeholder="Password" >
<input type="hidden" name="password" id="password-submit">
<i class="lock icon"></i>
</div>
</div>
<input type="submit" value="Reset password and continue" id="submit" class="ui green button"><br>
<div class="ui field">
<label>Confirm Password</label>
<div class="ui left icon input">
<input type="password" id="confirmPassword" placeholder="Confirm Password">
<input type="hidden" name="confirmPassword" id="confirm-submit">
<i class="lock icon"></i>
</div>
</div>
<input type="submit" value="Reset password" id="submit" class="ui fluid blue button"><br>
<div class="ui divider hidden"></div>
</div>
</div>
</form>
</div>

View file

@ -6,6 +6,7 @@
Model.Title = "Password Reset Required";
}
<p>An administrator has deemed it necessary that you reset your password. Please do so.</p>
<a href="/passwordReset">

View file

@ -1,11 +1,9 @@
using System.Globalization;
using System.Reflection;
using LBPUnion.ProjectLighthouse.Localization;
using LBPUnion.ProjectLighthouse.Middlewares;
using LBPUnion.ProjectLighthouse.Servers.Website.Middlewares;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.Localization;
using Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation;
using Microsoft.Extensions.FileProviders;
#if !DEBUG
@ -76,9 +74,10 @@ public class WebsiteStartup
app.UseDeveloperExceptionPage();
#endif
app.UseStatusCodePagesWithReExecute("/404");
app.UseForwardedHeaders();
app.UseMiddleware<HandlePageErrorMiddleware>();
app.UseMiddleware<RequestLogMiddleware>();
app.UseMiddleware<UserRequiredRedirectMiddleware>();
app.UseMiddleware<RateLimitMiddleware>();

View file

@ -0,0 +1,77 @@
#nullable enable
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using LBPUnion.ProjectLighthouse.Configuration;
using LBPUnion.ProjectLighthouse.Extensions;
using LBPUnion.ProjectLighthouse.PlayerData.Profiles;
using LBPUnion.ProjectLighthouse.PlayerData.Profiles.Email;
using Microsoft.EntityFrameworkCore;
namespace LBPUnion.ProjectLighthouse.Helpers;
public partial class SMTPHelper
{
// (User id, timestamp of last request + 30 seconds)
private static readonly ConcurrentDictionary<int, long> recentlySentEmail = new();
public static async Task<bool> SendVerificationEmail(Database database, User user)
{
// Remove expired entries
for (int i = recentlySentEmail.Count - 1; i >= 0; i--)
{
KeyValuePair<int, long> entry = recentlySentEmail.ElementAt(i);
bool valueExists = recentlySentEmail.TryGetValue(entry.Key, out long timestamp);
if (!valueExists)
{
recentlySentEmail.TryRemove(entry.Key, out _);
continue;
}
if (TimeHelper.TimestampMillis > timestamp) recentlySentEmail.TryRemove(entry.Key, out _);
}
if (recentlySentEmail.ContainsKey(user.UserId))
{
bool valueExists = recentlySentEmail.TryGetValue(user.UserId, out long timestamp);
if (!valueExists)
{
recentlySentEmail.TryRemove(user.UserId, out _);
}
else if (timestamp > TimeHelper.TimestampMillis)
{
return true;
}
}
string? existingToken = await database.EmailVerificationTokens.Where(v => v.UserId == user.UserId)
.Select(v => v.EmailToken)
.FirstOrDefaultAsync();
if (existingToken != null) database.EmailVerificationTokens.RemoveWhere(t => t.EmailToken == existingToken);
EmailVerificationToken verifyToken = new()
{
UserId = user.UserId,
User = user,
EmailToken = CryptoHelper.GenerateAuthToken(),
ExpiresAt = DateTime.Now.AddHours(6),
};
database.EmailVerificationTokens.Add(verifyToken);
await database.SaveChangesAsync();
string body = "Hello,\n\n" +
$"This email is a request to verify this email for your (likely new!) Project Lighthouse account ({user.Username}).\n\n" +
$"To verify your account, click the following link: {ServerConfiguration.Instance.ExternalUrl}/verifyEmail?token={verifyToken.EmailToken}\n\n\n" +
"If this wasn't you, feel free to ignore this email.";
bool success = await SendEmailAsync(user.EmailAddress, "Project Lighthouse Email Verification", body);
// Don't send another email for 30 seconds
recentlySentEmail.TryAdd(user.UserId, TimeHelper.TimestampMillis + 30 * 1000);
return success;
}
}

View file

@ -9,9 +9,8 @@ using LBPUnion.ProjectLighthouse.Logging;
namespace LBPUnion.ProjectLighthouse.Helpers;
public class SMTPHelper
public partial class SMTPHelper
{
internal static readonly SMTPHelper Instance = new();
private readonly MailAddress fromAddress;