Skip to content

Commit 17b8658

Browse files
committed
feat: add password reset workflow and email functionality
- Introduce `FPasswordResetManager` to handle password reset workflows, including token generation, validation, and password updates. - Add email-sending support with the new `FMailSender` class, leveraging Brevo API. - Extend REST API with endpoints for password reset and token validation. - Enhance rate limiting in `FAbuseProtection` and `FRateLimiter` to support password reset attempts. - Add environment variable support for Mail API key and update backend settings for better configuration.
1 parent 7c3d853 commit 17b8658

19 files changed

Lines changed: 529 additions & 50 deletions

ProjectServer/Assets/Config/BackendSettings.ini

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,4 +28,5 @@ SSLCert = /etc/letsencrypt/live/comm.sqrll.net/fullchain.pem
2828

2929
# Abuse protection settings
3030
RateLimitNumberPerIP = 55
31+
PasswordRateLimitNumberPerIP = 25
3132
RateLimitTimeToClearInMins = 60

ProjectServer/CMakeLists.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,10 @@ else()
7272
${PROJECT_SOURCE_FILES_PUBLIC}
7373
Source/Private/Rest/AccountEndpoint.cpp
7474
Source/Private/Rest/AccountEndpoint.h
75+
Source/Private/Managers/MailSender.cpp
76+
Source/Private/Managers/MailSender.h
77+
Source/Private/Managers/PasswordResetManager.cpp
78+
Source/Private/Managers/PasswordResetManager.h
7579
)
7680

7781
# The SDL java code is hardcoded to load libmain.so on android, so we need to change EXECUTABLE_NAME

ProjectServer/Source/Private/AbuseProtection/AbuseProtection.cpp

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,29 +10,44 @@ FAbuseProtection::FAbuseProtection(FBackendSettings* InBackendSettings)
1010
{
1111
LOG_DEBUG("BackendSettingsIni number of fields: '" << BackendSettingsIni->GetNumberOfFields() << "'.");
1212

13-
FIniField RateLimitNumberPerIPField = BackendSettingsIni->FindFieldByName("RateLimitNumberPerIP");
1413
FIniField RateLimitTimeToClearInMinsField = BackendSettingsIni->FindFieldByName("RateLimitTimeToClearInMins");
14+
FIniField RateLimitNumberPerIPField = BackendSettingsIni->FindFieldByName("RateLimitNumberPerIP");
15+
FIniField RateLimitPasswordField = BackendSettingsIni->FindFieldByName("PasswordRateLimitNumberPerIP");
1516

16-
RateLimiter = std::make_unique<FRateLimiter>(RateLimitTimeToClearInMinsField.GetValueAsInt(), RateLimitNumberPerIPField.GetValueAsInt());
17+
RateLimiter = std::make_unique<FRateLimiter>(
18+
RateLimitTimeToClearInMinsField.GetValueAsInt(),
19+
RateLimitNumberPerIPField.GetValueAsInt(),
20+
RateLimitPasswordField.GetValueAsInt()
21+
);
1722
}
1823
else
1924
{
20-
RateLimiter = std::make_unique<FRateLimiter>(60, 10);
25+
RateLimiter = std::make_unique<FRateLimiter>(60, 10, 8);
2126

2227
LOG_ERROR("FAbuseProtection missing BackendSettingsIni");
2328
}
2429
}
2530

26-
bool FAbuseProtection::IsAddressBlocked(const std::string& InAddress)
31+
bool FAbuseProtection::IsAddressBlocked(const std::string& InAddress) const
2732
{
2833
return RateLimiter->IsAddressBlocked(InAddress);
2934
}
3035

31-
void FAbuseProtection::AddRateLimitedAttempt(const std::string& InAddress)
36+
void FAbuseProtection::AddRateLimitedAttempt(const std::string& InAddress) const
3237
{
3338
RateLimiter->AddProtectedActionAttempt(InAddress);
3439
}
3540

41+
bool FAbuseProtection::CanAddressRequestPasswordReset(const std::string& InAddress) const
42+
{
43+
return !RateLimiter->IsPasswordResetAddressBlocked(InAddress);
44+
}
45+
46+
void FAbuseProtection::AddPasswordResetAttempt(const std::string& InAddress) const
47+
{
48+
RateLimiter->AddPasswordResetAttempt(InAddress);
49+
}
50+
3651
CUnorderedMap<std::string, std::string> FAbuseProtection::GetCORHeaders() const
3752
{
3853
return CORPolicyPtr->GetCORHeaders();

ProjectServer/Source/Private/AbuseProtection/RateLimiter.cpp

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,9 +63,10 @@ void FRateLimitObject::Reset()
6363
bIsClearing = false;
6464
}
6565

66-
FRateLimiter::FRateLimiter(const int32 InClearingTimeInMins, const int32 InNumberOfAttemptsToBlock)
66+
FRateLimiter::FRateLimiter(const int32 InClearingTimeInMins, const int32 InNumberOfAttemptsToBlock, const int32 InNumberOfPasswordResetAttemptsToBlock)
6767
: ClearingTimeInMins(std::chrono::minutes(InClearingTimeInMins))
6868
, NumberOfAttemptsToBlock(InNumberOfAttemptsToBlock)
69+
, NumberOfPasswordResetAttemptsToBlock(InNumberOfAttemptsToBlock)
6970
{
7071
FThreadsManager* ThreadsManager = FGlobalDefines::GEngine->GetThreadsManager();
7172
RateLimiterThreadData = ThreadsManager->CreateThread<FGenericThread, FThreadData>(RateLimiterThreadName);
@@ -100,6 +101,16 @@ void FRateLimiter::AddProtectedActionAttempt(const std::string& InAddress)
100101
DefaultIPAddressToLimits.AddAttempt(InAddress);
101102
}
102103

104+
bool FRateLimiter::IsPasswordResetAddressBlocked(const std::string& InAddress)
105+
{
106+
return PasswordResetIPAddressToLimits.IsBlockedKey(InAddress, NumberOfPasswordResetAttemptsToBlock);
107+
}
108+
109+
void FRateLimiter::AddPasswordResetAttempt(const std::string& InAddress)
110+
{
111+
PasswordResetIPAddressToLimits.AddAttempt(InAddress);
112+
}
113+
103114
void FRateLimiter::AsyncWork()
104115
{
105116
const std::chrono::time_point<std::chrono::utc_clock> CurrentTime = std::chrono::utc_clock::now();

ProjectServer/Source/Private/Auth/UserManager.cpp

Lines changed: 76 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -364,7 +364,7 @@ EUpdateUserNameStatus FUserManager::UpdateUserName(const Uint64 UsedId, const st
364364
return OutStatus;
365365
}
366366

367-
EUpdateUserPasswordStatus FUserManager::UpdateUserPassword(const Uint64 UsedId, const std::string& OldPassword, const std::string& NewPassword)
367+
EUpdateUserPasswordStatus FUserManager::UpdateUserPassword(const Uint64 InUserId, const std::string& OldPassword, const std::string& NewPassword)
368368
{
369369
if (OldPassword.empty() || !ValidatePasswordLength(NewPassword))
370370
{
@@ -379,34 +379,31 @@ EUpdateUserPasswordStatus FUserManager::UpdateUserPassword(const Uint64 UsedId,
379379
EUpdateUserPasswordStatus OutStatus = EUpdateUserPasswordStatus::Successful;
380380
std::string UserPasswordHash = HashUserPassword(NewPassword);
381381

382-
try
382+
std::shared_ptr<FUser> UserPtr = GetUserById(InUserId);
383+
if (UserPtr == nullptr)
383384
{
384-
FDataBaseConnect Connect;
385-
if (Connect.IsConnected())
386-
{
387-
// Get database connection session
388-
soci::session& DataBaseSession = Connect.GetSession();
385+
return EUpdateUserPasswordStatus::UserNotFound;
386+
}
389387

390-
// Update activity time
391-
DataBaseSession << "UPDATE users SET UserPasswordHash = :password WHERE id = :id",
392-
soci::use(UsedId, "id"),
393-
soci::use(UserPasswordHash, "password");
388+
if (!VerifyPasswords(UserPtr->GetUserPasswordHash(), OldPassword))
389+
{
390+
return EUpdateUserPasswordStatus::OldPasswordIncorrect;
391+
}
394392

395-
// Update cache
396-
std::vector<std::shared_ptr<FUser>> Users;
397-
const bool bGetUsers = GetUsersByIds({ UsedId }, Users);
398-
if (bGetUsers)
399-
{
400-
std::shared_ptr<FUser>& FirstUser = Users[0];
401-
FirstUser->UpdateUserPassword(UserPasswordHash);
393+
UpdateUserPasswordInDataBase(InUserId, UserPasswordHash);
402394

403-
OutStatus = EUpdateUserPasswordStatus::Successful;
404-
}
405-
}
406-
}
407-
catch (const std::exception& e)
395+
return OutStatus;
396+
}
397+
398+
EUpdateUserPasswordStatus FUserManager::OverrideUserPassword(Uint64 InUserId, const std::string& NewPassword)
399+
{
400+
EUpdateUserPasswordStatus OutStatus = EUpdateUserPasswordStatus::Successful;
401+
std::string UserPasswordHash = HashUserPassword(NewPassword);
402+
403+
EDatabaseOperationResult DatabaseOpResult = UpdateUserPasswordInDataBase(InUserId, UserPasswordHash);
404+
if (DatabaseOpResult != EDatabaseOperationResult::Success)
408405
{
409-
LOG_ERROR("Database error: " << e.what());
406+
OutStatus = EUpdateUserPasswordStatus::Unknown;
410407
}
411408

412409
return OutStatus;
@@ -452,6 +449,19 @@ bool FUserManager::GetUsersByIds(const std::vector<Uint64>& UserIds, std::vector
452449
return (DownloadUsersFromDBByIds(UserIds, OutUsers, true) == EDatabaseOperationResult::Success);
453450
}
454451

452+
std::shared_ptr<FUser> FUserManager::GetUserById(Uint64 InUserId)
453+
{
454+
std::vector<std::shared_ptr<FUser>> OutUsers;
455+
const bool bHasUser = GetUsersByIds({ InUserId }, OutUsers);
456+
457+
if (bHasUser && !OutUsers.empty())
458+
{
459+
return OutUsers[0];
460+
}
461+
462+
return nullptr;
463+
}
464+
455465
EDatabaseOperationResult FUserManager::DownloadUserFromDBByMail(const std::string& InUserEmail, std::shared_ptr<FUser>& UserPtr)
456466
{
457467
EDatabaseOperationResult DownloadResult = EDatabaseOperationResult::Unknown;
@@ -670,6 +680,48 @@ EDatabaseOperationResult FUserManager::UploadUserToDataBase(const std::string& I
670680
return DatabaseOperationResult;
671681
}
672682

683+
EDatabaseOperationResult FUserManager::UpdateUserPasswordInDataBase(Uint64 InUserId, const std::string& InUserPasswordHash)
684+
{
685+
EDatabaseOperationResult DataBaseOperationResult = EDatabaseOperationResult::Unknown;
686+
687+
try
688+
{
689+
FDataBaseConnect Connect;
690+
if (Connect.IsConnected())
691+
{
692+
// Get database connection session
693+
soci::session& DataBaseSession = Connect.GetSession();
694+
695+
// Update activity time
696+
DataBaseSession << "UPDATE users SET UserPasswordHash = :password WHERE id = :id",
697+
soci::use(InUserId, "id"),
698+
soci::use(InUserPasswordHash, "password");
699+
700+
// Update cache
701+
std::vector<std::shared_ptr<FUser>> Users;
702+
const bool bGetUsers = GetUsersByIds({ InUserId }, Users);
703+
if (bGetUsers)
704+
{
705+
std::shared_ptr<FUser>& FirstUser = Users[0];
706+
FirstUser->UpdateUserPassword(InUserPasswordHash);
707+
708+
DataBaseOperationResult = EDatabaseOperationResult::Success;
709+
}
710+
}
711+
else
712+
{
713+
DataBaseOperationResult = EDatabaseOperationResult::ConnectionFailed;
714+
}
715+
}
716+
catch (const std::exception& e)
717+
{
718+
LOG_ERROR("Database error: " << e.what());
719+
DataBaseOperationResult = EDatabaseOperationResult::DatabaseFailed;
720+
}
721+
722+
return DataBaseOperationResult;
723+
}
724+
673725
EDatabaseOperationResult FUserManager::DoesUserWithMailExists(const std::string& InUserEmail, bool& bOutExists)
674726
{
675727
EDatabaseOperationResult DownloadResult = EDatabaseOperationResult::Unknown;

ProjectServer/Source/Private/DataBase/DataBaseSettings.cpp

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -49,25 +49,24 @@ std::string FDataBaseSettings::GetEnvHost()
4949
LOG_INFO("SQRLL_COMM_DB_HOST empty, defaulting to localhost.");
5050
}
5151

52-
return Variable ? Variable : "localhost";
52+
return FUtil::GetEnvVariable("SQRLL_COMM_DB_HOST", "localhost").value();
5353
}
5454

5555
std::string FDataBaseSettings::GetEnvPort()
5656
{
5757
const char* Variable = std::getenv("SQRLL_COMM_DB_PORT");
58-
return Variable ? Variable : "3306";
58+
return FUtil::GetEnvVariable("SQRLL_COMM_DB_PORT", "3306").value();
5959
}
6060

6161
std::string FDataBaseSettings::GetEnvDataBaseName()
6262
{
6363
const char* Variable = std::getenv("SQRLL_COMM_DB_DBNAME");
64-
return Variable ? Variable : "sqrllapi";
64+
return FUtil::GetEnvVariable("SQRLL_COMM_DB_DBNAME", "sqrllapi").value();
6565
}
6666

6767
std::string FDataBaseSettings::GetEnvUser()
6868
{
69-
const char* Variable = std::getenv("SQRLL_COMM_DB_USER");
70-
return Variable ? Variable : "commapisqrllusertest";
69+
return FUtil::GetEnvVariable("SQRLL_COMM_DB_USER", "commapisqrllusertest").value();
7170
}
7271

7372
std::string FDataBaseSettings::GetEnvPassword()
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
// Created by https://www.linkedin.com/in/przemek2122/ 2026
2+
3+
#include "Managers/MailSender.h"
4+
#include "ProjectEngine.h"
5+
6+
#include <cpr/api.h>
7+
#include <cpr/body.h>
8+
9+
#include "nlohmann/json.hpp"
10+
#include "nlohmann/json_fwd.hpp"
11+
12+
void FMailSender::SendMail(const nlohmann::json& JsonBody)
13+
{
14+
FProjectEngine* ProjectEngine = dynamic_cast<FProjectEngine*>(FGlobalDefines::GEngine);
15+
16+
std::string APIKey;
17+
if (ProjectEngine != nullptr)
18+
{
19+
APIKey = ProjectEngine->GetMailAPIKey();
20+
}
21+
22+
if (!APIKey.empty())
23+
{
24+
try
25+
{
26+
const std::string JsonBodyString = JsonBody.dump();
27+
28+
const cpr::Response Response = cpr::Post(
29+
cpr::Url{"https://api.brevo.com/v3/smtp/email"},
30+
cpr::Header{
31+
{"api-key", APIKey},
32+
{"accept", "application/json"},
33+
{"content-type", "application/json"}
34+
},
35+
cpr::Body{JsonBodyString}
36+
);
37+
38+
if (Response.status_code != 201)
39+
{
40+
throw std::runtime_error("HTTP " + std::to_string(Response.status_code) + ": " + Response.text);
41+
}
42+
}
43+
catch (const std::exception& e)
44+
{
45+
throw std::runtime_error("Failed to send email: " + std::string(e.what()));
46+
}
47+
}
48+
else
49+
{
50+
LOG_ERROR("Mail will not be sent. Missing key!");
51+
}
52+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
// Created by https://www.linkedin.com/in/przemek2122/ 2026 https://github.com/Przemek2122/Engine
2+
3+
#include "Managers/PasswordResetManager.h"
4+
5+
#include "ProjectEngine.h"
6+
#include "Auth/UserManager.h"
7+
8+
FPasswordResetStruct FPasswordResetManager::GenerateResetToken(const std::string& UserMail)
9+
{
10+
// Set of chars 0-9, A-F
11+
static std::array<char, 16> RandomBytes = { '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F' };
12+
13+
static int32 TokenLength = 6;
14+
15+
std::vector<char> RandomToken;
16+
RandomToken.reserve(TokenLength);
17+
18+
for (int32 i = 0; i < TokenLength; ++i)
19+
{
20+
RandomToken.push_back(RandomBytes[rand() % RandomBytes.size()]);
21+
}
22+
23+
// Make token as string
24+
const std::string TokenString(RandomToken.begin(), RandomToken.end());
25+
26+
// Make struct
27+
FPasswordResetStruct ResetStruct = { UserMail, TokenString };
28+
29+
// Mutex unique lock
30+
std::unique_lock<std::shared_mutex> Lock(TokenToStructureMapMutex);
31+
32+
// Add token to map
33+
TokenToStructureMap[TokenString] = ResetStruct;
34+
35+
return ResetStruct;
36+
}
37+
38+
bool FPasswordResetManager::ValidateResetToken(const std::string& UserMail, const std::string& ResetToken)
39+
{
40+
bool bTokenMatch = false;
41+
42+
// Mutex shared lock
43+
std::shared_lock<std::shared_mutex> Lock(TokenToStructureMapMutex);
44+
45+
if (TokenToStructureMap.contains(ResetToken) && TokenToStructureMap[ResetToken].UserMailForReset == UserMail)
46+
{
47+
bTokenMatch = true;
48+
}
49+
50+
return bTokenMatch;
51+
}
52+
53+
bool FPasswordResetManager::UpdatePassword(const std::string& UserMail, const std::string& NewPassword)
54+
{
55+
FProjectEngine* ProjectEngine = dynamic_cast<FProjectEngine*>(FGlobalDefines::GEngine);
56+
if (ProjectEngine != nullptr)
57+
{
58+
FUserManager* UserManager = ProjectEngine->GetUserManager();
59+
60+
std::shared_ptr<FUser> User = UserManager->FindUserByMail(UserMail);
61+
62+
if (User != nullptr)
63+
{
64+
const EUpdateUserPasswordStatus Result = UserManager->OverrideUserPassword(User->GetUserId(), NewPassword);
65+
66+
return Result == EUpdateUserPasswordStatus::Successful;
67+
}
68+
}
69+
70+
return false;
71+
}
72+
73+
void FPasswordResetManager::InvalidateToken(const std::string& UserMail, const std::string& ResetToken)
74+
{
75+
}

0 commit comments

Comments
 (0)