ProjectLighthouse/ProjectLighthouse/Helpers/EmailHelper.cs
Henry Asbridge e060f55896
Base64 encode auth tokens (#1029)
* Base64 encode auth tokens to prevent issues in emails
This fixes #1023, which should in turn solve some issues people were having with emails.

* Make test bcrypt hash things as the auth token isn't one by default

* Update ProjectLighthouse/Helpers/CryptoHelper.cs

Co-authored-by: Josh <josh@slendy.pw>

* Make only email tokens base64 encoded

---------

Co-authored-by: Zaprit <zaprit@hugespaceship.io>
Co-authored-by: Josh <josh@slendy.pw>
2024-06-29 18:14:16 +00:00

113 lines
No EOL
4.9 KiB
C#

#nullable enable
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using LBPUnion.ProjectLighthouse.Configuration;
using LBPUnion.ProjectLighthouse.Database;
using LBPUnion.ProjectLighthouse.Extensions;
using LBPUnion.ProjectLighthouse.Types.Entities.Profile;
using LBPUnion.ProjectLighthouse.Types.Entities.Token;
using LBPUnion.ProjectLighthouse.Types.Mail;
using Microsoft.EntityFrameworkCore;
namespace LBPUnion.ProjectLighthouse.Helpers;
public static class SMTPHelper
{
// (User id, timestamp of last request + 30 seconds)
private static readonly ConcurrentDictionary<int, long> recentlySentMail = new();
private const long emailCooldown = 1000 * 30;
private static bool CanSendMail(UserEntity user)
{
// Remove expired entries
for (int i = recentlySentMail.Count - 1; i >= 0; i--)
{
KeyValuePair<int, long> entry = recentlySentMail.ElementAt(i);
if (recentlySentMail.TryGetValue(entry.Key, out long expiration) &&
TimeHelper.TimestampMillis > expiration)
{
recentlySentMail.TryRemove(entry.Key, out _);
}
}
if (recentlySentMail.TryGetValue(user.UserId, out long userExpiration))
{
return TimeHelper.TimestampMillis > userExpiration;
}
// If they don't have an entry in the dictionary then they can't be on cooldown
return true;
}
public static async Task SendPasswordResetEmail(DatabaseContext database, IMailService mail, UserEntity user)
{
if (!CanSendMail(user)) return;
if (await database.PasswordResetTokens.CountAsync(t => t.UserId == user.UserId) > 0) return;
PasswordResetTokenEntity token = new()
{
Created = DateTime.UtcNow,
UserId = user.UserId,
ResetToken = CryptoHelper.GenerateUrlToken(),
};
database.PasswordResetTokens.Add(token);
await database.SaveChangesAsync();
string messageBody = $"Hello, {user.Username}.\n\n" +
"A request to reset your account's password was issued. If this wasn't you, this can probably be ignored.\n\n" +
$"If this was you, your {ServerConfiguration.Instance.Customization.ServerName} password can be reset at the following link:\n" +
$"{ServerConfiguration.Instance.ExternalUrl}/passwordReset?token={token.ResetToken}\n\n" +
"This link will expire in 24 hours";
await mail.SendEmailAsync(user.EmailAddress, $"Project Lighthouse Password Reset Request for {user.Username}", messageBody);
recentlySentMail.TryAdd(user.UserId, TimeHelper.TimestampMillis + emailCooldown);
}
public static void SendRegistrationEmail(IMailService mail, UserEntity user)
{
// There is intentionally no cooldown here because this is only used for registration
// and a user can only be registered once, i.e. this should only be called once per user
string body = "An account for Project Lighthouse has been registered with this email address.\n\n" +
$"You can login at {ServerConfiguration.Instance.ExternalUrl}.";
mail.SendEmail(user.EmailAddress, "Project Lighthouse Account Created: " + user.Username, body);
}
public static async Task<bool> SendVerificationEmail(DatabaseContext database, IMailService mail, UserEntity user)
{
if (!CanSendMail(user)) return false;
string? existingToken = await database.EmailVerificationTokens.Where(v => v.UserId == user.UserId)
.Select(v => v.EmailToken)
.FirstOrDefaultAsync();
if (existingToken != null) await database.EmailVerificationTokens.RemoveWhere(t => t.EmailToken == existingToken);
EmailVerificationTokenEntity verifyToken = new()
{
UserId = user.UserId,
User = user,
EmailToken = CryptoHelper.GenerateUrlToken(),
ExpiresAt = DateTime.UtcNow.AddHours(6),
};
database.EmailVerificationTokens.Add(verifyToken);
await database.SaveChangesAsync();
string body = "Hello,\n\n" +
$"This email is a request to verify this email for your (likely new!) Project Lighthouse account ({user.Username}).\n\n" +
$"To verify your account, click the following link: {ServerConfiguration.Instance.ExternalUrl}/verifyEmail?token={verifyToken.EmailToken}\n\n\n" +
"If this wasn't you, feel free to ignore this email.";
bool success = await mail.SendEmailAsync(user.EmailAddress, "Project Lighthouse Email Verification", body);
// Don't send another email for 30 seconds
recentlySentMail.TryAdd(user.UserId, TimeHelper.TimestampMillis + emailCooldown);
return success;
}
}