diff --git a/ProjectLighthouse.Servers.Website/Pages/LoginForm.cshtml b/ProjectLighthouse.Servers.Website/Pages/LoginForm.cshtml index 9dd0dfa0..f518939f 100644 --- a/ProjectLighthouse.Servers.Website/Pages/LoginForm.cshtml +++ b/ProjectLighthouse.Servers.Website/Pages/LoginForm.cshtml @@ -54,15 +54,23 @@ { @await Html.PartialAsync("Partials/CaptchaPartial") } - - - @if (ServerConfiguration.Instance.Authentication.RegistrationEnabled) - { - + +
+ + @if (ServerConfiguration.Instance.Authentication.RegistrationEnabled) + { + +
+ + Register +
+
+ } +
+
+
- - Register + Forgot Password?
- } \ No newline at end of file diff --git a/ProjectLighthouse.Servers.Website/Pages/Partials/CommentsPartial.cshtml b/ProjectLighthouse.Servers.Website/Pages/Partials/CommentsPartial.cshtml index e771e409..7a0430cf 100644 --- a/ProjectLighthouse.Servers.Website/Pages/Partials/CommentsPartial.cshtml +++ b/ProjectLighthouse.Servers.Website/Pages/Partials/CommentsPartial.cshtml @@ -1,4 +1,5 @@ @using System.Web +@using System.IO @using LBPUnion.ProjectLighthouse.PlayerData.Profiles

Comments

diff --git a/ProjectLighthouse.Servers.Website/Pages/PasswordResetPage.cshtml.cs b/ProjectLighthouse.Servers.Website/Pages/PasswordResetPage.cshtml.cs index 6f744979..70c1bfb3 100644 --- a/ProjectLighthouse.Servers.Website/Pages/PasswordResetPage.cshtml.cs +++ b/ProjectLighthouse.Servers.Website/Pages/PasswordResetPage.cshtml.cs @@ -4,7 +4,6 @@ using LBPUnion.ProjectLighthouse.Configuration; using LBPUnion.ProjectLighthouse.Helpers; using LBPUnion.ProjectLighthouse.PlayerData.Profiles; using LBPUnion.ProjectLighthouse.Servers.Website.Pages.Layouts; -using LBPUnion.ProjectLighthouse.Types; using Microsoft.AspNetCore.Mvc; namespace LBPUnion.ProjectLighthouse.Servers.Website.Pages; @@ -19,8 +18,21 @@ public class PasswordResetPage : BaseLayout [UsedImplicitly] public async Task OnPost(string password, string confirmPassword) { - User? user = this.Database.UserFromWebRequest(this.Request); - if (user == null) return this.Redirect("~/login"); + User? user; + if (Request.Query.ContainsKey("token")) + { + user = await this.Database.UserFromPasswordResetToken(Request.Query["token"][0]); + if (user == null) + { + this.Error = "This password reset link either is invalid or has expired. Please try again."; + return this.Page(); + } + } + else + { + user = this.Database.UserFromWebRequest(this.Request); + if (user == null) return this.Redirect("~/login"); + } if (string.IsNullOrWhiteSpace(password)) { @@ -48,6 +60,8 @@ public class PasswordResetPage : BaseLayout [UsedImplicitly] public IActionResult OnGet() { + if (this.Request.Query.ContainsKey("token")) return this.Page(); + User? user = this.Database.UserFromWebRequest(this.Request); if (user == null) return this.Redirect("~/login"); diff --git a/ProjectLighthouse.Servers.Website/Pages/PasswordResetRequestForm.cshtml b/ProjectLighthouse.Servers.Website/Pages/PasswordResetRequestForm.cshtml new file mode 100644 index 00000000..22d590c2 --- /dev/null +++ b/ProjectLighthouse.Servers.Website/Pages/PasswordResetRequestForm.cshtml @@ -0,0 +1,34 @@ +@page "/passwordResetRequest" +@model LBPUnion.ProjectLighthouse.Servers.Website.Pages.PasswordResetRequestForm + +@{ + Layout = "Layouts/BaseLayout"; + Model.Title = "Password Reset"; +} + +@if (!string.IsNullOrWhiteSpace(Model.Error)) +{ +
+
+ Uh oh! +
+

@Model.Error

+
+} + +@if (!string.IsNullOrWhiteSpace(Model.Status)) +{ +
+
+ Success! +
+

@Model.Status

+
+} + +
+ @Html.AntiForgeryToken() + +

+ +
\ No newline at end of file diff --git a/ProjectLighthouse.Servers.Website/Pages/PasswordResetRequestForm.cshtml.cs b/ProjectLighthouse.Servers.Website/Pages/PasswordResetRequestForm.cshtml.cs new file mode 100644 index 00000000..e77e26d9 --- /dev/null +++ b/ProjectLighthouse.Servers.Website/Pages/PasswordResetRequestForm.cshtml.cs @@ -0,0 +1,67 @@ +using JetBrains.Annotations; +using LBPUnion.ProjectLighthouse.Configuration; +using LBPUnion.ProjectLighthouse.Helpers; +using LBPUnion.ProjectLighthouse.PlayerData; +using LBPUnion.ProjectLighthouse.PlayerData.Profiles; +using LBPUnion.ProjectLighthouse.Servers.Website.Pages.Layouts; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace LBPUnion.ProjectLighthouse.Servers.Website.Pages; + +public class PasswordResetRequestForm : BaseLayout +{ + + public string? Error { get; private set; } + + public string? Status { get; private set; } + + public PasswordResetRequestForm(Database database) : base(database) + { } + + [UsedImplicitly] + public async Task OnPost(string username) + { + + if (!ServerConfiguration.Instance.Mail.MailEnabled) + { + this.Error = "Email is not configured on this server, so password resets cannot be issued. Please contact your instance administrator for more details."; + return this.Page(); + } + + if (string.IsNullOrWhiteSpace(username)) + { + this.Error = "The username field is required."; + return this.Page(); + } + + User? user = await this.Database.Users.FirstOrDefaultAsync(u => u.Username == username); + + if (user == null) + { + this.Error = "User does not exist."; + return this.Page(); + } + + PasswordResetToken token = new() + { + Created = DateTime.Now, + UserId = user.UserId, + ResetToken = CryptoHelper.GenerateAuthToken(), + }; + + string messageBody = $"Hello, {user.Username}.\n\n" + + "A request to reset your account's password was issued. If this wasn't you, this can probably be ignored.\n\n" + + $"If this was you, your {ServerConfiguration.Instance.Customization.ServerName} password can be reset at the following link:\n" + + $"{ServerConfiguration.Instance.ExternalUrl}/passwordReset?token={token.ResetToken}"; + + SMTPHelper.SendEmail(user.EmailAddress, $"Project Lighthouse Password Reset Request for {user.Username}", messageBody); + + this.Database.PasswordResetTokens.Add(token); + await this.Database.SaveChangesAsync(); + + this.Status = $"Password reset email sent to {CensorHelper.MaskEmail(user.EmailAddress)}."; + return this.Page(); + } + public void OnGet() => this.Page(); +} \ No newline at end of file diff --git a/ProjectLighthouse/Database.cs b/ProjectLighthouse/Database.cs index 1ce9895e..ca7fa61b 100644 --- a/ProjectLighthouse/Database.cs +++ b/ProjectLighthouse/Database.cs @@ -46,6 +46,7 @@ public class Database : DbContext public DbSet Reports { get; set; } public DbSet EmailVerificationTokens { get; set; } public DbSet EmailSetTokens { get; set; } + public DbSet PasswordResetTokens { get; set; } protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseMySql(ServerConfiguration.Instance.DbConnectionString, MySqlServerVersion.LatestSupportedServerVersion); @@ -358,6 +359,27 @@ public class Database : DbContext #endregion + #region Password Reset Token + + public async Task UserFromPasswordResetToken(string resetToken) + { + + PasswordResetToken? token = await this.PasswordResetTokens.FirstOrDefaultAsync(token => token.ResetToken == resetToken); + if (token == null) + { + return null; + } + + if (token.Created < DateTime.Now.AddHours(-1)) // if token is expired + { + this.PasswordResetTokens.Remove(token); + return null; + } + return await this.Users.FirstOrDefaultAsync(user => user.UserId == token.UserId); + } + + #endregion + public async Task PhotoFromSubject(PhotoSubject subject) => await this.Photos.FirstOrDefaultAsync(p => p.PhotoSubjectIds.Contains(subject.PhotoSubjectId.ToString())); public async Task RemoveUser(User? user) diff --git a/ProjectLighthouse/Helpers/CensorHelper.cs b/ProjectLighthouse/Helpers/CensorHelper.cs index 85d3db91..ae5bc821 100644 --- a/ProjectLighthouse/Helpers/CensorHelper.cs +++ b/ProjectLighthouse/Helpers/CensorHelper.cs @@ -1,4 +1,5 @@ using System; +using System.IO; using System.Text; using LBPUnion.ProjectLighthouse.Configuration; using LBPUnion.ProjectLighthouse.Types; @@ -94,4 +95,23 @@ public static class CensorHelper return sb.ToString(); } + + public static string MaskEmail(string email) + { + if (string.IsNullOrEmpty(email) || !email.Contains('@')) return email; + + string[] emailArr = email.Split('@'); + string domainExt = Path.GetExtension(email); + + string maskedEmail = string.Format("{0}****{1}@{2}****{3}{4}", + emailArr[0][0], + emailArr[0].Substring(emailArr[0].Length - 1), + emailArr[1][0], + emailArr[1] + .Substring(emailArr[1].Length - domainExt.Length - 1, + 1), + domainExt); + + return maskedEmail; + } } \ No newline at end of file diff --git a/ProjectLighthouse/Migrations/20220624210701_AddedPasswordResetTokens.cs b/ProjectLighthouse/Migrations/20220624210701_AddedPasswordResetTokens.cs new file mode 100644 index 00000000..35a27a01 --- /dev/null +++ b/ProjectLighthouse/Migrations/20220624210701_AddedPasswordResetTokens.cs @@ -0,0 +1,41 @@ +using System; +using LBPUnion.ProjectLighthouse; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ProjectLighthouse.Migrations +{ + [DbContext(typeof(Database))] + [Migration("20220624210701_AddedPasswordResetTokens")] + public partial class AddedPasswordResetTokens : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "PasswordResetTokens", + columns: table => new + { + TokenId = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + UserId = table.Column(type: "int", nullable: false), + ResetToken = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + Created = table.Column(type: "datetime(6)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_PasswordResetTokens", x => x.TokenId); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "PasswordResetTokens"); + } + } +} diff --git a/ProjectLighthouse/Migrations/DatabaseModelSnapshot.cs b/ProjectLighthouse/Migrations/DatabaseModelSnapshot.cs index 70832e33..515b95d0 100644 --- a/ProjectLighthouse/Migrations/DatabaseModelSnapshot.cs +++ b/ProjectLighthouse/Migrations/DatabaseModelSnapshot.cs @@ -16,7 +16,7 @@ namespace ProjectLighthouse.Migrations { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "6.0.5") + .HasAnnotation("ProductVersion", "6.0.6") .HasAnnotation("Relational:MaxIdentifierLength", 64); modelBuilder.Entity("LBPUnion.ProjectLighthouse.Administration.CompletedMigration", b => @@ -390,6 +390,26 @@ namespace ProjectLighthouse.Migrations b.ToTable("GameTokens"); }); + modelBuilder.Entity("LBPUnion.ProjectLighthouse.PlayerData.PasswordResetToken", b => + { + b.Property("TokenId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("Created") + .HasColumnType("datetime(6)"); + + b.Property("ResetToken") + .HasColumnType("longtext"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("TokenId"); + + b.ToTable("PasswordResetTokens"); + }); + modelBuilder.Entity("LBPUnion.ProjectLighthouse.PlayerData.Photo", b => { b.Property("PhotoId") diff --git a/ProjectLighthouse/PlayerData/PasswordResetToken.cs b/ProjectLighthouse/PlayerData/PasswordResetToken.cs new file mode 100644 index 00000000..9b07cead --- /dev/null +++ b/ProjectLighthouse/PlayerData/PasswordResetToken.cs @@ -0,0 +1,17 @@ +using System; +using System.ComponentModel.DataAnnotations; + +namespace LBPUnion.ProjectLighthouse.PlayerData; + +public class PasswordResetToken +{ + // ReSharper disable once UnusedMember.Global + [Key] + public int TokenId { get; set; } + + public int UserId { get; set; } + + public string ResetToken { get; set; } + + public DateTime Created { get; set; } +} \ No newline at end of file