mirror of
https://github.com/LBPUnion/ProjectLighthouse.git
synced 2025-06-01 21:52:27 +00:00
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
This commit is contained in:
parent
4fd1063502
commit
14d2f0305e
28 changed files with 1077 additions and 20 deletions
|
@ -3,7 +3,7 @@
|
|||
"isRoot": true,
|
||||
"tools": {
|
||||
"dotnet-ef": {
|
||||
"version": "6.0.9",
|
||||
"version": "6.0.10",
|
||||
"commands": [
|
||||
"dotnet-ef"
|
||||
]
|
||||
|
|
|
@ -40,6 +40,10 @@
|
|||
<Generator>ResXFileCodeGenerator</Generator>
|
||||
<LastGenOutput>ModPanel.Designer.cs</LastGenOutput>
|
||||
</EmbeddedResource>
|
||||
<EmbeddedResource Update="TwoFactor.resx">
|
||||
<Generator>ResXFileCodeGenerator</Generator>
|
||||
<LastGenOutput>TwoFactor.Designer.cs</LastGenOutput>
|
||||
</EmbeddedResource>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
|
@ -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);
|
||||
}
|
|
@ -11,4 +11,5 @@ public enum TranslationAreas
|
|||
Error,
|
||||
Profile,
|
||||
ModPanel,
|
||||
TwoFactor,
|
||||
}
|
69
ProjectLighthouse.Localization/TwoFactor.resx
Normal file
69
ProjectLighthouse.Localization/TwoFactor.resx
Normal file
|
@ -0,0 +1,69 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
|
||||
<root>
|
||||
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
|
||||
<xsd:element name="root" msdata:IsDataSet="true">
|
||||
|
||||
</xsd:element>
|
||||
</xsd:schema>
|
||||
<resheader name="resmimetype">
|
||||
<value>text/microsoft-resx</value>
|
||||
</resheader>
|
||||
<resheader name="version">
|
||||
<value>1.3</value>
|
||||
</resheader>
|
||||
<resheader name="reader">
|
||||
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<data name="enable_2fa" xml:space="preserve">
|
||||
<value>Enable Two-Factor</value>
|
||||
</data>
|
||||
<data name="disable_2fa" xml:space="preserve">
|
||||
<value>Disable Two-Factor</value>
|
||||
</data>
|
||||
<data name="qr_description" xml:space="preserve">
|
||||
<value>Once you have added this two factor code to your app of choice, enter a valid code below to finish the setup process</value>
|
||||
</data>
|
||||
<data name="qr_title" xml:space="preserve">
|
||||
<value>Here is your Two-Factor QR code</value>
|
||||
</data>
|
||||
<data name="backup_title" xml:space="preserve">
|
||||
<value>Backup codes</value>
|
||||
</data>
|
||||
<data name="backup_description" xml:space="preserve">
|
||||
<value>These codes will allow you to regain access to your account if you ever lose access to your 2FA device</value>
|
||||
</data>
|
||||
<data name="backup_description2" xml:space="preserve">
|
||||
<value>Save these codes somewhere because otherwise you may be locked out of your account</value>
|
||||
</data>
|
||||
<data name="backup_confirmation" xml:space="preserve">
|
||||
<value>I've saved these codes</value>
|
||||
</data>
|
||||
<data name="invalid_code" xml:space="preserve">
|
||||
<value>Invalid 2FA Code</value>
|
||||
</data>
|
||||
<data name="disable_2fa_description" xml:space="preserve">
|
||||
<value>To disable two-factor authentication, enter a correct code from your authenticator app.</value>
|
||||
</data>
|
||||
<data name="2fa" xml:space="preserve">
|
||||
<value>Two-Factor Authentication</value>
|
||||
</data>
|
||||
<data name="2fa_required" xml:space="preserve">
|
||||
<value>You are required to setup 2FA because of your role within this instance.</value>
|
||||
</data>
|
||||
<data name="2fa_description" xml:space="preserve">
|
||||
<value>Enter a valid 2FA code to continue</value>
|
||||
</data>
|
||||
<data name="2fa_backup_description" xml:space="preserve">
|
||||
<value>Alternatively, you can click {0}here{1} to enter one of your backup codes</value>
|
||||
</data>
|
||||
<data name="backup_download" xml:space="preserve">
|
||||
<value>Download backup codes</value>
|
||||
</data>
|
||||
<data name="invalid_backup" xml:space="preserve">
|
||||
<value>Invalid Backup Code</value>
|
||||
</data>
|
||||
</root>
|
|
@ -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));
|
||||
}
|
||||
}
|
|
@ -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));
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
<form action="@submitUrl" id="2fa-form" method="post" autocomplete="off">
|
||||
@Html.AntiForgeryToken()
|
||||
<div class="digits" id="2fa">
|
||||
@if (!string.IsNullOrWhiteSpace(error))
|
||||
{
|
||||
<div class="ui negative message">
|
||||
<div class="header">
|
||||
@Model.Translate(GeneralStrings.Error)
|
||||
</div>
|
||||
<p style="white-space: pre-line">@Model.Error</p>
|
||||
</div>
|
||||
}
|
||||
<div class="header" id="header">@Model.Translate(TwoFactorStrings.TwoFactor)</div>
|
||||
<input type="text" maxlength="1" id="digit1"/>
|
||||
<input type="text" maxlength="1" id="digit2"/>
|
||||
<input type="text" maxlength="1" id="digit3" class="middleDigit"/>
|
||||
<input type="text" maxlength="1" id="digit4"/>
|
||||
<input type="text" maxlength="1" id="digit5"/>
|
||||
<input type="text" maxlength="1" id="digit6"/>
|
||||
<input type="hidden" name="code" id="code"/>
|
||||
<input type="hidden" name="redirect" value="@callbackUrl">
|
||||
@if (allowBackupCodes)
|
||||
{
|
||||
const string opening = "<a id=\"backup-link\" style=\"cursor: pointer\">";
|
||||
const string closing = "</a>";
|
||||
string formatted = TwoFactorStrings.TwoFactorBackup.Translate(Model.GetLanguage(), opening, closing);
|
||||
<div class="ui divider"></div>
|
||||
<div>@Html.Raw(formatted)</div>
|
||||
<div id="backup" style="display:none">
|
||||
<input class="backup" type="text" maxlength="11" placeholder="XXXXX-XXXXX" name="backup" id="backup-input"/>
|
||||
<div class="ui divider fitted hidden"></div>
|
||||
<button class="ui green button" type="submit">Submit</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</form>
|
||||
<script>
|
||||
|
||||
@if (allowBackupCodes)
|
||||
{
|
||||
<text>
|
||||
document.getElementById("backup-link").addEventListener("click", function (){
|
||||
let backupInput = document.getElementById("backup");
|
||||
if (backupInput.style.display === "none"){
|
||||
backupInput.style.display = "";
|
||||
} else {
|
||||
backupInput.style.display = "none";
|
||||
}
|
||||
});
|
||||
</text>
|
||||
}
|
||||
|
||||
document.querySelector(".digits").addEventListener("keydown", function(event){
|
||||
if (event.key === "Backspace") {
|
||||
const target = event.target;
|
||||
if (target.value === "" && target.previousElementSibling){
|
||||
target.previousElementSibling.focus();
|
||||
}
|
||||
}
|
||||
});
|
||||
document.querySelector(".digits").addEventListener("paste", function (e){
|
||||
if (e.target.classList.contains("backup")) return;
|
||||
e.preventDefault();
|
||||
// common browser -> e.originalEvent.clipboardData
|
||||
// uncommon browser -> window.clipboardData
|
||||
const clipboardData = e.clipboardData || e.originalEvent.clipboardData || window.clipboardData;
|
||||
const pastedData = clipboardData.getData('text');
|
||||
if (pastedData.length !== 6) return;
|
||||
|
||||
pastedData && (pastedData.replace(/[^0-9]/g,''));
|
||||
|
||||
for (let i = 1; i <= 6; i++){
|
||||
document.getElementById("digit" + i).value = pastedData[i-1];
|
||||
}
|
||||
submitForm();
|
||||
});
|
||||
|
||||
function submitForm(){
|
||||
let digit = "";
|
||||
for (let i = 1; i <= 6; i++){
|
||||
digit += document.getElementById("digit" + i).value;
|
||||
}
|
||||
|
||||
document.getElementById("code").value = digit;
|
||||
// if a user submits a regular code, reset the backup
|
||||
if (document.getElementById("backup-input"))
|
||||
document.getElementById("backup-input").value = "";
|
||||
document.getElementById("2fa-form").submit();
|
||||
}
|
||||
|
||||
document.querySelector(".digits").addEventListener("input", function({ target, data }){
|
||||
if (data && target.classList.contains("backup")) return;
|
||||
// Exclude non-numeric characters (if a value has been entered)
|
||||
data && (target.value = data.replace(/[^0-9]/g,''));
|
||||
|
||||
const hasValue = target.value !== "";
|
||||
const hasSibling = target.nextElementSibling;
|
||||
const hasSiblingInput = hasSibling && target.nextElementSibling.nodeName === "INPUT" && target.nextElementSibling.getAttribute("type") === "text";
|
||||
|
||||
if (target.id === "digit6"){
|
||||
submitForm();
|
||||
}
|
||||
|
||||
if (hasValue && hasSiblingInput){
|
||||
target.nextElementSibling.focus();
|
||||
}
|
||||
});
|
||||
if(performance.navigation.type === 2){
|
||||
location.reload();
|
||||
}
|
||||
if (window.history.replaceState) {
|
||||
window.history.replaceState(null, null, window.location.href);
|
||||
}
|
||||
</script>
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
||||
<div class="ui center segment center aligned">
|
||||
<h2>@Model.Translate(TwoFactorStrings.DisableTwoFactor)</h2>
|
||||
<p>@Model.Translate(TwoFactorStrings.DisableTwoFactorDescription)</p>
|
||||
@await Html.PartialAsync("Partials/TwoFactorPartial", new ViewDataDictionary(ViewData)
|
||||
{
|
||||
{
|
||||
"SubmitUrl", "/remove2fa"
|
||||
},
|
||||
{
|
||||
"Error", Model.Error
|
||||
},
|
||||
})
|
||||
</div>
|
|
@ -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<IActionResult> 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");
|
||||
}
|
||||
}
|
|
@ -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))
|
||||
{
|
||||
<div class="ui center segment center aligned">
|
||||
@if (Model.User?.TwoFactorRequired ?? false)
|
||||
{
|
||||
<h3>@Model.Translate(TwoFactorStrings.TwoFactorRequired)</h3>
|
||||
}
|
||||
<h2>@Model.Translate(TwoFactorStrings.QrTitle)</h2>
|
||||
<img src="@Model.QrCode" alt="2 Factor QR Code"/>
|
||||
<p>@Model.Translate(TwoFactorStrings.QrDescription)</p>
|
||||
@await Html.PartialAsync("Partials/TwoFactorPartial", new ViewDataDictionary(ViewData)
|
||||
{
|
||||
{
|
||||
"SubmitUrl", "/setup2fa"
|
||||
},
|
||||
{
|
||||
"Error", Model.Error
|
||||
},
|
||||
{
|
||||
"BackupCodes", false
|
||||
},
|
||||
})
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="ui center segment center aligned">
|
||||
<h1 class="ui negative message">IMPORTANT</h1>
|
||||
<h2>@Model.Translate(TwoFactorStrings.BackupCodeTitle)</h2>
|
||||
<p>@Model.Translate(TwoFactorStrings.BackupCodeDescription)
|
||||
<br/>@Model.Translate(TwoFactorStrings.BackupCodeDescription2)
|
||||
</p>
|
||||
<h3 id="codes">
|
||||
@foreach (string backupCode in Model.User!.TwoFactorBackup.Split(","))
|
||||
{
|
||||
@backupCode<br/>
|
||||
}
|
||||
</h3>
|
||||
<a class="ui blue button" onclick="saveCodes('@(ServerConfiguration.Instance.Customization.ServerName + "-backup-codes.txt")')">
|
||||
<i class="arrow circle down icon"></i>
|
||||
<span>@Model.Translate(TwoFactorStrings.DownloadBackupCodes)</span>
|
||||
</a>
|
||||
<a class="ui green button" href="/">
|
||||
<i class="check icon"></i>
|
||||
<span>@Model.Translate(TwoFactorStrings.BackupCodeConfirmation)</span>
|
||||
</a>
|
||||
</div>
|
||||
<script>
|
||||
function saveCodes(filename){
|
||||
let codes = document.getElementById("codes");
|
||||
let codeArray = codes.innerText.split("\n");
|
||||
let serverName = "@ServerConfiguration.Instance.Customization.ServerName";
|
||||
let username = "@Model.User.Username";
|
||||
let data = "These are your " + serverName + " backup codes for account " + username + ". Keep them safe!\n\n"
|
||||
for (const code of codeArray){
|
||||
data += code + "\n";
|
||||
}
|
||||
data = data.replace(/\n*$/, "");
|
||||
|
||||
save(filename, data);
|
||||
}
|
||||
function save(filename, data) {
|
||||
const blob = new Blob([data], {type: 'text/plain'});
|
||||
if(window.navigator && window.navigator.msSaveOrOpenBlob) {
|
||||
window.navigator.msSaveBlob(blob, filename);
|
||||
}
|
||||
else{
|
||||
const elem = window.document.createElement('a');
|
||||
elem.href = window.URL.createObjectURL(blob);
|
||||
elem.download = filename;
|
||||
document.body.appendChild(elem);
|
||||
elem.click();
|
||||
document.body.removeChild(elem);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
}
|
|
@ -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<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("~/");
|
||||
|
||||
// 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<Rgba32>(size, size);
|
||||
Rgba32 dark = darkColor.ToPixel<Rgba32>();
|
||||
Rgba32 light = lightColor.ToPixel<Rgba32>();
|
||||
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<IActionResult> 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<string> 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();
|
||||
}
|
||||
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
||||
<div class="ui center segment center aligned">
|
||||
<h2>@Model.Translate(TwoFactorStrings.TwoFactor)</h2>
|
||||
<p>@Model.Translate(TwoFactorStrings.TwoFactorDescription)</p>
|
||||
@await Html.PartialAsync("Partials/TwoFactorPartial", new ViewDataDictionary(ViewData)
|
||||
{
|
||||
{
|
||||
"SubmitUrl", "/2fa"
|
||||
},
|
||||
{
|
||||
"Error", Model.Error
|
||||
},
|
||||
{
|
||||
"CallbackUrl", Model.RedirectUrl
|
||||
}
|
||||
})
|
||||
</div>
|
|
@ -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<IActionResult> 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<IActionResult> 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);
|
||||
}
|
||||
}
|
|
@ -114,16 +114,37 @@ function onSubmit(e){
|
|||
</select>
|
||||
</div>
|
||||
}
|
||||
<button class="ui button green" tabindex="0">Save Changes</button>
|
||||
<a class="ui button red" href="/user/@Model.ProfileUser.UserId">Discard Changes</a>
|
||||
<div class="ui divider fitted hidden"></div>
|
||||
@if (Model.User == Model.ProfileUser)
|
||||
{
|
||||
<div class="field">
|
||||
<label style="text-align: left">Authentication</label>
|
||||
<a class="ui blue button" href="/passwordReset">
|
||||
<i class="key icon"></i>
|
||||
@Model.Translate(GeneralStrings.ResetPassword)
|
||||
</a>
|
||||
@if (ServerConfiguration.Instance.TwoFactorConfiguration.TwoFactorEnabled)
|
||||
{
|
||||
@if (Model.ProfileUser.IsTwoFactorSetup)
|
||||
{
|
||||
<a class="ui red button" href="/remove2fa">
|
||||
<i class="lock open icon"></i>
|
||||
@Model.Translate(TwoFactorStrings.DisableTwoFactor)
|
||||
</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<a class="ui green button" href="/setup2fa">
|
||||
<i class="lock icon"></i>
|
||||
@Model.Translate(TwoFactorStrings.EnableTwoFactor)
|
||||
</a>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<div class="ui divider"></div>
|
||||
<button class="ui button green" tabindex="0">Save Changes</button>
|
||||
<a class="ui button red" href="/user/@Model.ProfileUser.UserId">Discard Changes</a>
|
||||
<div class="ui divider fitted hidden"></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -11,6 +11,7 @@
|
|||
<ItemGroup>
|
||||
<ProjectReference Include="..\ProjectLighthouse.Localization\ProjectLighthouse.Localization.csproj" />
|
||||
<ProjectReference Include="..\ProjectLighthouse\ProjectLighthouse.csproj" />
|
||||
<PackageReference Include="QRCoder" Version="1.4.3"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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();
|
||||
}
|
109
ProjectLighthouse/Helpers/Base32Helper.cs
Normal file
109
ProjectLighthouse/Helpers/Base32Helper.cs
Normal file
|
@ -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)),
|
||||
};
|
||||
}
|
||||
}
|
|
@ -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));
|
||||
|
|
|
@ -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<string>(
|
||||
name: "TwoFactorBackup",
|
||||
table: "Users",
|
||||
type: "longtext",
|
||||
nullable: true)
|
||||
.Annotation("MySql:CharSet", "utf8mb4");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<bool>(
|
||||
name: "Verified",
|
||||
table: "WebTokens",
|
||||
type: "tinyint(1)",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Verified",
|
||||
table: "WebTokens");
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -14,4 +14,6 @@ public class WebToken
|
|||
public string UserToken { get; set; }
|
||||
|
||||
public DateTime ExpiresAt { get; set; }
|
||||
|
||||
public bool Verified { get; set; }
|
||||
}
|
|
@ -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<string>("PlanetHashLBP2")
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.Property<string>("PlanetHashLBP2CC")
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.Property<string>("PlanetHashLBP3")
|
||||
.HasColumnType("longtext");
|
||||
|
||||
|
@ -827,6 +830,12 @@ namespace ProjectLighthouse.Migrations
|
|||
b.Property<string>("TimeZone")
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.Property<string>("TwoFactorBackup")
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.Property<string>("TwoFactorSecret")
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasColumnType("longtext");
|
||||
|
@ -995,6 +1004,9 @@ namespace ProjectLighthouse.Migrations
|
|||
b.Property<string>("UserToken")
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.Property<bool>("Verified")
|
||||
.HasColumnType("tinyint(1)");
|
||||
|
||||
b.HasKey("TokenId");
|
||||
|
||||
b.ToTable("WebTokens");
|
||||
|
|
|
@ -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 {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue