1d079d218c274f09b61ec390619782e0c505d157 max Tue Sep 1 06:44:56 2026 -0700 hgLogin: confirm the recovery email address by mail, refs #38197 The optional recovery address given at signup is now mailed a signed confirmation link, and counts for sign-in and password recovery only once that link has been opened. The link is signed with login.cookieSalt, expires after a week and works exactly once. Adds gbMembers.recovEmailVerified through the usual auto-upgrade. Existing rows default to 'Y', so recovery addresses set up before this keep working and nobody has to re-confirm one. An install that sends no mail, or that has no login.cookieSalt to sign a link with, stores the address as it does today. diff --git src/hg/hgLogin/hgLogin.c src/hg/hgLogin/hgLogin.c index 85904827b20..fcec1851abd 100644 --- src/hg/hgLogin/hgLogin.c +++ src/hg/hgLogin/hgLogin.c @@ -36,44 +36,47 @@ #include "hCommon.h" #include "botDelay.h" #include "errCatch.h" #define EMAILSEP ";" /* ---- Global variables. ---- */ char msg[4096] = ""; char *incorrectUsernameOrPassword="The username or password you entered is incorrect."; char *incorrectUsername="The username you entered is incorrect."; /* The excludeVars are not saved to the cart. */ char *excludeVars[] = { "submit", "Submit", "debug", "fixMembers", "update", "hgLogin_password", "hgLogin_password2", "hgLogin_newPassword1", "hgLogin_newPassword2", "hgLogin_newEmail1", "hgLogin_newEmail2", "hgLogin_curPassword", "code", "state", "provider", "user", "token", - "newEmail", "exp", "sig", NULL }; + "newEmail", "recovEmail", "exp", "sig", NULL }; struct cart *cart; /* This holds cgi and other variables between clicks. */ char *database; /* Name of genome database - hg15, mm3, or the like. */ struct hash *oldCart; /* Old cart hash. */ char *errMsg = NULL; /* Error message to show user when form data rejected */ char brwName[64]; char brwAddr[256]; char signature[256]; char returnAddr[256]; char *hgLoginUrl = NULL; /* full absolute URL to hgLogin as seen from browser, e.g. http://genome.ucsc.edu/cgi-bin/hgLogin. Can be a relative URL /cgi-bin/hgLogin if hg.conf login.relativeLink is on. */ boolean pwdEyeIconEnabled = TRUE; /* show/hide eye icon on password fields; set from hg.conf login.pwdEyeIcon in doMiddle() */ +boolean recovEmailVerifyOk = FALSE; /* TRUE when gbMembers has the recovEmailVerified column, so + a confirmed recovery address can be told apart from one that was merely typed into the signup + form. Set in doMiddle() after the auto-upgrade; FALSE on a mirror where the ALTER failed. */ /* for earlyBotCheck() function at the beginning of main() */ #define delayFraction 1.0 /* standard penalty is 1.0 for most CGIs */ /* Forward declarations for functions used before their definitions. */ static void printSocialButtons(boolean dividerAbove, boolean dividerBelow, char *action); static void printEmailLinkButton(); static boolean emailLinkEnabled(); static void printUsernameNote(); void emailLinkPage(struct sqlConnection *conn); void displayLoginPage(struct sqlConnection *conn); void displayAccHelpPage(struct sqlConnection *conn); void completeAccountPage(struct sqlConnection *conn); void sendEmailLink(struct sqlConnection *conn); @@ -506,40 +509,62 @@ void mailUsername(char *email, char *users) /* send user name list to the email address */ { char subject[256]; char msg[4096]; char *remoteAddr=getenv("REMOTE_ADDR"); safef(subject, sizeof(subject),"Your username at the %s", brwName); safef(msg, sizeof(msg), " Someone (probably you, from IP address %s) has requested username(s) associated with this email address at the %s: \n\n %s\n\n%s\n%s", remoteAddr, brwName, users, signature, returnAddr); sendMailOut(email, subject, msg); } +static char *sqlAddressMatch(char *email) +/* Return a SQL fragment matching the gbMembers rows that belong to whoever controls email: the + * accounts carrying it as their primary address, plus the accounts carrying it as a *confirmed* + * recovery address. An unconfirmed recovEmail is only a string that a signup form typed in -- + * nobody ever proved they can read mail there -- so matching it would let someone who registered + * with a victim's address as their recovery address capture that victim's login (see + * confirmRecovEmail). Callers must pass a non-empty email, or rows with a blank recovEmail + * match. Result is allocd and carries the sqlSafef prefix; embed it with %-s. */ +{ +struct dyString *dy = sqlDyStringCreate("(email='%s'", email); +if (recovEmailVerifyOk) + sqlDyStringPrintf(dy, " OR (recovEmail='%s' AND recovEmailVerified='Y')", email); +else + /* A mirror whose gbMembers predates the column: we cannot tell confirmed from unconfirmed, + * so keep the old behavior rather than locking those users out of their own accounts. */ + sqlDyStringPrintf(dy, " OR recovEmail='%s'", email); +sqlDyStringPrintf(dy, ")"); +return dyStringCannibalize(&dy); +} + void sendUsername(struct sqlConnection *conn, char *email) /* email user username(s) */ { struct sqlResult *sr; char **row; -char query[256]; +char query[1024]; /* find all the user names associated with this email address */ char userList[512]=""; -sqlSafef(query,sizeof(query),"SELECT * FROM gbMembers WHERE email='%s' or recovEmail='%s'", email, email); +char *addrMatch = sqlAddressMatch(email); +sqlSafef(query,sizeof(query),"SELECT * FROM gbMembers WHERE %-s", addrMatch); +freeMem(addrMatch); sr = sqlGetResult(conn, query); int numUser = 0; while ((row = sqlNextRow(sr)) != NULL) { struct gbMembers *m = gbMembersLoad(row); if (numUser >= 1) safecat(userList, sizeof(userList), ", "); safecat(userList, sizeof(userList), m->userName); numUser += 1; } sqlFreeResult(&sr); mailUsername(email, userList); } void sendPwdMailOut(char *email, char *recovEmail, char *subject, char *msg, char *username) @@ -658,30 +683,38 @@ /* email user new password */ { char query[256]; /* find email address associated with this username */ sqlSafef(query,sizeof(query),"SELECT email FROM gbMembers WHERE userName='%s'", username); char *email = sqlQuickString(conn, query); if (!email || sameString(email,"")) { freez(&errMsg); errMsg = cloneString("Email address not found."); displayAccHelpPage(conn); return; } +/* Only a confirmed recovery address gets a copy: an unconfirmed one never proved it belongs to + * this account, and a new password must not be mailed to a stranger whose address someone typed + * into the signup form. */ +if (recovEmailVerifyOk) + sqlSafef(query,sizeof(query), + "SELECT recovEmail FROM gbMembers WHERE userName='%s' AND recovEmailVerified='Y'", + username); +else sqlSafef(query,sizeof(query),"SELECT recovEmail FROM gbMembers WHERE userName='%s'", username); char *recovEmail = sqlQuickString(conn, query); sendNewPwdMail(username, email, recovEmail, password); } void lostPassword(struct sqlConnection *conn, char *username) /* Generate and mail new password to user */ { char query[256]; char *password = generateRandomPassword(); char encPwd[45] = ""; encryptNewPwd(password, encPwd, sizeof(encPwd)); sqlSafef(query,sizeof(query), "UPDATE gbMembers SET lastUse=NOW(),newPassword='%s', newPasswordExpire=DATE_ADD(NOW(), INTERVAL 7 DAY), passwordChangeRequired='Y' WHERE userName='%s'", encPwd, username); @@ -1069,30 +1102,83 @@ * change was not theirs and can ask us to undo it. This is the notice that protects the current * owner -- confirming the new address only proves the new mailbox is reachable. */ { char subject[256]; safef(subject, sizeof(subject), "Your %s email address was changed", brwName); char *remoteAddr = getenv("REMOTE_ADDR"); char message[4096]; safef(message, sizeof(message), "The email address on the %s account \"%s\" was just changed to %s (request from IP address " "%s).\n\nIf you made this change, nothing more is needed. If you did NOT, please reply to " "this message right away so we can help you secure the account.\n\n%s\n%s", brwName, user, newEmail, emptyForNull(remoteAddr), signature, returnAddr); sendActMailOut(oldEmail, subject, message); } +static char *recovEmailSig(char *user, char *recovEmail, char *verified, char *expStr) +/* HMAC-MD5 over a pending recovery-address confirmation, keyed by the secret login.cookieSalt. + * It goes in the link mailed to the address, so that opening the link -- and only opening it -- + * marks the address confirmed, proving the mailbox really does reach the person who claimed it. + * verified is the account's recovEmailVerified value when the link was minted; because + * confirmRecovEmail recomputes the signature from the value currently on the account, a link + * stops validating once it has been used, so each link works exactly once. Result is allocd. */ +{ +char *salt = cfgOption(CFG_LOGIN_COOKIE_SALT); +if (isEmpty(salt)) + errAbort("Confirming a recovery email address requires %s in hg.conf, set to a secret random " + "string. Without a secret we cannot sign the confirmation link.", CFG_LOGIN_COOKIE_SALT); +char buf[1024]; +safef(buf, sizeof(buf), "recovEmail|%s|%s|%s|%s", + emptyForNull(user), emptyForNull(recovEmail), emptyForNull(verified), emptyForNull(expStr)); +return hmacMd5(salt, buf); +} + +static void sendRecovEmailConfirmMail(char *recovEmail, char *user) +/* Email a one-time link to recovEmail that, when opened, marks it confirmed for account user. + * Until that happens the address counts for nothing: it cannot sign anyone in and it gets no + * copy of a password reset. The link lasts a week, like the account activation mail, because a + * recovery mailbox is often not the one its owner reads every day. */ +{ +char expStr[32]; +safef(expStr, sizeof(expStr), "%ld", clock1() + 7*24*3600); // link good for a week +char *sig = recovEmailSig(user, recovEmail, "N", expStr); +char url[1024]; +safef(url, sizeof(url), + "%s?hgLogin.do.confirmRecovEmail=1&user=%s&recovEmail=%s&exp=%s&sig=%s", + hgLoginUrl, cgiEncode(user), cgiEncode(recovEmail), expStr, sig); +char subject[256]; +safef(subject, sizeof(subject), "Confirm your %s recovery email address", brwName); +char *remoteAddr = getenv("REMOTE_ADDR"); +char message[4096]; +safef(message, sizeof(message), + "Someone (probably you, from IP address %s) gave this address as the recovery email address " + "for the %s account \"%s\".\nTo confirm that this mailbox is yours, open this link in your " + "browser:\n\n%s\n\nThe link works once and expires in seven days. Until it is opened, this " + "address cannot be used to sign in to that account and will not receive a password reset.\n\n" + "If this is *not* you, do not open the link: someone typed your address by mistake, and " + "ignoring this message is all it takes to keep them from using it.\n\n%s\n%s", + emptyForNull(remoteAddr), brwName, user, url, signature, returnAddr); +/* Not sendActMailOut(): that exits the CGI when the address will not take mail, which would + * end the signup response after the account has already been created and its activation mail + * sent. A recovery address is optional and easy to mistype, so a bad one must not derail + * signing up -- the address simply stays unconfirmed, which is the safe state. */ +if (mailViaPipeBounce(recovEmail, subject, message, returnAddr) == -1) + fprintf(stderr, "hgLogin: could not mail recovery-address confirmation to %s for account " + "%s\n", recovEmail, user); +freeMem(sig); +} + void changeEmailPage(struct sqlConnection *conn) /* Draw the change-email page for the currently logged-in user. The account is taken from * the validated login cookie (wikiLinkUserName), never from a form field, so a user can only * change their own email. Where the account has a password we also ask for it here, so a * borrowed login cookie alone cannot change the address (and from there take over the account * via password recovery). Social-login accounts have no password and are asked for none; for * them the new address is instead confirmed by email before it takes effect (see changeEmail). */ { if (!emailLinkEnabled()) { displayLoginPage(conn); return; } char *user = wikiLinkUserName(); if (isEmpty(user)) @@ -1246,30 +1332,87 @@ } sqlSafef(query, sizeof(query), "UPDATE gbMembers SET email='%s', lastUse=NOW() WHERE userName='%s'", newEmail, user); sqlUpdate(conn, query); /* Alert the previous address that the change happened, so a hijack is noticed. */ if (isNotEmpty(oldEmail) && differentWord(oldEmail, newEmail)) sendChangeEmailAlertMail(oldEmail, user, newEmail); char *encEmail = htmlEncode(newEmail); hPrintf("

%s

", brwName); hPrintf("

Your email address has been changed.

"); hPrintf("

Your email address is now %s.

", encEmail); freeMem(encEmail); returnToURL(1500); } +void confirmRecovEmail(struct sqlConnection *conn) +/* Mark a recovery address confirmed. Reached by opening the signed link mailed to that address + * (see sendRecovEmailConfirmMail); the signature and its expiry are the authorization, so this + * needs no login cookie -- the link may be opened from that mailbox in any browser. */ +{ +if (!recovEmailVerifyOk || isEmpty(cfgOption(CFG_LOGIN_COOKIE_SALT))) + { + /* No column to record the answer in, or no secret to check the signature against, so the + * link cannot have come from us. Checked before recovEmailSig(), which aborts without a + * secret: a link is only ever minted where one is configured, so reaching here means a + * hand-made URL and it deserves the ordinary refusal, not an error page. */ + freez(&errMsg); + errMsg = cloneString("This confirmation link is not valid."); + displayLoginPage(conn); + return; + } +char *user = cgiUsualString("user", ""); +char *recovEmail = cgiUsualString("recovEmail", ""); +char *expStr = cgiUsualString("exp", ""); +char *sig = cgiUsualString("sig", ""); +/* Recompute the signature over the flag currently on the account. Once confirmed the flag is + * 'Y', so re-opening the same link no longer matches: the link works exactly once. */ +char query[1024]; +sqlSafef(query, sizeof(query), + "SELECT recovEmailVerified FROM gbMembers WHERE userName='%s' AND recovEmail='%s'", + user, recovEmail); +char *verified = sqlQuickString(conn, query); +char *expected = recovEmailSig(user, recovEmail, emptyForNull(verified), expStr); +boolean sigOk = isNotEmpty(sig) && sameString(sig, expected); +freeMem(expected); +if (!sigOk || isEmpty(user) || spc_email_isvalid(recovEmail) == 0) + { + freez(&errMsg); + errMsg = cloneString("This confirmation link is not valid or has already been used."); + displayLoginPage(conn); + return; + } +if (clock1() > atol(expStr)) + { + freez(&errMsg); + errMsg = cloneString("This confirmation link has expired."); + displayLoginPage(conn); + return; + } +sqlSafef(query, sizeof(query), + "UPDATE gbMembers SET recovEmailVerified='Y', lastUse=NOW() " + "WHERE userName='%s' AND recovEmail='%s'", user, recovEmail); +sqlUpdate(conn, query); +char *encEmail = htmlEncode(recovEmail); +hPrintf("

%s

", brwName); +hPrintf("

Your recovery email address has been confirmed.

"); +hPrintf("

%s can now be used to sign in to your account and to recover your " + "password.

", encEmail); +freeMem(encEmail); +returnToURL(1500); +} + void signupPage(struct sqlConnection *conn) /* draw the signup page */ { hPrintf("
" "

%s

", brwName); hPrintf( "

Signing up enables you to save multiple sessions, share your sessions with others via short and stable session links and manage previously uploaded custom tracks and track hubs.

" "\n"); hPrintf("

Already have an account? " "Go to the login page.

", hgLoginUrl); printSocialButtons(FALSE, TRUE, "Sign up"); hPrintf("

Sign Up Using Email

" "
" "%s" "\n", hgLoginUrl, errMsg ? errMsg : ""); @@ -1284,30 +1427,33 @@ "" "
" "\n" "
" "" "" "
\n", htmlEncode(cartUsualString(cart, "hgLogin_userName", "")), // all three go into value="" attributes; escape (XSS) htmlEncode(cartUsualString(cart, "hgLogin_email", "")), htmlEncode(cartUsualString(cart, "hgLogin_email2", ""))); if (sqlFieldIndex(conn, "gbMembers", "recovEmail") != -1) hPrintf("
" "" "" + "

We will email this address a link to confirm it. Until " + "you open that link, the address cannot be used to sign in or to recover your " + "password.

" "
" "\n"); hPrintf("
" "" "" "", htmlEncode(cartUsualString(cart, "hgLogin_password", ""))); // value="" attribute; escape (XSS) printPwdEyeIcon("signupPwEyeIcon", "signupPwEyeSlash"); hPrintf( "" "
" "\n" "
" "" @@ -1450,89 +1596,103 @@ } /* pass all the checks, OK to create the account now */ char encPwd[45] = ""; encryptNewPwd(password, encPwd, sizeof(encPwd)); char *accActStatus = "N"; if (sameWord(returnAddr, "NOEMAIL")) accActStatus = "Y"; struct dyString *query2 = sqlDyStringCreate( "INSERT INTO gbMembers SET " "userName='%s',realName='%s',password='%s',email='%s'," "lastUse=NOW(),accountActivated='%s'", user,user,encPwd,email,accActStatus); +/* A recovery address is confirmed by mail before it counts for anything (see + * sendRecovEmailConfirmMail). Two kinds of install cannot confirm anything: one that sends no + * mail at all, and one with no login.cookieSalt to sign the link with (plain login does not + * need the salt, so an install can run happily without one). There, leave the address as + * usable as it is today rather than storing one that could never be confirmed. */ +boolean confirmRecov = !isEmpty(recovEmail) && recovEmailVerifyOk + && !sameWord(returnAddr, "NOEMAIL") + && isNotEmpty(cfgOption(CFG_LOGIN_COOKIE_SALT)); // set the recov email only if we got one (and we only got one if the table has this field) if (!isEmpty(recovEmail)) sqlDyStringPrintf(query2, ",recovEmail='%s'", recovEmail); +if (confirmRecov) + sqlDyStringPrintf(query2, ",recovEmailVerified='N'"); sqlUpdate(conn, dyStringContents(query2)); dyStringFree(&query2); if (sameWord(returnAddr, "NOEMAIL")) { redirectToLoginPage("hgLogin.do.displayLoginPage=1"); return; } setupNewAccount(conn, email, user); +if (confirmRecov) + sendRecovEmailConfirmMail(recovEmail, user); /* send out activate code mail, and display the mail confirmation box */ cartRemove(cart, "hgLogin_email"); cartRemove(cart, "hgLogin_email2"); cartRemove(cart, "hgLogin_userName"); cartRemove(cart, "user"); cartRemove(cart, "token"); redirectToLoginPage("hgLogin.do.displayActMailSuccess=1"); } void accountHelp(struct sqlConnection *conn) /* email user username(s) or new password */ { -char query[256]; +char query[1024]; // room for an address-matching clause holding a long address twice char *email = cartUsualString(cart, "hgLogin_email", ""); char *username = cartUsualString(cart, "hgLogin_userName", ""); char *helpWith = cartUsualString(cart, "hgLogin_helpWith", ""); /* Passwordless email login link */ if (sameString(helpWith,"loginLink")) { sendEmailLink(conn); return; } /* Forgot username */ if (sameString(helpWith,"username")) { if (sameString(email,"")) { freez(&errMsg); errMsg = cloneString("Email address cannot be blank."); displayAccHelpPage(conn); return; } else if (spc_email_isvalid(email) == 0) { freez(&errMsg); errMsg = cloneString("Invalid email address format."); displayAccHelpPage(conn); return; } else { + char *addrMatch = sqlAddressMatch(email); sqlSafef(query,sizeof(query), - "SELECT password FROM gbMembers WHERE email='%s' or recovEmail='%s'", email, email); + "SELECT password FROM gbMembers WHERE %-s", addrMatch); + freeMem(addrMatch); char *password = sqlQuickString(conn, query); cartSetString(cart, "hgLogin_sendMailTo", email); cartSetString(cart, "hgLogin_sendMailContain", "username(s)"); if (!password) /* Email address not found */ { displayMailSuccess(); return; } sendUsername(conn, email); return; } } /* Forgot password */ if (sameString(helpWith,"password")) { @@ -2079,45 +2239,45 @@ { // The email-link chooser must not run where passwordless login is switched off. displayLoginPage(conn); return; } char *email = emailMode ? cartUsualString(cart, "emailLogin_email", "") : cartUsualString(cart, "oauth_pending_email", ""); if (isEmpty(email) || (!emailMode && !pendingIdentityValid())) { if (!emailMode) clearPendingIdentity(); displayLoginPage(conn); return; } char *encEmail = htmlEncode(email); // the address is displayed; never trust it raw (XSS) -char query[512]; +char query[1024]; +char *addrMatch = sqlAddressMatch(email); // email is non-empty here (checked above) if (emailMode) // Only the accounts that hold the just-validated login token, matching what emailLogin saw. sqlSafef(query, sizeof(query), - "SELECT * FROM gbMembers WHERE (email='%s' OR recovEmail='%s') AND loginToken='%s' " + "SELECT * FROM gbMembers WHERE %-s AND loginToken='%s' " "AND loginToken<>'' AND loginTokenExpires > NOW() AND accountActivated='Y' ORDER BY idx", - email, email, cartUsualString(cart, "emailLogin_tokenMd5", "")); + addrMatch, cartUsualString(cart, "emailLogin_tokenMd5", "")); else // Only activated accounts, matching what chooseAccount() and resolveIdentity() accept; // otherwise the page offers a row the action refuses, and shows the username of an - // unactivated row anyone could have created with this address. Match both primary and - // recovery address, as resolveIdentity() does; email is non-empty here (checked above). + // unactivated row anyone could have created with this address. sqlSafef(query, sizeof(query), - "SELECT * FROM gbMembers WHERE (email='%s' OR recovEmail='%s') AND accountActivated='Y' " - "ORDER BY idx", email, email); + "SELECT * FROM gbMembers WHERE %-s AND accountActivated='Y' ORDER BY idx", addrMatch); +freeMem(addrMatch); struct gbMembers *list = gbMembersLoadByQuery(conn, query), *m; hPrintf("
" "

%s

", brwName); hPrintf("

Choose an account

"); if (emailMode) hPrintf("

The email address %s is associated with more than one %s account. " "Select the account you would like to sign in to.

", encEmail, brwName); else hPrintf("

The email address %s is associated with more than one %s account. " "Select the account you would like to sign in to; your %s login will be linked to it.

", encEmail, brwName, oauthProviderLabel(provider)); hPrintf("%s", errMsg ? errMsg : ""); hPrintf("", hgLoginUrl); hPrintf("
"); @@ -2138,95 +2298,97 @@ "  Cancel" "
", getReturnToUrlForAttr()); cartSaveSession(cart); freeMem(encEmail); gbMembersFreeList(&list); } void chooseAccount(struct sqlConnection *conn) /* Finish the "which account?" chooser: for OAuth, link the pending identity to the chosen * account; for the email link, just sign in. Either way, only accept an account that really * matches the verified email (and, for the email link, still holds the valid token), never an * arbitrary username the client might submit. */ { int chosenIdx = cartUsualInt(cart, "hgLogin_chosenIdx", 0); char *provider = cartUsualString(cart, "oauth_pending_provider", ""); -char query[512]; +char query[1024]; // room for an address-matching clause holding a long address twice if (isEmpty(provider)) { /* Passwordless email-link mode. */ if (!emailLinkEnabled()) { displayLoginPage(conn); return; } char *email = cartUsualString(cart, "emailLogin_email", ""); char *tokenMd5 = cartUsualString(cart, "emailLogin_tokenMd5", ""); if (isEmpty(email) || isEmpty(tokenMd5)) { freez(&errMsg); errMsg = cloneString("Your login link expired. Please request a new one."); displayLoginPage(conn); return; } + char *addrMatch = sqlAddressMatch(email); sqlSafef(query, sizeof(query), - "SELECT * FROM gbMembers WHERE idx=%d AND (email='%s' OR recovEmail='%s') " + "SELECT * FROM gbMembers WHERE idx=%d AND %-s " "AND loginToken='%s' AND loginToken<>'' AND loginTokenExpires > NOW() " "AND accountActivated='Y'", - chosenIdx, email, email, tokenMd5); + chosenIdx, addrMatch, 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. * 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); + "UPDATE gbMembers SET loginToken='' WHERE %-s AND loginToken='%s'", + addrMatch, tokenMd5); + freeMem(addrMatch); sqlUpdate(conn, query); cartRemove(cart, "emailLogin_email"); cartRemove(cart, "emailLogin_tokenMd5"); cartRemove(cart, "hgLogin_chosenIdx"); 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; } /* Only an activated account counts: an unactivated row can hold any address someone typed * without ever proving they own it (see resolveIdentity), so it must not receive a social link. - * Match both primary and recovery address, as chooseAccountPage() offers; email is non-empty - * here (checked above). */ + * Match what chooseAccountPage() offers; email is non-empty here (checked above). */ +char *oauthAddrMatch = sqlAddressMatch(email); sqlSafef(query, sizeof(query), - "SELECT * FROM gbMembers WHERE idx=%d AND (email='%s' OR recovEmail='%s') " - "AND accountActivated='Y'", - chosenIdx, email, email); + "SELECT * FROM gbMembers WHERE idx=%d AND %-s AND accountActivated='Y'", + chosenIdx, oauthAddrMatch); +freeMem(oauthAddrMatch); struct gbMembers *m = gbMembersLoadByQuery(conn, query); 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(); @@ -2239,43 +2401,44 @@ /* 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.) */ { struct gbMembers *matches = NULL; int n = 0; if (id->emailVerified && isNotEmpty(id->email)) { - char query[512]; - /* Match the provider email against both the primary and the recovery address, the same as - * password and email-link login do (see sendUsername/emailLogin). The isNotEmpty() guard - * above keeps id->email out of the query, so a blank recovEm=='' row can never match. + char query[1024]; + /* Match the provider email against the primary address and any confirmed recovery address, + * the same as password and email-link login do (see sqlAddressMatch). The isNotEmpty() + * guard above keeps an empty id->email out of the query, so a blank recovEmail='' row can + * never match. * Match only activated accounts. gbMembers has no unique key on email, and the plain * signup form will create an unactivated row for any address a person types -- the * activation mail goes to the address's real owner, who ignores it. Without this filter * someone could pre-register a victim's address, and the victim's first social login would * then auto-link to (and sign in as) the attacker's account. */ + char *addrMatch = sqlAddressMatch(id->email); sqlSafef(query, sizeof(query), - "SELECT * FROM gbMembers WHERE (email='%s' OR recovEmail='%s') AND accountActivated='Y' " - "ORDER BY idx", - id->email, id->email); + "SELECT * FROM gbMembers WHERE %-s AND accountActivated='Y' ORDER BY idx", addrMatch); + freeMem(addrMatch); matches = gbMembersLoadByQuery(conn, query); n = slCount(matches); } if (n > 1) { setPendingIdentity(id); gbMembersFreeList(&matches); chooseAccountPage(conn); return; } struct gbMembers *linked = memberForIdentity(conn, id); if (linked != NULL) { @@ -2450,34 +2613,35 @@ /* Generate and email a one-time passwordless login link to the address on file. */ { if (!emailLinkEnabled()) { displayLoginPage(conn); return; } char *email = cartUsualString(cart, "hgLogin_email", ""); if (isEmpty(email) || spc_email_isvalid(email) == 0) { freez(&errMsg); errMsg = cloneString("Please enter a valid email address."); emailLinkPage(conn); return; } -char query[512]; +char query[1024]; +char *addrMatch = sqlAddressMatch(email); sqlSafef(query, sizeof(query), - "SELECT * FROM gbMembers WHERE (email='%s' OR recovEmail='%s') AND accountActivated='Y'", - email, email); + "SELECT * FROM gbMembers WHERE %-s AND accountActivated='Y'", addrMatch); +freeMem(addrMatch); struct gbMembers *list = gbMembersLoadByQuery(conn, query), *m; if (list != NULL) { /* One token for the address, stored on every account that uses it, and one email. * The user proves they own the address by clicking; only then (in emailLogin) do we * reveal the accounts and let them choose, so we never disclose accounts to someone * who merely typed the address here. */ char *token = makeRandomKey(128+33); char *tokenMD5 = generateTokenMD5(token); for (m = list; m != NULL; m = m->next) { sqlSafef(query, sizeof(query), "UPDATE gbMembers SET loginToken='%s', " "loginTokenExpires=DATE_ADD(NOW(), INTERVAL 1 HOUR) WHERE idx=%u", tokenMD5, m->idx); @@ -2493,35 +2657,37 @@ } void emailLogin(struct sqlConnection *conn) /* Validate a one-time email login token. The token proves the user owns the address; if it * matches one account, log straight in; if it matches several accounts that share the * address, show the account chooser (the same one the OAuth flow uses). */ { if (!emailLinkEnabled()) { displayLoginPage(conn); return; } char *email = cgiUsualString("email", ""); char *token = cgiUsualString("token", ""); char *tokenMD5 = generateTokenMD5(token); -char query[512]; +char query[1024]; +char *addrMatch = sqlAddressMatch(email); sqlSafef(query, sizeof(query), - "SELECT * FROM gbMembers WHERE (email='%s' OR recovEmail='%s') AND loginToken='%s' " + "SELECT * FROM gbMembers WHERE %-s AND loginToken='%s' " "AND loginToken<>'' AND loginTokenExpires > NOW() AND accountActivated='Y' ORDER BY idx", - email, email, tokenMD5); + addrMatch, tokenMD5); +freeMem(addrMatch); 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='' WHERE idx=%u", list->idx); sqlUpdate(conn, query); loginAndReturn(conn, list->userName, list->idx); } @@ -2559,30 +2725,38 @@ cartRemove(cart, serverOwned[i]); } void doMiddle(struct cart *theCart) /* Write the middle parts of the HTML page. * This routine sets up some globals and then * dispatches to the appropriate page-maker. */ { struct sqlConnection *conn = hConnectCentral(); // on mirrors, try to add the field 'recovEmail' to gbMembers. This may or may not work, depending on their config if (sqlFieldIndex(conn, "gbMembers", "recovEmail") == -1) { autoUpgradeTableAddColumn(conn, "gbMembers", "recovEmail", "varchar(255)", FALSE, "''"); } +/* Tells a confirmed recovery address from one that was only typed into the signup form. + * Existing rows default to 'Y': every recovery address that predates this column keeps working + * exactly as before, so no one has to re-confirm an address they set up long ago. Only + * addresses entered from now on have to be confirmed (see sendRecovEmailConfirmMail). */ +if (sqlFieldIndex(conn, "gbMembers", "recovEmailVerified") == -1) + autoUpgradeTableAddColumn(conn, "gbMembers", "recovEmailVerified", "varchar(1)", FALSE, "'Y'"); +recovEmailVerifyOk = (sqlFieldIndex(conn, "gbMembers", "recovEmailVerified") != -1); + // columns for the passwordless email-link login feature if (sqlFieldIndex(conn, "gbMembers", "loginToken") == -1) autoUpgradeTableAddColumn(conn, "gbMembers", "loginToken", "varchar(255)", FALSE, "NULL"); if (sqlFieldIndex(conn, "gbMembers", "loginTokenExpires") == -1) autoUpgradeTableAddColumn(conn, "gbMembers", "loginTokenExpires", "DATETIME", FALSE, "NULL"); // table linking accounts to social (Google/ORCID) identities; only needed where OAuth is set up if (oauthAnyProviderEnabled()) createIdentityTable(conn); cart = theCart; dropRequestSuppliedFlowVars(); safecpy(brwName,sizeof(brwName), browserName()); safecpy(brwAddr,sizeof(brwAddr), browserAddr()); safecpy(signature,sizeof(signature), mailSignature()); @@ -2606,30 +2780,32 @@ emailLinkPage(conn); else if (cartVarExists(cart, "hgLogin.do.sendEmailLink")) sendEmailLink(conn); else if (cartVarExists(cart, "hgLogin.do.emailLogin")) emailLogin(conn); else if (cartVarExists(cart, "hgLogin.do.changePasswordPage")) changePasswordPage(conn); else if (cartVarExists(cart, "hgLogin.do.changePassword")) changePassword(conn); else if (cartVarExists(cart, "hgLogin.do.changeEmailPage")) changeEmailPage(conn); else if (cartVarExists(cart, "hgLogin.do.changeEmail")) changeEmail(conn); else if (cartVarExists(cart, "hgLogin.do.confirmChangeEmail")) confirmChangeEmail(conn); +else if (cartVarExists(cart, "hgLogin.do.confirmRecovEmail")) + confirmRecovEmail(conn); else if (cartVarExists(cart, "hgLogin.do.displayAccHelpPage")) displayAccHelpPage(conn); else if (cartVarExists(cart, "hgLogin.do.accountHelp")) accountHelp(conn); else if (cartVarExists(cart, "hgLogin.do.activateAccount")) activateAccount(conn); else if (cartVarExists(cart, "hgLogin.do.displayActMailSuccess")) displayActMailSuccess(); else if (cartVarExists(cart, "hgLogin.do.displayMailSuccess")) displayMailSuccess(); else if (cartVarExists(cart, "hgLogin.do.displayMailSuccessPwd")) displayMailSuccessPwd(); else if (cartVarExists(cart, "hgLogin.do.displayLoginPage")) displayLoginPage(conn); else if (cartVarExists(cart, "hgLogin.do.displayLogin"))