|
@@ -1,6 +1,7 @@
|
|
|
#include "auth_store.hpp"
|
|
#include "auth_store.hpp"
|
|
|
#include "common/uuid.hpp"
|
|
#include "common/uuid.hpp"
|
|
|
#include "common/time_utils.hpp"
|
|
#include "common/time_utils.hpp"
|
|
|
|
|
+#include "common/string_utils.hpp"
|
|
|
#include "logging/logger.hpp"
|
|
#include "logging/logger.hpp"
|
|
|
|
|
|
|
|
namespace smartbotic::webserver::auth {
|
|
namespace smartbotic::webserver::auth {
|
|
@@ -129,6 +130,40 @@ void AuthStore::enforceSessionLifetime() {
|
|
|
jwt_.refreshTokenLifetimeSec());
|
|
jwt_.refreshTokenLifetimeSec());
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+void AuthStore::normalizeUserEmails() {
|
|
|
|
|
+ constexpr int32_t kPageSize = 200;
|
|
|
|
|
+ int64_t examined = 0, normalized = 0;
|
|
|
|
|
+
|
|
|
|
|
+ for (int32_t page = 1; ; ++page) {
|
|
|
|
|
+ storage::QueryOptions options;
|
|
|
|
|
+ options.page = page;
|
|
|
|
|
+ options.page_size = kPageSize;
|
|
|
|
|
+ auto result = storage_.query("users", options);
|
|
|
|
|
+ if (result.failed() || result.value().documents.empty()) break;
|
|
|
|
|
+
|
|
|
|
|
+ for (const auto& doc : result.value().documents) {
|
|
|
|
|
+ const std::string id = doc.value("_id", "");
|
|
|
|
|
+ const std::string email = doc.value("email", "");
|
|
|
|
|
+ if (id.empty() || email.empty()) continue;
|
|
|
|
|
+ examined++;
|
|
|
|
|
+
|
|
|
|
|
+ std::string lowered = StringUtils::toLower(email);
|
|
|
|
|
+ if (lowered == email) continue;
|
|
|
|
|
+
|
|
|
|
|
+ nlohmann::json updates;
|
|
|
|
|
+ updates["email"] = lowered;
|
|
|
|
|
+ if (storage_.update("users", id, updates, 0, true).ok()) normalized++;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (result.value().documents.size() < static_cast<size_t>(kPageSize)) break;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (normalized > 0) {
|
|
|
|
|
+ LOG_INFO("Users: {} examined - {} emails lowercased for case-insensitive login",
|
|
|
|
|
+ examined, normalized);
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
Result<User> AuthStore::createUser(const std::string& username,
|
|
Result<User> AuthStore::createUser(const std::string& username,
|
|
|
const std::string& email,
|
|
const std::string& email,
|
|
|
const std::string& password,
|
|
const std::string& password,
|
|
@@ -139,8 +174,15 @@ Result<User> AuthStore::createUser(const std::string& username,
|
|
|
return Error(ErrorCode::AlreadyExists, "Username already taken");
|
|
return Error(ErrorCode::AlreadyExists, "Username already taken");
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ // Email is the login credential, matched case-insensitively, so two
|
|
|
|
|
+ // users differing only by case would otherwise make login by email
|
|
|
|
|
+ // ambiguous. Normalising to lowercase on write keeps the stored value and
|
|
|
|
|
+ // every future lookup in agreement without needing a case-insensitive
|
|
|
|
|
+ // query.
|
|
|
|
|
+ std::string normalized_email = StringUtils::toLower(email);
|
|
|
|
|
+
|
|
|
// Check if email exists
|
|
// Check if email exists
|
|
|
- existing = getUserByEmail(email);
|
|
|
|
|
|
|
+ existing = getUserByEmail(normalized_email);
|
|
|
if (existing.ok()) {
|
|
if (existing.ok()) {
|
|
|
return Error(ErrorCode::AlreadyExists, "Email already registered");
|
|
return Error(ErrorCode::AlreadyExists, "Email already registered");
|
|
|
}
|
|
}
|
|
@@ -149,7 +191,7 @@ Result<User> AuthStore::createUser(const std::string& username,
|
|
|
User user;
|
|
User user;
|
|
|
user.id = UUID::generatePrefixed("usr");
|
|
user.id = UUID::generatePrefixed("usr");
|
|
|
user.username = username;
|
|
user.username = username;
|
|
|
- user.email = email;
|
|
|
|
|
|
|
+ user.email = normalized_email;
|
|
|
user.password_hash = BcryptUtils::hashPassword(password);
|
|
user.password_hash = BcryptUtils::hashPassword(password);
|
|
|
user.role = role;
|
|
user.role = role;
|
|
|
user.active = true;
|
|
user.active = true;
|
|
@@ -190,7 +232,10 @@ Result<User> AuthStore::getUserByUsername(const std::string& username) {
|
|
|
|
|
|
|
|
Result<User> AuthStore::getUserByEmail(const std::string& email) {
|
|
Result<User> AuthStore::getUserByEmail(const std::string& email) {
|
|
|
storage::QueryOptions options;
|
|
storage::QueryOptions options;
|
|
|
- options.filters.push_back({"email", email});
|
|
|
|
|
|
|
+ // Stored emails are lowercase (see createUser/updateUser and
|
|
|
|
|
+ // normalizeUserEmails), so lowercasing the lookup value is what makes the
|
|
|
|
|
+ // match case-insensitive.
|
|
|
|
|
+ options.filters.push_back({"email", StringUtils::toLower(email)});
|
|
|
options.page_size = 1;
|
|
options.page_size = 1;
|
|
|
|
|
|
|
|
auto result = storage_.query("users", options);
|
|
auto result = storage_.query("users", options);
|
|
@@ -233,6 +278,21 @@ Result<User> AuthStore::updateUser(const std::string& id, const nlohmann::json&
|
|
|
nlohmann::json update_data = updates;
|
|
nlohmann::json update_data = updates;
|
|
|
// Note: updatedAt is managed by database automatically
|
|
// Note: updatedAt is managed by database automatically
|
|
|
|
|
|
|
|
|
|
+ if (update_data.contains("email")) {
|
|
|
|
|
+ std::string normalized_email = StringUtils::toLower(update_data.value("email", ""));
|
|
|
|
|
+ update_data["email"] = normalized_email;
|
|
|
|
|
+
|
|
|
|
|
+ if (!normalized_email.empty()) {
|
|
|
|
|
+ // Two users cannot share an email or login-by-email becomes
|
|
|
|
|
+ // ambiguous - the same rule createUser enforces, applied here
|
|
|
|
|
+ // too since this is the other place an email is written.
|
|
|
|
|
+ auto existing = getUserByEmail(normalized_email);
|
|
|
|
|
+ if (existing.ok() && existing.value().id != id) {
|
|
|
|
|
+ return Error(ErrorCode::AlreadyExists, "Email already registered");
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
// Don't allow password update through this method
|
|
// Don't allow password update through this method
|
|
|
update_data.erase("passwordHash");
|
|
update_data.erase("passwordHash");
|
|
|
|
|
|
|
@@ -279,11 +339,18 @@ Result<void> AuthStore::changePassword(const std::string& id,
|
|
|
return Result<void>();
|
|
return Result<void>();
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-Result<LoginResponse> AuthStore::login(const std::string& username,
|
|
|
|
|
|
|
+Result<LoginResponse> AuthStore::login(const std::string& identifier,
|
|
|
const std::string& password,
|
|
const std::string& password,
|
|
|
const std::string& ip_address,
|
|
const std::string& ip_address,
|
|
|
const std::string& user_agent) {
|
|
const std::string& user_agent) {
|
|
|
- auto user_result = getUserByUsername(username);
|
|
|
|
|
|
|
+ // Email is the login credential now; username is tried second, purely as
|
|
|
|
|
+ // a transitional fallback, so an account stuck with a placeholder address
|
|
|
|
|
+ // (or a caller still sending its username) is not locked out. Drop this
|
|
|
|
|
+ // fallback once every account is known to have a real email.
|
|
|
|
|
+ auto user_result = getUserByEmail(identifier);
|
|
|
|
|
+ if (user_result.failed()) {
|
|
|
|
|
+ user_result = getUserByUsername(identifier);
|
|
|
|
|
+ }
|
|
|
if (user_result.failed()) {
|
|
if (user_result.failed()) {
|
|
|
return Error(ErrorCode::InvalidCredentials, "Invalid username or password");
|
|
return Error(ErrorCode::InvalidCredentials, "Invalid username or password");
|
|
|
}
|
|
}
|
|
@@ -323,7 +390,7 @@ Result<LoginResponse> AuthStore::login(const std::string& username,
|
|
|
updates["lastLogin"] = TimeUtils::nowMs();
|
|
updates["lastLogin"] = TimeUtils::nowMs();
|
|
|
storage_.update("users", user.id, updates, 0, true);
|
|
storage_.update("users", user.id, updates, 0, true);
|
|
|
|
|
|
|
|
- LOG_INFO("User logged in: {} from {}", username, ip_address);
|
|
|
|
|
|
|
+ LOG_INFO("User logged in: {} from {}", user.username, ip_address);
|
|
|
|
|
|
|
|
LoginResponse response;
|
|
LoginResponse response;
|
|
|
response.access_token = access_token;
|
|
response.access_token = access_token;
|