d91971402abfed81d749023106edbbcd4642c901 max Tue Aug 18 16:36:22 2026 -0700 hgLogin: update gbMembers.lastUse on OAuth logins, not just gbMemberIdentity.lastUse OAuth sign-ins recorded the login only in gbMemberIdentity.lastUse (via linkIdentity) and never touched gbMembers.lastUse, so a returning social-login user's gbMembers.lastUse went stale. Move the lastUse stamp into loginAndReturn, the shared funnel for all social and email-link logins, so every method that passes through it records the sign-in uniformly. Drop the now-redundant incidental lastUse writes on the two email-link paths. The password path keeps its own stamp via clearNewPasswordFields (it uses displayLoginSuccess, not loginAndReturn). refs #37984 diff --git src/hg/hgLogin/hgLogin.c src/hg/hgLogin/hgLogin.c index 17e30c1f304..558997735e5 100644 --- src/hg/hgLogin/hgLogin.c +++ src/hg/hgLogin/hgLogin.c @@ -1724,35 +1724,39 @@ if (!emailLinkEnabled()) return; hPrintf("" "Email me a sign-in link", hgLoginUrl); } static void printUsernameNote() /* Print a short hint, shown wherever a new username is chosen, explaining that the username * shows up in every short link the user later creates, so it should be short and easy to type. */ { hPrintf("
Note: your username becomes part of every short link "
"you create later (for example %s/s/username/MySession), so choose "
"something short and easy to type.
Login successful, setting cookies now…
"); struct dyString *cookieJS = getLoginCookieJS(userName, idx); jsInline(cookieJS->string); cartRemove(cart, "hgLogin_userName"); returnToURL(150); } static void createIdentityTable(struct sqlConnection *conn) /* Create the gbMemberIdentity table if it does not exist. On a mirror whose central db * is read-only this may fail; social login simply won't work there (and won't be enabled * without client secrets anyway), so ignore any error. */ { if (sqlTableExists(conn, "gbMemberIdentity")) return; @@ -2042,31 +2046,31 @@ user, realName, emptyForNull(email), emailVerified ? "Y" : "N"); sqlUpdate(conn, dyStringContents(q)); dyStringFree(&q); uint idx = sqlLastAutoId(conn); struct oauthIdentity pending; ZeroVar(&pending); pending.provider = provider; pending.subject = subject; pending.email = email; linkIdentity(conn, idx, &pending); clearPendingIdentity(); if (!emailVerified) setupNewAccount(conn, email, user); // send confirmation mail for the unverified address -loginAndReturn(user, idx); +loginAndReturn(conn, user, idx); } void chooseAccountPage(struct sqlConnection *conn) /* Ask the user which of several accounts sharing an email address to sign in to. Used by * two flows: OAuth (oauth_pending_* in the cart -> the chosen account is linked to the social * identity) and the passwordless email link (emailLogin_* in the cart -> just sign in). */ { char *provider = cartUsualString(cart, "oauth_pending_provider", ""); boolean emailMode = isEmpty(provider); if (emailMode && !emailLinkEnabled()) { // The email-link chooser must not run where passwordless login is switched off. displayLoginPage(conn); return; } @@ -2159,44 +2163,40 @@ return; } sqlSafef(query, sizeof(query), "SELECT * FROM gbMembers WHERE idx=%d AND (email='%s' OR recovEmail='%s') " "AND loginToken='%s' AND loginToken<>'' AND loginTokenExpires > NOW() " "AND accountActivated='Y'", chosenIdx, email, email, tokenMd5); struct gbMembers *m = gbMembersLoadByQuery(conn, query); if (m == NULL) { freez(&errMsg); errMsg = cloneString("Please choose one of the listed accounts."); chooseAccountPage(conn); return; } - /* Consume the token on every account that shared it (single use), then sign in. */ + /* Consume the token on every account that shared it (single use), then sign in. + * loginAndReturn records the sign-in on the chosen account in gbMembers.lastUse. */ sqlSafef(query, sizeof(query), "UPDATE gbMembers SET loginToken='' WHERE (email='%s' OR recovEmail='%s') AND loginToken='%s'", email, email, tokenMd5); sqlUpdate(conn, query); - /* Record the sign-in on the account actually chosen, the same as the single-account path in - * emailLogin, so lastUse reflects the login. */ - sqlSafef(query, sizeof(query), - "UPDATE gbMembers SET lastUse=NOW() WHERE idx=%u", m->idx); - sqlUpdate(conn, query); cartRemove(cart, "emailLogin_email"); cartRemove(cart, "emailLogin_tokenMd5"); cartRemove(cart, "hgLogin_chosenIdx"); - loginAndReturn(m->userName, m->idx); + loginAndReturn(conn, m->userName, m->idx); gbMembersFree(&m); return; } /* OAuth mode. */ char *subject = cartUsualString(cart, "oauth_pending_subject", ""); char *email = cartUsualString(cart, "oauth_pending_email", ""); if (isEmpty(subject) || isEmpty(email) || !pendingIdentityValid()) { clearPendingIdentity(); freez(&errMsg); errMsg = cloneString("Your login session expired. Please sign in again."); displayLoginPage(conn); return; } @@ -2212,31 +2212,31 @@ if (m == NULL) { freez(&errMsg); errMsg = cloneString("Please choose one of the listed accounts."); chooseAccountPage(conn); return; } struct oauthIdentity pending; ZeroVar(&pending); pending.provider = provider; pending.subject = subject; pending.email = email; linkIdentity(conn, m->idx, &pending); clearPendingIdentity(); cartRemove(cart, "hgLogin_chosenIdx"); -loginAndReturn(m->userName, m->idx); +loginAndReturn(conn, m->userName, m->idx); gbMembersFree(&m); } static void resolveIdentity(struct sqlConnection *conn, struct oauthIdentity *id) /* Log in the user behind an authenticated provider identity: * 1. If the provider gave a verified email matching MORE THAN ONE account, always let the * user pick which one -- even if this identity was linked before. Because login cookies * never expire, a user goes through OAuth very rarely, so an occasional pick is cheap * and it lets a person with several same-email accounts choose freely each time. * 2. Else if the (provider,subject) is already linked, log into that account. * 3. Else if the verified email matches exactly one account, auto-link and log in. * 4. Else send the user to the "choose a username" page to finish a new account. * (Providers that don't release an email, e.g. ORCID, never reach step 1 or 3 and rely on * the stored link from step 2.) */ { @@ -2261,40 +2261,40 @@ n = slCount(matches); } if (n > 1) { setPendingIdentity(id); gbMembersFreeList(&matches); chooseAccountPage(conn); return; } struct gbMembers *linked = memberForIdentity(conn, id); if (linked != NULL) { linkIdentity(conn, linked->idx, id); - loginAndReturn(linked->userName, linked->idx); + loginAndReturn(conn, linked->userName, linked->idx); gbMembersFree(&linked); gbMembersFreeList(&matches); return; } if (n == 1) { linkIdentity(conn, matches->idx, id); - loginAndReturn(matches->userName, matches->idx); + loginAndReturn(conn, matches->userName, matches->idx); gbMembersFreeList(&matches); return; } gbMembersFreeList(&matches); setPendingIdentity(id); completeAccountPage(conn); } void oauthStart(struct sqlConnection *conn) /* Begin a social login: save an anti-CSRF state nonce (in the cart) and redirect the * browser to the provider's authorization page. */ { char *provider = cgiUsualString("provider", ""); if (!oauthProviderEnabled(provider)) @@ -2501,33 +2501,33 @@ sqlSafef(query, sizeof(query), "SELECT * FROM gbMembers WHERE (email='%s' OR recovEmail='%s') AND loginToken='%s' " "AND loginToken<>'' AND loginTokenExpires > NOW() AND accountActivated='Y' ORDER BY idx", email, email, tokenMD5); struct gbMembers *list = gbMembersLoadByQuery(conn, query); int n = slCount(list); if (n == 0) { freez(&errMsg); errMsg = cloneString("This login link is invalid or has expired. Please request a new one."); displayLoginPage(conn); } else if (n == 1) { sqlSafef(query, sizeof(query), - "UPDATE gbMembers SET loginToken='', lastUse=NOW() WHERE idx=%u", list->idx); + "UPDATE gbMembers SET loginToken='' WHERE idx=%u", list->idx); sqlUpdate(conn, query); - loginAndReturn(list->userName, list->idx); + loginAndReturn(conn, list->userName, list->idx); } else { /* Several accounts share this now-verified address: stash the proof and let the user * pick one. chooseAccount re-checks the token before logging in. */ cartSetString(cart, "emailLogin_email", email); cartSetString(cart, "emailLogin_tokenMd5", tokenMD5); chooseAccountPage(conn); } gbMembersFreeList(&list); } static void dropRequestSuppliedFlowVars() /* The cart variables holding the state of a login in flight are written by hgLogin and by * nothing else: the nonce and provider of an OAuth round trip, the pending identity behind