mirror of
https://github.com/LBPUnion/ProjectLighthouse.git
synced 2025-09-11 12:06:17 +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
|
@ -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);
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue