From 14d2f0305edd324b041239440926a6b941fdf444 Mon Sep 17 00:00:00 2001 From: Josh Date: Mon, 12 Dec 2022 21:11:39 -0600 Subject: [PATCH] Implement 2FA (#577) * Initial work for TOTP 2FA * Fix bug in 2FA code script * Add translations for two factor and /disable2fa * Fix compilation error * Add TwoFactorLoginPage * Add two factor login process * Little bit of backup code work * Finish two factor * Fix unit tests * ??? goofy ahh code * Use SHA-256 instead of SHA-512 * I guess SHA-256 doesn't work either * Fix comments in Base32 helper * Move QRCoder package to website * Add name to endregion comment in css * Fix bug with redirects --- .config/dotnet-tools.json | 2 +- .../ProjectLighthouse.Localization.csproj | 4 + .../StringLists/TwoFactorStrings.cs | 29 ++++ .../TranslationAreas.cs | 1 + ProjectLighthouse.Localization/TwoFactor.resx | 69 +++++++++ .../UserRequiredRedirectMiddleware.cs | 48 +++++-- .../Pages/LoginForm.cshtml.cs | 11 +- .../Pages/Partials/TwoFactorPartial.cshtml | 123 ++++++++++++++++ .../TwoFactor/DisableTwoFactorPage.cshtml | 23 +++ .../TwoFactor/DisableTwoFactorPage.cshtml.cs | 67 +++++++++ .../Pages/TwoFactor/SetupTwoFactorPage.cshtml | 87 +++++++++++ .../TwoFactor/SetupTwoFactorPage.cshtml.cs | 135 ++++++++++++++++++ .../Pages/TwoFactor/TwoFactorLoginPage.cshtml | 26 ++++ .../TwoFactor/TwoFactorLoginPage.cshtml.cs | 88 ++++++++++++ .../Pages/UserSettingsPage.cshtml | 35 ++++- .../ProjectLighthouse.Servers.Website.csproj | 1 + .../Tests/AdminTests.cs | 2 + .../Tests/AuthenticationTests.cs | 1 + .../TwoFactorConfiguration.cs | 10 ++ .../Configuration/ServerConfiguration.cs | 3 +- ProjectLighthouse/Helpers/Base32Helper.cs | 109 ++++++++++++++ ProjectLighthouse/Helpers/CryptoHelper.cs | 76 ++++++++++ .../20221105212037_AddTwoFactorToUser.cs | 41 ++++++ .../20221118162114_AddVerifiedToWebToken.cs | 30 ++++ ProjectLighthouse/PlayerData/Profiles/User.cs | 13 ++ ProjectLighthouse/PlayerData/WebToken.cs | 2 + .../Migrations/DatabaseModelSnapshot.cs | 14 +- ProjectLighthouse/StaticFiles/css/styles.css | 47 +++++- 28 files changed, 1077 insertions(+), 20 deletions(-) create mode 100644 ProjectLighthouse.Localization/StringLists/TwoFactorStrings.cs create mode 100644 ProjectLighthouse.Localization/TwoFactor.resx create mode 100644 ProjectLighthouse.Servers.Website/Pages/Partials/TwoFactorPartial.cshtml create mode 100644 ProjectLighthouse.Servers.Website/Pages/TwoFactor/DisableTwoFactorPage.cshtml create mode 100644 ProjectLighthouse.Servers.Website/Pages/TwoFactor/DisableTwoFactorPage.cshtml.cs create mode 100644 ProjectLighthouse.Servers.Website/Pages/TwoFactor/SetupTwoFactorPage.cshtml create mode 100644 ProjectLighthouse.Servers.Website/Pages/TwoFactor/SetupTwoFactorPage.cshtml.cs create mode 100644 ProjectLighthouse.Servers.Website/Pages/TwoFactor/TwoFactorLoginPage.cshtml create mode 100644 ProjectLighthouse.Servers.Website/Pages/TwoFactor/TwoFactorLoginPage.cshtml.cs create mode 100644 ProjectLighthouse/Configuration/ConfigurationCategories/TwoFactorConfiguration.cs create mode 100644 ProjectLighthouse/Helpers/Base32Helper.cs create mode 100644 ProjectLighthouse/Migrations/20221105212037_AddTwoFactorToUser.cs create mode 100644 ProjectLighthouse/Migrations/20221118162114_AddVerifiedToWebToken.cs diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 5660a96c..fab01c71 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,7 +3,7 @@ "isRoot": true, "tools": { "dotnet-ef": { - "version": "6.0.9", + "version": "6.0.10", "commands": [ "dotnet-ef" ] diff --git a/ProjectLighthouse.Localization/ProjectLighthouse.Localization.csproj b/ProjectLighthouse.Localization/ProjectLighthouse.Localization.csproj index 50028a67..edc3f4e7 100644 --- a/ProjectLighthouse.Localization/ProjectLighthouse.Localization.csproj +++ b/ProjectLighthouse.Localization/ProjectLighthouse.Localization.csproj @@ -40,6 +40,10 @@ ResXFileCodeGenerator ModPanel.Designer.cs + + ResXFileCodeGenerator + TwoFactor.Designer.cs + diff --git a/ProjectLighthouse.Localization/StringLists/TwoFactorStrings.cs b/ProjectLighthouse.Localization/StringLists/TwoFactorStrings.cs new file mode 100644 index 00000000..307ad97d --- /dev/null +++ b/ProjectLighthouse.Localization/StringLists/TwoFactorStrings.cs @@ -0,0 +1,29 @@ +namespace LBPUnion.ProjectLighthouse.Localization.StringLists; + +public static class TwoFactorStrings +{ + public static readonly TranslatableString EnableTwoFactor = create("enable_2fa"); + public static readonly TranslatableString DisableTwoFactor = create("disable_2fa"); + + public static readonly TranslatableString TwoFactor = create("2fa"); + public static readonly TranslatableString TwoFactorDescription = create("2fa_description"); + public static readonly TranslatableString TwoFactorBackup = create("2fa_backup_description"); + + public static readonly TranslatableString TwoFactorRequired = create("2fa_required"); + + public static readonly TranslatableString DisableTwoFactorDescription = create("disable_2fa_description"); + + public static readonly TranslatableString InvalidCode = create("invalid_code"); + public static readonly TranslatableString InvalidBackupCode = create("invalid_backup"); + + public static readonly TranslatableString BackupCodeTitle = create("backup_title"); + public static readonly TranslatableString BackupCodeDescription = create("backup_description"); + public static readonly TranslatableString BackupCodeDescription2 = create("backup_description2"); + public static readonly TranslatableString BackupCodeConfirmation = create("backup_confirmation"); + public static readonly TranslatableString DownloadBackupCodes = create("backup_download"); + + public static readonly TranslatableString QrTitle = create("qr_title"); + public static readonly TranslatableString QrDescription = create("qr_description"); + + private static TranslatableString create(string key) => new(TranslationAreas.TwoFactor, key); +} \ No newline at end of file diff --git a/ProjectLighthouse.Localization/TranslationAreas.cs b/ProjectLighthouse.Localization/TranslationAreas.cs index 085ade23..b1d1ce68 100644 --- a/ProjectLighthouse.Localization/TranslationAreas.cs +++ b/ProjectLighthouse.Localization/TranslationAreas.cs @@ -11,4 +11,5 @@ public enum TranslationAreas Error, Profile, ModPanel, + TwoFactor, } \ No newline at end of file diff --git a/ProjectLighthouse.Localization/TwoFactor.resx b/ProjectLighthouse.Localization/TwoFactor.resx new file mode 100644 index 00000000..21d78ec4 --- /dev/null +++ b/ProjectLighthouse.Localization/TwoFactor.resx @@ -0,0 +1,69 @@ + + + + + + + + + + text/microsoft-resx + + + 1.3 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Enable Two-Factor + + + Disable Two-Factor + + + Once you have added this two factor code to your app of choice, enter a valid code below to finish the setup process + + + Here is your Two-Factor QR code + + + Backup codes + + + These codes will allow you to regain access to your account if you ever lose access to your 2FA device + + + Save these codes somewhere because otherwise you may be locked out of your account + + + I've saved these codes + + + Invalid 2FA Code + + + To disable two-factor authentication, enter a correct code from your authenticator app. + + + Two-Factor Authentication + + + You are required to setup 2FA because of your role within this instance. + + + Enter a valid 2FA code to continue + + + Alternatively, you can click {0}here{1} to enter one of your backup codes + + + Download backup codes + + + Invalid Backup Code + + \ No newline at end of file diff --git a/ProjectLighthouse.Servers.Website/Middlewares/UserRequiredRedirectMiddleware.cs b/ProjectLighthouse.Servers.Website/Middlewares/UserRequiredRedirectMiddleware.cs index 92d81793..75336a8c 100644 --- a/ProjectLighthouse.Servers.Website/Middlewares/UserRequiredRedirectMiddleware.cs +++ b/ProjectLighthouse.Servers.Website/Middlewares/UserRequiredRedirectMiddleware.cs @@ -1,6 +1,8 @@ using LBPUnion.ProjectLighthouse.Configuration; using LBPUnion.ProjectLighthouse.Middlewares; +using LBPUnion.ProjectLighthouse.PlayerData; using LBPUnion.ProjectLighthouse.PlayerData.Profiles; +using Microsoft.EntityFrameworkCore; namespace LBPUnion.ProjectLighthouse.Servers.Website.Middlewares; @@ -12,14 +14,16 @@ public class UserRequiredRedirectMiddleware : MiddlewareDBContext public override async Task InvokeAsync(HttpContext ctx, Database database) { User? user = database.UserFromWebRequest(ctx.Request); - if (user == null || ctx.Request.Path.StartsWithSegments("/logout")) + if (user == null || pathContains(ctx, "/logout")) { await this.next(ctx); return; } + WebToken token = await database.WebTokens.FirstAsync(t => t.UserId == user.UserId); + // Request ends with a path (e.g. /css/style.css) - if (!string.IsNullOrEmpty(Path.GetExtension(ctx.Request.Path)) || ctx.Request.Path.StartsWithSegments("/gameAssets")) + if (!string.IsNullOrEmpty(Path.GetExtension(ctx.Request.Path)) || pathContains(ctx, "/gameAssets")) { await this.next(ctx); return; @@ -27,8 +31,7 @@ public class UserRequiredRedirectMiddleware : MiddlewareDBContext if (user.PasswordResetRequired) { - if (!ctx.Request.Path.StartsWithSegments("/passwordResetRequired") && - !ctx.Request.Path.StartsWithSegments("/passwordReset")) + if (!pathContains(ctx, "/passwordResetRequired", "/passwordReset")) { ctx.Response.Redirect("/passwordResetRequired"); return; @@ -38,7 +41,7 @@ public class UserRequiredRedirectMiddleware : MiddlewareDBContext return; } - if (ServerConfiguration.Instance.Mail.MailEnabled) + if (!user.EmailAddressVerified && ServerConfiguration.Instance.Mail.MailEnabled) { // The normal flow is for users to set their email during login so just force them to log out if (user.EmailAddress == null) @@ -47,15 +50,44 @@ public class UserRequiredRedirectMiddleware : MiddlewareDBContext return; } - if (!user.EmailAddressVerified && - !ctx.Request.Path.StartsWithSegments("/login/sendVerificationEmail") && - !ctx.Request.Path.StartsWithSegments("/verifyEmail")) + if (!pathContains(ctx, "/login/sendVerificationEmail", "/verifyEmail")) { ctx.Response.Redirect("/login/sendVerificationEmail"); return; } + + await this.next(ctx); + return; + } + + if (user.TwoFactorRequired && !user.IsTwoFactorSetup && ServerConfiguration.Instance.TwoFactorConfiguration.TwoFactorEnabled) + { + if (!pathContains(ctx, "/setup2fa")) + { + ctx.Response.Redirect("/setup2fa"); + return; + } + + await this.next(ctx); + return; + } + + if (!token.Verified && ServerConfiguration.Instance.TwoFactorConfiguration.TwoFactorEnabled) + { + if (!pathContains(ctx, "/2fa")) + { + ctx.Response.Redirect("/2fa"); + return; + } + await this.next(ctx); + return; } await this.next(ctx); } + + private static bool pathContains(HttpContext ctx, params string[] pathList) + { + return pathList.Any(path => ctx.Request.Path.StartsWithSegments(path)); + } } \ No newline at end of file diff --git a/ProjectLighthouse.Servers.Website/Pages/LoginForm.cshtml.cs b/ProjectLighthouse.Servers.Website/Pages/LoginForm.cshtml.cs index 709cf2c2..afd94755 100644 --- a/ProjectLighthouse.Servers.Website/Pages/LoginForm.cshtml.cs +++ b/ProjectLighthouse.Servers.Website/Pages/LoginForm.cshtml.cs @@ -1,4 +1,5 @@ #nullable enable +using System.Web; using JetBrains.Annotations; using LBPUnion.ProjectLighthouse.Configuration; using LBPUnion.ProjectLighthouse.Extensions; @@ -9,7 +10,6 @@ using LBPUnion.ProjectLighthouse.PlayerData; using LBPUnion.ProjectLighthouse.PlayerData.Profiles; using LBPUnion.ProjectLighthouse.PlayerData.Profiles.Email; using LBPUnion.ProjectLighthouse.Servers.Website.Pages.Layouts; -using LBPUnion.ProjectLighthouse.Types; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -108,6 +108,7 @@ public class LoginForm : BaseLayout UserId = user.UserId, UserToken = CryptoHelper.GenerateAuthToken(), ExpiresAt = DateTime.Now + TimeSpan.FromDays(7), + Verified = !ServerConfiguration.Instance.TwoFactorConfiguration.TwoFactorEnabled || !user.IsTwoFactorSetup, }; this.Database.WebTokens.Add(webToken); @@ -128,6 +129,14 @@ public class LoginForm : BaseLayout if (user.PasswordResetRequired) return this.Redirect("~/passwordResetRequired"); if (ServerConfiguration.Instance.Mail.MailEnabled && !user.EmailAddressVerified) return this.Redirect("~/login/sendVerificationEmail"); + if (!webToken.Verified) + { + return string.IsNullOrWhiteSpace(redirect) + ? this.Redirect("~/2fa") + : this.Redirect("~/2fa" + "?redirect=" + HttpUtility.UrlEncode(redirect)); + } + + if (string.IsNullOrWhiteSpace(redirect)) { return this.RedirectToPage(nameof(LandingPage)); diff --git a/ProjectLighthouse.Servers.Website/Pages/Partials/TwoFactorPartial.cshtml b/ProjectLighthouse.Servers.Website/Pages/Partials/TwoFactorPartial.cshtml new file mode 100644 index 00000000..a6843bc7 --- /dev/null +++ b/ProjectLighthouse.Servers.Website/Pages/Partials/TwoFactorPartial.cshtml @@ -0,0 +1,123 @@ +@using LBPUnion.ProjectLighthouse.Localization.StringLists +@{ + string submitUrl = (string?)ViewData["SubmitUrl"] ?? "/2fa"; + string callbackUrl = (string?)ViewData["CallbackUrl"] ?? ""; + string error = (string?)ViewData["Error"] ?? ""; + bool allowBackupCodes = (bool?)ViewData["BackupCodes"] ?? true; +} + +
+ @Html.AntiForgeryToken() +
+ @if (!string.IsNullOrWhiteSpace(error)) + { +
+
+ @Model.Translate(GeneralStrings.Error) +
+

@Model.Error

+
+ } + + + + + + + + + + @if (allowBackupCodes) + { + const string opening = ""; + const string closing = ""; + string formatted = TwoFactorStrings.TwoFactorBackup.Translate(Model.GetLanguage(), opening, closing); +
+
@Html.Raw(formatted)
+ + } +
+
+ + diff --git a/ProjectLighthouse.Servers.Website/Pages/TwoFactor/DisableTwoFactorPage.cshtml b/ProjectLighthouse.Servers.Website/Pages/TwoFactor/DisableTwoFactorPage.cshtml new file mode 100644 index 00000000..87238c01 --- /dev/null +++ b/ProjectLighthouse.Servers.Website/Pages/TwoFactor/DisableTwoFactorPage.cshtml @@ -0,0 +1,23 @@ +@page "/remove2fa" +@using LBPUnion.ProjectLighthouse.Localization.StringLists +@model LBPUnion.ProjectLighthouse.Servers.Website.Pages.TwoFactor.DisableTwoFactorPage + +@{ + Layout = "Layouts/BaseLayout"; + Model.Title = Model.Translate(TwoFactorStrings.DisableTwoFactor); +} + + +
+

@Model.Translate(TwoFactorStrings.DisableTwoFactor)

+

@Model.Translate(TwoFactorStrings.DisableTwoFactorDescription)

+ @await Html.PartialAsync("Partials/TwoFactorPartial", new ViewDataDictionary(ViewData) + { + { + "SubmitUrl", "/remove2fa" + }, + { + "Error", Model.Error + }, + }) +
\ No newline at end of file diff --git a/ProjectLighthouse.Servers.Website/Pages/TwoFactor/DisableTwoFactorPage.cshtml.cs b/ProjectLighthouse.Servers.Website/Pages/TwoFactor/DisableTwoFactorPage.cshtml.cs new file mode 100644 index 00000000..8085cf2f --- /dev/null +++ b/ProjectLighthouse.Servers.Website/Pages/TwoFactor/DisableTwoFactorPage.cshtml.cs @@ -0,0 +1,67 @@ +using LBPUnion.ProjectLighthouse.Configuration; +using LBPUnion.ProjectLighthouse.Helpers; +using LBPUnion.ProjectLighthouse.Localization.StringLists; +using LBPUnion.ProjectLighthouse.PlayerData.Profiles; +using LBPUnion.ProjectLighthouse.Servers.Website.Pages.Layouts; +using Microsoft.AspNetCore.Mvc; + +namespace LBPUnion.ProjectLighthouse.Servers.Website.Pages.TwoFactor; + +public class DisableTwoFactorPage : BaseLayout +{ + public DisableTwoFactorPage(Database database) : base(database) { } + + public string Error { get; set; } = ""; + + public IActionResult OnGet() + { + if (!ServerConfiguration.Instance.TwoFactorConfiguration.TwoFactorEnabled) return this.Redirect("~/login"); + + User? user = this.Database.UserFromWebRequest(this.Request); + if (user == null) return this.Redirect("~/login"); + + if (!user.IsTwoFactorSetup) return this.Redirect("~/user/" + user.UserId + "/settings"); + + return this.Page(); + } + + public async Task OnPost([FromForm] string? code, [FromForm] string? backup) + { + if (!ServerConfiguration.Instance.TwoFactorConfiguration.TwoFactorEnabled) return this.Redirect("~/login"); + + User? user = this.Database.UserFromWebRequest(this.Request); + if (user == null) return this.Redirect("~/login"); + + if (!user.IsTwoFactorSetup) return this.Redirect("~/user/" + user.UserId + "/settings"); + + // if both are null or neither are null, there should only be one at at time + if (string.IsNullOrWhiteSpace(code) == string.IsNullOrWhiteSpace(backup)) + { + this.Error = this.Translate(TwoFactorStrings.InvalidCode); + return this.Page(); + } + + if (string.IsNullOrWhiteSpace(backup)) + { + if (!CryptoHelper.VerifyCode(code, user.TwoFactorSecret)) + { + this.Error = this.Translate(TwoFactorStrings.InvalidCode); + return this.Page(); + } + } + else + { + if(!CryptoHelper.VerifyBackup(backup, user.TwoFactorBackup)) + { + this.Error = this.Translate(TwoFactorStrings.InvalidBackupCode); + return this.Page(); + } + } + + user.TwoFactorBackup = null; + user.TwoFactorSecret = null; + await this.Database.SaveChangesAsync(); + + return this.Redirect("~/user/" + user.UserId + "/settings"); + } +} \ No newline at end of file diff --git a/ProjectLighthouse.Servers.Website/Pages/TwoFactor/SetupTwoFactorPage.cshtml b/ProjectLighthouse.Servers.Website/Pages/TwoFactor/SetupTwoFactorPage.cshtml new file mode 100644 index 00000000..736e258e --- /dev/null +++ b/ProjectLighthouse.Servers.Website/Pages/TwoFactor/SetupTwoFactorPage.cshtml @@ -0,0 +1,87 @@ +@page "/setup2fa" +@using LBPUnion.ProjectLighthouse.Configuration +@using LBPUnion.ProjectLighthouse.Localization.StringLists +@model LBPUnion.ProjectLighthouse.Servers.Website.Pages.TwoFactor.SetupTwoFactorPage + +@{ + Layout = "Layouts/BaseLayout"; + Model.Title = Model.Translate(TwoFactorStrings.EnableTwoFactor); +} + +@if (!string.IsNullOrWhiteSpace(Model.QrCode)) +{ +
+ @if (Model.User?.TwoFactorRequired ?? false) + { +

@Model.Translate(TwoFactorStrings.TwoFactorRequired)

+ } +

@Model.Translate(TwoFactorStrings.QrTitle)

+ 2 Factor QR Code +

@Model.Translate(TwoFactorStrings.QrDescription)

+ @await Html.PartialAsync("Partials/TwoFactorPartial", new ViewDataDictionary(ViewData) + { + { + "SubmitUrl", "/setup2fa" + }, + { + "Error", Model.Error + }, + { + "BackupCodes", false + }, + }) +
+} +else +{ +
+

IMPORTANT

+

@Model.Translate(TwoFactorStrings.BackupCodeTitle)

+

@Model.Translate(TwoFactorStrings.BackupCodeDescription) +
@Model.Translate(TwoFactorStrings.BackupCodeDescription2) +

+

+ @foreach (string backupCode in Model.User!.TwoFactorBackup.Split(",")) + { + @backupCode
+ } +

+ + + @Model.Translate(TwoFactorStrings.DownloadBackupCodes) + + + + @Model.Translate(TwoFactorStrings.BackupCodeConfirmation) + +
+ +} \ No newline at end of file diff --git a/ProjectLighthouse.Servers.Website/Pages/TwoFactor/SetupTwoFactorPage.cshtml.cs b/ProjectLighthouse.Servers.Website/Pages/TwoFactor/SetupTwoFactorPage.cshtml.cs new file mode 100644 index 00000000..24bcc897 --- /dev/null +++ b/ProjectLighthouse.Servers.Website/Pages/TwoFactor/SetupTwoFactorPage.cshtml.cs @@ -0,0 +1,135 @@ +#nullable enable +using System.Security.Cryptography; +using System.Text; +using System.Web; +using LBPUnion.ProjectLighthouse.Configuration; +using LBPUnion.ProjectLighthouse.Helpers; +using LBPUnion.ProjectLighthouse.Localization.StringLists; +using LBPUnion.ProjectLighthouse.PlayerData; +using LBPUnion.ProjectLighthouse.PlayerData.Profiles; +using LBPUnion.ProjectLighthouse.Servers.Website.Pages.Layouts; +using Microsoft.AspNetCore.Mvc; +using QRCoder; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Formats.Png; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; + +namespace LBPUnion.ProjectLighthouse.Servers.Website.Pages.TwoFactor; + +public class SetupTwoFactorPage : BaseLayout +{ + public SetupTwoFactorPage(Database database) : base(database) + { } + + public string QrCode { get; set; } = ""; + + public string Error { get; set; } = ""; + + public async Task OnGet() + { + if (!ServerConfiguration.Instance.TwoFactorConfiguration.TwoFactorEnabled) return this.Redirect("~/login"); + + User? user = this.Database.UserFromWebRequest(this.Request); + if (user == null) return this.Redirect("~/login"); + + if (user.IsTwoFactorSetup) return this.Redirect("~/"); + + // Don't regenerate the two factor secret if they accidentally refresh the page + if (string.IsNullOrWhiteSpace(user.TwoFactorSecret)) user.TwoFactorSecret = CryptoHelper.GenerateTotpSecret(); + + this.QrCode = getQrCode(user); + + await this.Database.SaveChangesAsync(); + + return this.Page(); + } + + private static string GenerateQrCode(string text, int pixelsPerModule, Color darkColor, Color lightColor, bool drawQuietZones) + { + QRCodeGenerator qrGenerator = new(); + QRCodeData qrCodeData = qrGenerator.CreateQrCode(text, QRCodeGenerator.ECCLevel.Q); + + int size = (qrCodeData.ModuleMatrix.Count - (drawQuietZones ? 0 : 8)) * pixelsPerModule; + int offset = drawQuietZones ? 0 : 4 * pixelsPerModule; + + Image image = new Image(size, size); + Rgba32 dark = darkColor.ToPixel(); + Rgba32 light = lightColor.ToPixel(); + image.Mutate(c => c.ProcessPixelRowsAsVector4((span, value) => + { + for (int x = 0; x < span.Length; x++) + { + int y = value.Y; + int offsetX = x + offset; + int offsetY = y + offset; + + bool module = + qrCodeData.ModuleMatrix[(offsetY + pixelsPerModule) / pixelsPerModule - 1][ + (offsetX + pixelsPerModule) / pixelsPerModule - 1]; + if (module) + { + span[x].X = dark.R / 255f; + span[x].Y = dark.G / 255f; + span[x].Z = dark.B / 255f; + span[x].W = dark.A / 255f; + } + else + { + span[x].X = light.R / 255f; + span[x].Y = light.G / 255f; + span[x].Z = light.B / 255f; + span[x].W = light.A / 255f; + } + } + })); + return image.ToBase64String(PngFormat.Instance); + } + + private static string getQrCode(User user) + { + string instanceName = ServerConfiguration.Instance.Customization.ServerName; + string totpLink = CryptoHelper.GenerateTotpLink(user.TwoFactorSecret, HttpUtility.HtmlEncode(instanceName), user.Username); + return GenerateQrCode(totpLink, 6, Color.FromRgb(18, 18, 18), Color.Transparent, false); + } + + public async Task OnPost([FromForm] string? code) + { + if (!ServerConfiguration.Instance.TwoFactorConfiguration.TwoFactorEnabled) return this.Redirect("~/login"); + + WebToken? token = this.Database.WebTokenFromRequest(this.Request); + if (token == null) return this.Redirect("~/login"); + + User? user = this.Database.UserFromWebRequest(this.Request); + if (user == null) return this.Redirect("~/login"); + + if (user.IsTwoFactorSetup) return this.Redirect("~/"); + + if (CryptoHelper.VerifyCode(code, user.TwoFactorSecret)) + { + List backups = new(); + const string alphabet = "abcdefghijklmnopqrstuvwxyz0123456789"; + // 6 backup codes, format = [0-9a-z]{5}-[0-9a-z]{5} + for (int i = 0; i < 6; i++) + { + StringBuilder backupCode = new(); + for (int j = 0; j < 10; j++) + { + backupCode.Append(alphabet[RandomNumberGenerator.GetInt32(0, alphabet.Length)]); + if (j == 4) backupCode.Append('-'); + } + backups.Add(backupCode.ToString()); + } + user.TwoFactorBackup = string.Join(",", backups); + token.Verified = true; + await this.Database.SaveChangesAsync(); + + return this.Page(); + } + this.QrCode = getQrCode(user); + this.Error = this.Translate(TwoFactorStrings.InvalidCode); + + return this.Page(); + } + +} \ No newline at end of file diff --git a/ProjectLighthouse.Servers.Website/Pages/TwoFactor/TwoFactorLoginPage.cshtml b/ProjectLighthouse.Servers.Website/Pages/TwoFactor/TwoFactorLoginPage.cshtml new file mode 100644 index 00000000..6d7376ef --- /dev/null +++ b/ProjectLighthouse.Servers.Website/Pages/TwoFactor/TwoFactorLoginPage.cshtml @@ -0,0 +1,26 @@ +@page "/2fa" +@using LBPUnion.ProjectLighthouse.Localization.StringLists +@model LBPUnion.ProjectLighthouse.Servers.Website.Pages.TwoFactor.TwoFactorLoginPage + +@{ + Layout = "Layouts/BaseLayout"; + Model.Title = Model.Translate(TwoFactorStrings.TwoFactor); +} + + +
+

@Model.Translate(TwoFactorStrings.TwoFactor)

+

@Model.Translate(TwoFactorStrings.TwoFactorDescription)

+ @await Html.PartialAsync("Partials/TwoFactorPartial", new ViewDataDictionary(ViewData) + { + { + "SubmitUrl", "/2fa" + }, + { + "Error", Model.Error + }, + { + "CallbackUrl", Model.RedirectUrl + } + }) +
\ No newline at end of file diff --git a/ProjectLighthouse.Servers.Website/Pages/TwoFactor/TwoFactorLoginPage.cshtml.cs b/ProjectLighthouse.Servers.Website/Pages/TwoFactor/TwoFactorLoginPage.cshtml.cs new file mode 100644 index 00000000..6d3fc2e8 --- /dev/null +++ b/ProjectLighthouse.Servers.Website/Pages/TwoFactor/TwoFactorLoginPage.cshtml.cs @@ -0,0 +1,88 @@ +using LBPUnion.ProjectLighthouse.Configuration; +using LBPUnion.ProjectLighthouse.Helpers; +using LBPUnion.ProjectLighthouse.Localization.StringLists; +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.TwoFactor; + +public class TwoFactorLoginPage : BaseLayout +{ + public TwoFactorLoginPage(Database database) : base(database) + { } + + public string Error { get; set; } = ""; + public string RedirectUrl { get; set; } = ""; + + public async Task OnGet([FromQuery] string? redirect) + { + if (!ServerConfiguration.Instance.TwoFactorConfiguration.TwoFactorEnabled) return this.Redirect("~/login"); + + WebToken? token = this.Database.WebTokenFromRequest(this.Request); + if (token == null) return this.Redirect("~/login"); + + this.RedirectUrl = redirect ?? "~/"; + + if (token.Verified) return this.Redirect(this.RedirectUrl); + + User? user = await this.Database.Users.Where(u => u.UserId == token.UserId).FirstOrDefaultAsync(); + if (user == null) return this.Redirect("~/login"); + + if (!user.IsTwoFactorSetup) return this.Redirect(this.RedirectUrl); + + return this.Page(); + } + + public async Task OnPost([FromForm] string? code, [FromForm] string? redirect, [FromForm] string? backup) + { + if (!ServerConfiguration.Instance.TwoFactorConfiguration.TwoFactorEnabled) return this.Redirect("~/login"); + + WebToken? token = this.Database.WebTokenFromRequest(this.Request); + if (token == null) return this.Redirect("~/login"); + + this.RedirectUrl = redirect ?? "~/"; + + if (token.Verified) return this.Redirect(this.RedirectUrl); + + User? user = await this.Database.Users.Where(u => u.UserId == token.UserId).FirstOrDefaultAsync(); + if (user == null) return this.Redirect("~/login"); + + if (!user.IsTwoFactorSetup) + { + token.Verified = true; + await this.Database.SaveChangesAsync(); + } + + // if both are null or neither are null, there should only be one at at time + if (string.IsNullOrWhiteSpace(code) == string.IsNullOrWhiteSpace(backup)) + { + this.Error = this.Translate(TwoFactorStrings.InvalidCode); + return this.Page(); + } + + if (string.IsNullOrWhiteSpace(backup)) + { + if (!CryptoHelper.VerifyCode(code, user.TwoFactorSecret)) + { + this.Error = this.Translate(TwoFactorStrings.InvalidCode); + return this.Page(); + } + } + else + { + if (!CryptoHelper.VerifyBackup(backup, user.TwoFactorBackup)) + { + this.Error = this.Translate(TwoFactorStrings.InvalidBackupCode); + return this.Page(); + } + } + + token.Verified = true; + await this.Database.SaveChangesAsync(); + + return this.Redirect(this.RedirectUrl); + } +} \ No newline at end of file diff --git a/ProjectLighthouse.Servers.Website/Pages/UserSettingsPage.cshtml b/ProjectLighthouse.Servers.Website/Pages/UserSettingsPage.cshtml index 59b8a378..bc610ee4 100644 --- a/ProjectLighthouse.Servers.Website/Pages/UserSettingsPage.cshtml +++ b/ProjectLighthouse.Servers.Website/Pages/UserSettingsPage.cshtml @@ -114,16 +114,37 @@ function onSubmit(e){ } + @if (Model.User == Model.ProfileUser) + { +
+ + + + @Model.Translate(GeneralStrings.ResetPassword) + + @if (ServerConfiguration.Instance.TwoFactorConfiguration.TwoFactorEnabled) + { + @if (Model.ProfileUser.IsTwoFactorSetup) + { + + + @Model.Translate(TwoFactorStrings.DisableTwoFactor) + + } + else + { + + + @Model.Translate(TwoFactorStrings.EnableTwoFactor) + + } + } +
+ } +
Discard Changes - @if (Model.User == Model.ProfileUser) - { - - - @Model.Translate(GeneralStrings.ResetPassword) - - } diff --git a/ProjectLighthouse.Servers.Website/ProjectLighthouse.Servers.Website.csproj b/ProjectLighthouse.Servers.Website/ProjectLighthouse.Servers.Website.csproj index b81f7ec8..99457736 100644 --- a/ProjectLighthouse.Servers.Website/ProjectLighthouse.Servers.Website.csproj +++ b/ProjectLighthouse.Servers.Website/ProjectLighthouse.Servers.Website.csproj @@ -11,6 +11,7 @@ + diff --git a/ProjectLighthouse.Tests.WebsiteTests/Tests/AdminTests.cs b/ProjectLighthouse.Tests.WebsiteTests/Tests/AdminTests.cs index 69ef1d99..46f5bc58 100644 --- a/ProjectLighthouse.Tests.WebsiteTests/Tests/AdminTests.cs +++ b/ProjectLighthouse.Tests.WebsiteTests/Tests/AdminTests.cs @@ -27,6 +27,7 @@ public class AdminTests : LighthouseWebTest UserId = user.UserId, UserToken = CryptoHelper.GenerateAuthToken(), ExpiresAt = DateTime.Now + TimeSpan.FromHours(1), + Verified = true, }; database.WebTokens.Add(webToken); @@ -52,6 +53,7 @@ public class AdminTests : LighthouseWebTest UserId = user.UserId, UserToken = CryptoHelper.GenerateAuthToken(), ExpiresAt = DateTime.Now + TimeSpan.FromHours(1), + Verified = true, }; database.WebTokens.Add(webToken); diff --git a/ProjectLighthouse.Tests.WebsiteTests/Tests/AuthenticationTests.cs b/ProjectLighthouse.Tests.WebsiteTests/Tests/AuthenticationTests.cs index 638c23fd..8c882500 100644 --- a/ProjectLighthouse.Tests.WebsiteTests/Tests/AuthenticationTests.cs +++ b/ProjectLighthouse.Tests.WebsiteTests/Tests/AuthenticationTests.cs @@ -89,6 +89,7 @@ public class AuthenticationTests : LighthouseWebTest UserId = user.UserId, UserToken = CryptoHelper.GenerateAuthToken(), ExpiresAt = DateTime.Now + TimeSpan.FromHours(1), + Verified = true, }; database.WebTokens.Add(webToken); diff --git a/ProjectLighthouse/Configuration/ConfigurationCategories/TwoFactorConfiguration.cs b/ProjectLighthouse/Configuration/ConfigurationCategories/TwoFactorConfiguration.cs new file mode 100644 index 00000000..7e27bd4f --- /dev/null +++ b/ProjectLighthouse/Configuration/ConfigurationCategories/TwoFactorConfiguration.cs @@ -0,0 +1,10 @@ +using LBPUnion.ProjectLighthouse.Administration; + +namespace LBPUnion.ProjectLighthouse.Configuration.ConfigurationCategories; + +public class TwoFactorConfiguration +{ + public bool TwoFactorEnabled { get; set; } = true; + public bool RequireTwoFactor { get; set; } = true; + public PermissionLevel RequiredTwoFactorLevel { get; set; } = PermissionLevel.Moderator; +} \ No newline at end of file diff --git a/ProjectLighthouse/Configuration/ServerConfiguration.cs b/ProjectLighthouse/Configuration/ServerConfiguration.cs index 4db842fc..466dfe03 100644 --- a/ProjectLighthouse/Configuration/ServerConfiguration.cs +++ b/ProjectLighthouse/Configuration/ServerConfiguration.cs @@ -23,7 +23,7 @@ public class ServerConfiguration // You can use an ObsoleteAttribute instead. Make sure you set it to error, though. // // Thanks for listening~ - public const int CurrentConfigVersion = 13; + public const int CurrentConfigVersion = 14; #region Meta @@ -199,4 +199,5 @@ public class ServerConfiguration public WebsiteConfiguration WebsiteConfiguration { get; set; } = new(); public CustomizationConfiguration Customization { get; set; } = new(); public RateLimitConfiguration RateLimitConfiguration { get; set; } = new(); + public TwoFactorConfiguration TwoFactorConfiguration { get; set; } = new(); } \ No newline at end of file diff --git a/ProjectLighthouse/Helpers/Base32Helper.cs b/ProjectLighthouse/Helpers/Base32Helper.cs new file mode 100644 index 00000000..94891643 --- /dev/null +++ b/ProjectLighthouse/Helpers/Base32Helper.cs @@ -0,0 +1,109 @@ +using System; +using System.Linq; + +namespace LBPUnion.ProjectLighthouse.Helpers; + +public static class Base32Encoding + { + public static byte[] ToBytes(string input) + { + if(string.IsNullOrEmpty(input)) throw new ArgumentNullException(nameof(input)); + + input = input.TrimEnd('='); // remove padding characters + int byteCount = input.Length * 5 / 8; // this must be truncated + byte[] returnArray = new byte[byteCount]; + + byte curByte = 0, bitsRemaining = 8; + int arrayIndex = 0; + + foreach (int cValue in input.Select(CharToValue)) + { + int mask; + if(bitsRemaining > 5) + { + mask = cValue << (bitsRemaining - 5); + curByte = (byte)(curByte | mask); + bitsRemaining -= 5; + } + else + { + mask = cValue >> (5 - bitsRemaining); + curByte = (byte)(curByte | mask); + returnArray[arrayIndex++] = curByte; + curByte = (byte)(cValue << (3 + bitsRemaining)); + bitsRemaining += 3; + } + } + + // if we didn't end with a full byte + if(arrayIndex != byteCount) + { + returnArray[arrayIndex] = curByte; + } + + return returnArray; + } + + public static string ToString(byte[] input) + { + if(input == null || input.Length == 0) + { + throw new ArgumentNullException(nameof(input)); + } + + int charCount = (int)Math.Ceiling(input.Length / 5d) * 8; + char[] returnArray = new char[charCount]; + + byte nextChar = 0, bitsRemaining = 5; + int arrayIndex = 0; + + foreach(byte b in input) + { + nextChar = (byte)(nextChar | (b >> (8 - bitsRemaining))); + returnArray[arrayIndex++] = ValueToChar(nextChar); + + if(bitsRemaining < 4) + { + nextChar = (byte)((b >> (3 - bitsRemaining)) & 31); + returnArray[arrayIndex++] = ValueToChar(nextChar); + bitsRemaining += 5; + } + + bitsRemaining -= 3; + nextChar = (byte)((b << bitsRemaining) & 31); + } + + // if we didn't end with a full char + if (arrayIndex == charCount) return new string(returnArray); + returnArray[arrayIndex++] = ValueToChar(nextChar); + while(arrayIndex != charCount) returnArray[arrayIndex++] = '='; // padding + + return new string(returnArray); + } + + private static int CharToValue(char c) + { + int value = c; + + return value switch + { + // 65-90 == uppercase letters + < 91 and > 64 => value - 65, + // 50-55 == numbers 2-7 + < 56 and > 49 => value - 24, + // 97-122 == lowercase letters + < 123 and > 96 => value - 97, + _ => throw new ArgumentException(@"Character is not a Base32 character.", nameof(c)), + }; + } + + private static char ValueToChar(byte b) + { + return b switch + { + < 26 => (char)(b + 65), + < 32 => (char)(b + 24), + _ => throw new ArgumentException(@"Byte is not a Base32 value.", nameof(b)), + }; + } + } \ No newline at end of file diff --git a/ProjectLighthouse/Helpers/CryptoHelper.cs b/ProjectLighthouse/Helpers/CryptoHelper.cs index 644a971a..b29841e7 100644 --- a/ProjectLighthouse/Helpers/CryptoHelper.cs +++ b/ProjectLighthouse/Helpers/CryptoHelper.cs @@ -2,9 +2,11 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.IO; +using System.Linq; using System.Security.Cryptography; using System.Text; using System.Threading.Tasks; +using LBPUnion.ProjectLighthouse.Extensions; namespace LBPUnion.ProjectLighthouse.Helpers; @@ -86,6 +88,80 @@ public static class CryptoHelper return Encoding.UTF8.GetString(bytes); } + #region Two Factor Authentication + + public static string GenerateTotpSecret() + { + // RFC 4226 recommends the secret to be 160 bits i.e. 20 bytes + byte[] rand = (byte[])GenerateRandomBytes(20); + + // Base 64 bad apparently + return Base32Encoding.ToString(rand); + } + + public static bool VerifyBackup(string code, string backups) => backups.Split(",").Any(backup => ValuesEqual(code, backup)); + + public static bool VerifyCode(string code, string secret) + { + if (code.Length != 6) return false; + + long window = TimeHelper.Timestamp / 30; + + byte[] secretBytes = Base32Encoding.ToBytes(secret); + for (int i = -1; i <= 1; i++) + { + byte[] windowBytes = BitConverter.GetBytes(window + i); + if (BitConverter.IsLittleEndian) windowBytes.Reverse(); + + long genCode = generateTotpCode(secretBytes, windowBytes); + string strCode = genCode.ToString(); + strCode = strCode.Substring(strCode.Length - 6, 6); + if (ValuesEqual(strCode, code)) + { + return true; + } + } + return false; + } + + private static long generateTotpCode(byte[] secret, byte[] data) + { + using HMACSHA1 hmac = new(secret); + + byte[] computedHash = hmac.ComputeHash(data); + + // The RFC has a hard coded index 19 in this value. + // This is the same thing but also accommodates SHA256 and SHA512 + // hmacComputedHash[19] => hmacComputedHash[hmacComputedHash.Length - 1] + + int offset = computedHash[^1] & 0xf; + return (computedHash[offset] & 0x7f) << 24 | + (computedHash[offset + 1] & 0xff) << 16 | + (computedHash[offset + 2] & 0xff) << 8 | + (computedHash[offset + 3] & 0xff) % 1000000; + } + + // Constant time comparison of two values + private static bool ValuesEqual(string a, string b) + { + if (a.Length != b.Length) + { + return false; + } + + int result = 0; + for (int i = 0; i < a.Length; i++) + { + result |= a[i] ^ b[i]; + } + + return result == 0; + } + + public static string GenerateTotpLink(string secret, string issuer, string username) => $"otpauth://totp/{issuer}:{username}?secret={secret}&issuer={issuer}&algorithm=SHA1&digits=6&period=30"; + + #endregion + #region Hash Functions public static string Sha256Hash(string str) => Sha256Hash(Encoding.UTF8.GetBytes(str)); diff --git a/ProjectLighthouse/Migrations/20221105212037_AddTwoFactorToUser.cs b/ProjectLighthouse/Migrations/20221105212037_AddTwoFactorToUser.cs new file mode 100644 index 00000000..5fc60425 --- /dev/null +++ b/ProjectLighthouse/Migrations/20221105212037_AddTwoFactorToUser.cs @@ -0,0 +1,41 @@ +using LBPUnion.ProjectLighthouse; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ProjectLighthouse.Migrations +{ + [DbContext(typeof(Database))] + [Migration("20221105212037_AddTwoFactorToUser")] + public partial class AddTwoFactorToUser : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "TwoFactorBackup", + table: "Users", + type: "longtext", + nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.AddColumn( + name: "TwoFactorSecret", + table: "Users", + type: "longtext", + nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "TwoFactorBackup", + table: "Users"); + + migrationBuilder.DropColumn( + name: "TwoFactorSecret", + table: "Users"); + } + } +} diff --git a/ProjectLighthouse/Migrations/20221118162114_AddVerifiedToWebToken.cs b/ProjectLighthouse/Migrations/20221118162114_AddVerifiedToWebToken.cs new file mode 100644 index 00000000..edeb951b --- /dev/null +++ b/ProjectLighthouse/Migrations/20221118162114_AddVerifiedToWebToken.cs @@ -0,0 +1,30 @@ +using LBPUnion.ProjectLighthouse; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ProjectLighthouse.Migrations +{ + [DbContext(typeof(Database))] + [Migration("20221118162114_AddVerifiedToWebToken")] + public partial class AddVerifiedToWebToken : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Verified", + table: "WebTokens", + type: "tinyint(1)", + nullable: false, + defaultValue: false); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Verified", + table: "WebTokens"); + } + } +} \ No newline at end of file diff --git a/ProjectLighthouse/PlayerData/Profiles/User.cs b/ProjectLighthouse/PlayerData/Profiles/User.cs index 1745ccbd..05538c93 100644 --- a/ProjectLighthouse/PlayerData/Profiles/User.cs +++ b/ProjectLighthouse/PlayerData/Profiles/User.cs @@ -194,6 +194,19 @@ public class User public PrivacyType ProfileVisibility { get; set; } = PrivacyType.All; + [JsonIgnore] + public bool TwoFactorRequired => ServerConfiguration.Instance.TwoFactorConfiguration.RequireTwoFactor && + this.PermissionLevel >= ServerConfiguration.Instance.TwoFactorConfiguration.RequiredTwoFactorLevel; + + [JsonIgnore] + public bool IsTwoFactorSetup => this.TwoFactorBackup?.Length > 0 && this.TwoFactorSecret?.Length > 0; + + [JsonIgnore] + public string TwoFactorSecret { get; set; } = ""; + + [JsonIgnore] + public string TwoFactorBackup { get; set; } = ""; + // should not be adjustable by user public bool CommentsEnabled { get; set; } = true; diff --git a/ProjectLighthouse/PlayerData/WebToken.cs b/ProjectLighthouse/PlayerData/WebToken.cs index 9ad0f5be..5a775e41 100644 --- a/ProjectLighthouse/PlayerData/WebToken.cs +++ b/ProjectLighthouse/PlayerData/WebToken.cs @@ -14,4 +14,6 @@ public class WebToken public string UserToken { get; set; } public DateTime ExpiresAt { get; set; } + + public bool Verified { get; set; } } \ No newline at end of file diff --git a/ProjectLighthouse/ProjectLighthouse/Migrations/DatabaseModelSnapshot.cs b/ProjectLighthouse/ProjectLighthouse/Migrations/DatabaseModelSnapshot.cs index bc21a18a..74203cc0 100644 --- a/ProjectLighthouse/ProjectLighthouse/Migrations/DatabaseModelSnapshot.cs +++ b/ProjectLighthouse/ProjectLighthouse/Migrations/DatabaseModelSnapshot.cs @@ -16,7 +16,7 @@ namespace ProjectLighthouse.Migrations { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "6.0.9") + .HasAnnotation("ProductVersion", "6.0.10") .HasAnnotation("Relational:MaxIdentifierLength", 64); modelBuilder.Entity("LBPUnion.ProjectLighthouse.Administration.CompletedMigration", b => @@ -815,6 +815,9 @@ namespace ProjectLighthouse.Migrations b.Property("PlanetHashLBP2") .HasColumnType("longtext"); + b.Property("PlanetHashLBP2CC") + .HasColumnType("longtext"); + b.Property("PlanetHashLBP3") .HasColumnType("longtext"); @@ -827,6 +830,12 @@ namespace ProjectLighthouse.Migrations b.Property("TimeZone") .HasColumnType("longtext"); + b.Property("TwoFactorBackup") + .HasColumnType("longtext"); + + b.Property("TwoFactorSecret") + .HasColumnType("longtext"); + b.Property("Username") .IsRequired() .HasColumnType("longtext"); @@ -995,6 +1004,9 @@ namespace ProjectLighthouse.Migrations b.Property("UserToken") .HasColumnType("longtext"); + b.Property("Verified") + .HasColumnType("tinyint(1)"); + b.HasKey("TokenId"); b.ToTable("WebTokens"); diff --git a/ProjectLighthouse/StaticFiles/css/styles.css b/ProjectLighthouse/StaticFiles/css/styles.css index f9f7f285..b99698d6 100644 --- a/ProjectLighthouse/StaticFiles/css/styles.css +++ b/ProjectLighthouse/StaticFiles/css/styles.css @@ -139,11 +139,56 @@ div.cardStatsUnderTitle > span { border-radius: .28571429rem; } - /*#endregion User cards*/ /*#endregion Cards*/ +/* #region Two Factor */ +.digits input { + font-size: 2rem; + width: 2.3rem; + height: 3rem; + text-align: center; + border: 1px solid #d4d4d5; + border-radius: 5px +} + +.middleDigit { + margin-right: 0.5em; +} + +.header { + font-size: 1.2rem; + text-align: center; + font-weight: bold; + padding-bottom: 0.5em; +} + +.digits input:focus { + border: 2px solid #0e91f5; + outline: none; +} + +.digits input { + user-select: text; +} + +.digits { + padding: 20px; + display: inline-block; + user-select: none; +} + +.digits .backup { + display: block; + text-align: left; + margin: auto; + width: 14rem; + border-width: 2px; +} + +/* #endregion Two Factor */ + /*#region Comments*/ .comment {