Kaynağa Gözat

feat: log in with email, keep username as a display name

Email is now the login credential; username stays exactly as it was
everywhere else - node authorship, version history, _created_by, the
collaboration indicators. Matching is case-insensitive: emails are
lowercased on write (createUser, updateUser) and at lookup time
(getUserByEmail), and normalizeUserEmails() lowercases any row written
before this change, run at startup next to enforceSessionLifetime().

AuthStore::login tries the identifier against email first, then falls back
to username - deliberately transitional, so an account with a placeholder
address (or a caller still sending its username) is not locked out. The
login API accepts an "email" field and keeps accepting "username"; email
wins when both are present. The existing {"username": "admin", "password":
"admin"} call keeps working, unchanged, which is what
scripts/verify-node.py and every case in tests/nodes use.

Two users can no longer share an email: createUser already rejected a
duplicate, and updateUser now rejects one too (case-insensitively, excluding
the user's own row) with 400 "Email already registered".

The WebUI login field is now labelled and placeholder'd as an email address;
its input stays type="text" rather than type="email" so the transitional
username fallback (e.g. plain "admin") is not blocked by browser-native
validation.

Verified: login by lowercase email, mixed-case email, and via an "email"
body field all return 200; username login still returns 200; creating or
updating a user to an email that only differs in case from an existing
user's is rejected with 400. Node suite: 81 passed, 0 failed, 0 skipped.
fszontagh 1 ay önce
ebeveyn
işleme
594a1f3ca4

+ 11 - 4
src/webserver/api/auth_controller.cpp

@@ -41,11 +41,18 @@ void AuthController::login(const httplib::Request& req, httplib::Response& res,
     try {
         auto body = nlohmann::json::parse(req.body);
 
-        std::string username = body.value("username", "");
+        // Email is the login credential; "username" is kept working so
+        // existing callers (scripts/verify-node.py, the node test suite)
+        // don't break. Whichever arrives, AuthStore::login tries it against
+        // email first and falls back to username.
+        std::string identifier = body.value("email", "");
+        if (identifier.empty()) {
+            identifier = body.value("username", "");
+        }
         std::string password = body.value("password", "");
 
-        if (username.empty() || password.empty()) {
-            sendError(res, "Username and password required", 400);
+        if (identifier.empty() || password.empty()) {
+            sendError(res, "Username/email and password required", 400);
             return;
         }
 
@@ -55,7 +62,7 @@ void AuthController::login(const httplib::Request& req, httplib::Response& res,
         }
         std::string user_agent = req.get_header_value("User-Agent");
 
-        auto result = auth_store_.login(username, password, ip, user_agent);
+        auto result = auth_store_.login(identifier, password, ip, user_agent);
         if (result.failed()) {
             sendError(res, result.error().message(), 401);
             return;

+ 73 - 6
src/webserver/auth/auth_store.cpp

@@ -1,6 +1,7 @@
 #include "auth_store.hpp"
 #include "common/uuid.hpp"
 #include "common/time_utils.hpp"
+#include "common/string_utils.hpp"
 #include "logging/logger.hpp"
 
 namespace smartbotic::webserver::auth {
@@ -129,6 +130,40 @@ void AuthStore::enforceSessionLifetime() {
              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,
                                    const std::string& email,
                                    const std::string& password,
@@ -139,8 +174,15 @@ Result<User> AuthStore::createUser(const std::string& username,
         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
-    existing = getUserByEmail(email);
+    existing = getUserByEmail(normalized_email);
     if (existing.ok()) {
         return Error(ErrorCode::AlreadyExists, "Email already registered");
     }
@@ -149,7 +191,7 @@ Result<User> AuthStore::createUser(const std::string& username,
     User user;
     user.id = UUID::generatePrefixed("usr");
     user.username = username;
-    user.email = email;
+    user.email = normalized_email;
     user.password_hash = BcryptUtils::hashPassword(password);
     user.role = role;
     user.active = true;
@@ -190,7 +232,10 @@ Result<User> AuthStore::getUserByUsername(const std::string& username) {
 
 Result<User> AuthStore::getUserByEmail(const std::string& email) {
     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;
 
     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;
     // 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
     update_data.erase("passwordHash");
 
@@ -279,11 +339,18 @@ Result<void> AuthStore::changePassword(const std::string& id,
     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& ip_address,
                                         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()) {
         return Error(ErrorCode::InvalidCredentials, "Invalid username or password");
     }
@@ -323,7 +390,7 @@ Result<LoginResponse> AuthStore::login(const std::string& username,
     updates["lastLogin"] = TimeUtils::nowMs();
     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;
     response.access_token = access_token;

+ 11 - 2
src/webserver/auth/auth_store.hpp

@@ -62,6 +62,14 @@ public:
     // a read and nothing else.
     void enforceSessionLifetime();
 
+    // Lowercases any stored user email that is not already lowercase. Email
+    // is now looked up case-insensitively by normalising to lowercase on
+    // write and at query time, but that only covers rows written after this
+    // change - a record written earlier under any other case would otherwise
+    // never match a lookup again. Run at startup, alongside
+    // enforceSessionLifetime(); a no-op after the first run on a given row.
+    void normalizeUserEmails();
+
     // User operations
     common::Result<User> createUser(const std::string& username,
                                     const std::string& email,
@@ -78,8 +86,9 @@ public:
                                         const std::string& old_password,
                                         const std::string& new_password);
 
-    // Authentication
-    common::Result<LoginResponse> login(const std::string& username,
+    // Authentication. `identifier` is matched against email first, then
+    // username as a transitional fallback - see the .cpp for why.
+    common::Result<LoginResponse> login(const std::string& identifier,
                                         const std::string& password,
                                         const std::string& ip_address = "",
                                         const std::string& user_agent = "");

+ 5 - 0
src/webserver/webserver_service.cpp

@@ -111,6 +111,11 @@ WebServerService::WebServerService(const WebServerServiceConfig& config)
     // take effect for as long as the old one lasted.
     auth_store_->enforceSessionLifetime();
 
+    // Email became the login credential and is matched case-insensitively;
+    // this brings any row written under the old case-sensitive scheme into
+    // line so it is still findable by lookup.
+    auth_store_->normalizeUserEmails();
+
     // Initialize auth middleware
     auth_middleware_ = std::make_unique<auth::AuthMiddleware>(*jwt_, *auth_store_);
 

+ 3 - 2
webui/src/pages/LoginPage.tsx

@@ -63,11 +63,12 @@ export default function LoginPage() {
 
             <div>
               <label htmlFor="username" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
-                Username
+                Email
               </label>
               <input
                 id="username"
                 type="text"
+                placeholder="you@example.com"
                 value={username}
                 onChange={(e) => setUsername(e.target.value)}
                 className="w-full px-4 py-2 border border-gray-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
@@ -99,7 +100,7 @@ export default function LoginPage() {
           </form>
 
           <p className="mt-6 text-center text-sm text-gray-500 dark:text-gray-400">
-            Default credentials: admin / admin
+            Default credentials: admin@localhost / admin
           </p>
         </div>
       </div>