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.
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",
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.
"
""
@@ -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.
", 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"))