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

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