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:
Josh 2022-12-12 21:11:39 -06:00 committed by GitHub
parent 4fd1063502
commit 14d2f0305e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 1077 additions and 20 deletions

View file

@ -3,7 +3,7 @@
"isRoot": true,
"tools": {
"dotnet-ef": {
"version": "6.0.9",
"version": "6.0.10",
"commands": [
"dotnet-ef"
]

View file

@ -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>

View file

@ -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);
}

View file

@ -11,4 +11,5 @@ public enum TranslationAreas
Error,
Profile,
ModPanel,
TwoFactor,
}

View 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>

View file

@ -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));
}
}

View file

@ -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));

View file

@ -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>

View file

@ -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>

View file

@ -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");
}
}

View file

@ -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>
}

View file

@ -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();
}
}

View file

@ -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>

View file

@ -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);
}
}

View file

@ -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>

View file

@ -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>

View file

@ -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);

View file

@ -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);

View file

@ -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;
}

View file

@ -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();
}

View 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)),
};
}
}

View file

@ -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));

View file

@ -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");
}
}
}

View file

@ -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");
}
}
}

View file

@ -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;

View file

@ -14,4 +14,6 @@ public class WebToken
public string UserToken { get; set; }
public DateTime ExpiresAt { get; set; }
public bool Verified { get; set; }
}

View file

@ -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");

View file

@ -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 {