4b04bd017d7a36d460447d8552ecaf9c8a33db4a max Tue Aug 4 02:51:56 2026 -0700 hgLogin: gate email-link sign-in and change-email behind login.emailLink (default off), refs #37929 Also: GitHub/OIDC token-format and robustness fixes, signed pending-identity to close an account-takeover hole in the OAuth account chooser, account chooser for the email-link flow, idx-based chooser to avoid a utf8/latin1 collation error on non-ASCII usernames, and login/signup page UI polish (consistent buttons, fonts, cache-busted stylesheet, forgot links, wording). refs #37984 diff --git src/hg/hgLogin/hgLogin.c src/hg/hgLogin/hgLogin.c index f7cc0fdc79f..4ff08174b69 100644 --- src/hg/hgLogin/hgLogin.c +++ src/hg/hgLogin/hgLogin.c @@ -52,32 +52,33 @@ 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() */ /* 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); +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); /* ---- Global helper functions ---- */ char *browserName() /* Return the browser name like 'UCSC Genome Browser' */ { if isEmpty(cfgOption(CFG_LOGIN_BROWSER_NAME)) return cloneString("NULL_browserName"); else return cloneString(cfgOption(CFG_LOGIN_BROWSER_NAME)); @@ -603,59 +604,68 @@ " document.getElementById('usernameBox').style.display='inline';\n" " document.getElementById('emailAddrBox').style.display='none';\n" " }\n" "}\n" ); hPrintf("<div id=\"accountHelpBox\" class=\"centeredContainer formBox\">" "\n" "<h2>%s</h2>" "\n", brwName); hPrintf("<h3>Having trouble signing in?</h3>" "\n" "<form method=post action=\"%s\" name=\"accountLoginForm\" id=\"acctHelpForm\">" "\n" "<p><span style='color:red;'>%s</span><p>" "\n", hgLoginUrl, errMsg ? errMsg : ""); -hPrintf("<div class=\"inputGroup\">" - "<div class=\"acctHelpSection\"><input name=\"hgLogin_helpWith\" type=\"radio\" value=\"password\" id=\"password\">" - "<label for=\"password\" class=\"radioLabel\">I forgot my <b>password</b>. Send me a new one.</label></div>" - "<div class=\"acctHelpSection\"><input name=\"hgLogin_helpWith\" type=\"radio\" value=\"username\" id=\"username\">" - "<label for=\"username\" class=\"radioLabel\">I forgot my <b>username</b>. Please email it to me.</label></div>" - "<div class=\"acctHelpSection\"><input name=\"hgLogin_helpWith\" type=\"radio\" value=\"loginLink\" id=\"loginLink\">" - "<label for=\"loginLink\" class=\"radioLabel\">Email me a <b>login link</b> so I can sign in without a password.</label></div>" - "\n" - "</div>" - "\n"); +// A "Forgot username/password" link may preselect a radio via hgLogin_helpWith in the URL. +char *pre = cartUsualString(cart, "hgLogin_helpWith", ""); +hPrintf("<div class=\"inputGroup\">"); +hPrintf("<div class=\"acctHelpSection\"><input name=\"hgLogin_helpWith\" type=\"radio\" value=\"password\" id=\"password\"%s>" + "<label for=\"password\" class=\"radioLabel\">I forgot my <b>password</b>. Send me a new one.</label></div>", + sameString(pre, "password") ? " checked" : ""); +hPrintf("<div class=\"acctHelpSection\"><input name=\"hgLogin_helpWith\" type=\"radio\" value=\"username\" id=\"username\"%s>" + "<label for=\"username\" class=\"radioLabel\">I forgot my <b>username</b>. Please email it to me.</label></div>", + sameString(pre, "username") ? " checked" : ""); +if (emailLinkEnabled()) + hPrintf("<div class=\"acctHelpSection\"><input name=\"hgLogin_helpWith\" type=\"radio\" value=\"loginLink\" id=\"loginLink\">" + "<label for=\"loginLink\" class=\"radioLabel\">Email me a <b>login link</b> so I can sign in without a password.</label></div>"); +hPrintf("</div>\n"); hPrintf("<div class=\"inputGroup\" id=\"usernameBox\" style=\"display: none;\">" "<label for=\"emailUsername\">Username</label>" "<input type=\"text\" name=\"hgLogin_userName\" value=\"%s\" size=\"30\" id=\"emailUsername\">" "</div>" "\n" "<div class=\"inputGroup\" id=\"emailAddrBox\" style=\"display: none;\">" "<label for=\"emailPassword\">Email address</label>" "<input type=\"text\" name=\"hgLogin_email\" value=\"%s\" size=\"30\" id=\"emailPassword\">" "</div>" "\n" "<div class=\"formControls\">" " <input type=\"submit\" name=\"hgLogin.do.accountHelp\" value=\"Continue\" class=\"largeButton\">" - " <a href=\"%s\">Cancel</a>" + " <a href=\"%s\" class=\"cancelButton\">Cancel</a>" "</div>" "</form>" "</div><!-- END - accountHelpBox -->", username, email, getReturnToURL()); jsOnEventById("click", "password", "toggle('showU');"); jsOnEventById("click", "username", "toggle('showE');"); +if (emailLinkEnabled()) jsOnEventById("click", "loginLink", "toggle('showE');"); +// If a radio was preselected via the URL, open its matching input box on load. +if (sameString(pre, "password")) + jsInline("toggle('showU');"); +else if (sameString(pre, "username") || sameString(pre, "loginLink")) + jsInline("toggle('showE');"); cartSaveSession(cart); } void sendNewPassword(struct sqlConnection *conn, char *username, char *password) /* 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."); @@ -765,76 +775,75 @@ " }\n" "}\n" ); } void displayLoginPage(struct sqlConnection *conn) /* draw the account login page */ { char *username = cartUsualString(cart, "hgLogin_userName", ""); hPrintf("<div id=\"loginBox\" class=\"centeredContainer formBox\">" "\n" "<h2>%s</h2>" "\n", brwName); hPrintf( "<h3>Login</h3>" - "\n"); + "<p>Do not have an account? <a href=\"%s?hgLogin.do.signupPage=1\">Go to the sign up page</a>.</p>" + "\n", hgLoginUrl); if (errMsg && sameString(errMsg, "Your account has been activated.")) hPrintf("<span style='color:green;'>%s</span>\n", errMsg ? errMsg : ""); else hPrintf("<span style='color:red;'>%s</span>\n", errMsg ? errMsg : ""); hPrintf("<form method=post action=\"%s\" name=\"accountLoginForm\" id=\"accountLoginForm\">" "\n" "<div class=\"inputGroup\">" "<label for=\"userName\">Username</label>" "<input type=text name=\"hgLogin_userName\" value=\"%s\" size=\"30\" id=\"userName\">" + "<a class=\"forgotLink\" href=\"%s?hgLogin.do.displayAccHelpPage=1&hgLogin_helpWith=username\">Forgot username</a>" "</div>" "\n" "<div class=\"inputGroup\">" "<label for=\"password\">Password</label>" "<span style=\"position:relative; display:inline-block;\">" "<input type=password name=\"hgLogin_password\" value=\"\" size=\"30\" id=\"password\">" - , hgLoginUrl, username); + , hgLoginUrl, username, hgLoginUrl); printPwdEyeIcon("pwdEyeIcon", "pwdEyeSlash"); hPrintf( "</span>" + "<a class=\"forgotLink\" href=\"%s?hgLogin.do.displayAccHelpPage=1&hgLogin_helpWith=password\">Forgot password</a>" "</div>" "\n" "<div class=\"formControls\">" " <input type=\"submit\" name=\"hgLogin.do.displayLogin\" value=\"Login\" class=\"largeButton\">" - " <a href=\"%s\">Cancel</a>" + " <a href=\"%s\" class=\"cancelButton\">Cancel</a>" "</div>" - , getReturnToURL()); + , hgLoginUrl, getReturnToURL()); if (pwdEyeIconEnabled) { printPwdToggleJS(); jsOnEventById("click", "pwdEyeIcon", "togglePwdVisibility('password','pwdEyeSlash');"); } cartSaveSession(cart); hPrintf("</form>\n"); printEmailLinkButton(); -printSocialButtons(TRUE, FALSE); +printSocialButtons(TRUE, FALSE, "Sign in"); hPrintf( - "<div id=\"helpBox\">" - "<a href=\"%s?hgLogin.do.displayAccHelpPage=1\">Forgot username, password or sign in with an email link</a><br>" - "Need an account? <a href=\"%s?hgLogin.do.signupPage=1\">Sign up</a>.<br>" - "</div><!-- END - helpBox -->" "</div><!-- END - loginBox -->" "\n" "\n" "</body>" - "</html>", hgLoginUrl, hgLoginUrl); + "</html>"); } void activateAccount(struct sqlConnection *conn) /* activate account */ { char query[256]; char *token = cgiUsualString("token", ""); char *username = cgiUsualString("user",""); sqlSafef(query,sizeof(query), "SELECT emailToken FROM gbMembers WHERE userName='%s'", username); char *emailToken = sqlQuickString(conn, query); if (sameString(emailToken, token)) { sqlSafef(query,sizeof(query), "UPDATE gbMembers SET lastUse=NOW(), dateActivated=NOW(), emailToken='', emailTokenExpires='', accountActivated='Y' WHERE userName='%s'", username); @@ -892,31 +901,31 @@ hPrintf( "</span>" "</div>" "\n" "<div class=\"inputGroup\">" "<label for=\"newPw2\">Re-enter New Password</label>" "<span style=\"position:relative; display:inline-block;\">" "<input type=\"password\" name=\"hgLogin_newPassword2\" value=\"\" size=\"30\" id=\"newPw2\">"); printPwdEyeIcon("newPw2EyeIcon", "newPw2EyeSlash"); hPrintf( "</span>" "</div>" "\n" "<div class=\"formControls\">" " <input type=\"submit\" name=\"hgLogin.do.changePassword\" value=\"Change Password\" class=\"largeButton\"> " - " <a href=\"%s\">Cancel</a>" + " <a href=\"%s\" class=\"cancelButton\">Cancel</a>" "\n" "</div>" "</form>" "\n" "</div><!-- END - changePwBox -->" "\n", getReturnToURL()); if (pwdEyeIconEnabled) { printPwdToggleJS(); jsOnEventById("click", "curPwEyeIcon", "togglePwdVisibility('currentPw','curPwEyeSlash');"); jsOnEventById("click", "newPw1EyeIcon", "togglePwdVisibility('newPw1','newPw1EyeSlash');"); jsOnEventById("click", "newPw2EyeIcon", "togglePwdVisibility('newPw2','newPw2EyeSlash');"); } cartSaveSession(cart); } @@ -1007,67 +1016,77 @@ cartRemove(cart, "hgLogin_newPassword1"); cartRemove(cart, "hgLogin_newPassword2"); sqlSafef(query,sizeof(query),"SELECT * FROM gbMembers WHERE userName='%s'", user); struct gbMembers *m = gbMembersLoadByQuery(conn, query); struct dyString *cookieJS = getLoginCookieJS(user, m->idx); jsInline(cookieJS->string); returnToURL(150); } 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. Being logged in is the authorization; no password is required, * which also lets social-login accounts (which have no password) change their email. */ { +if (!emailLinkEnabled()) + { + displayLoginPage(conn); + return; + } char *user = wikiLinkUserName(); if (isEmpty(user)) { freez(&errMsg); errMsg = cloneString("Please log in first to change your email address."); displayLoginPage(conn); return; } char query[256]; sqlSafef(query, sizeof(query), "SELECT email FROM gbMembers WHERE userName='%s'", user); char *curEmail = sqlQuickString(conn, query); hPrintf("<div id=\"changeEmailBox\" class=\"centeredContainer formBox\">" "<h2>%s</h2>", brwName); hPrintf("<h3>Change Email</h3>"); hPrintf("<p><span style='color:red;'>%s</span></p>", errMsg ? errMsg : ""); hPrintf("<form method=\"post\" action=\"%s\" name=\"changeEmailForm\">", hgLoginUrl); hPrintf("<p>Signed in as <b>%s</b>.<br>Current email address: <b>%s</b></p>", user, isNotEmpty(curEmail) ? curEmail : "(none)"); hPrintf("<div class=\"inputGroup\">" "<label for=\"newEmail1\">New email address</label>" "<input type=\"text\" name=\"hgLogin_newEmail1\" value=\"\" size=\"30\" id=\"newEmail1\">" "</div>"); hPrintf("<div class=\"inputGroup\">" "<label for=\"newEmail2\">Re-enter new email address</label>" "<input type=\"text\" name=\"hgLogin_newEmail2\" value=\"\" size=\"30\" id=\"newEmail2\">" "</div>"); hPrintf("<div class=\"formControls\">" "<input type=\"submit\" name=\"hgLogin.do.changeEmail\" value=\"Change Email\" class=\"largeButton\">" - " <a href=\"%s\">Cancel</a>" + " <a href=\"%s\" class=\"cancelButton\">Cancel</a>" "</div></form></div><!-- END - changeEmailBox -->", getReturnToURL()); cartSaveSession(cart); } void changeEmail(struct sqlConnection *conn) /* Process the change-email form for the currently logged-in user. */ { +if (!emailLinkEnabled()) + { + displayLoginPage(conn); + return; + } char *user = wikiLinkUserName(); if (isEmpty(user)) { freez(&errMsg); errMsg = cloneString("Please log in first to change your email address."); displayLoginPage(conn); return; } char *email1 = cartUsualString(cart, "hgLogin_newEmail1", ""); char *email2 = cartUsualString(cart, "hgLogin_newEmail2", ""); if (isEmpty(email1) || spc_email_isvalid(email1) == 0) { freez(&errMsg); errMsg = cloneString("Please enter a valid email address."); changeEmailPage(conn); @@ -1086,36 +1105,35 @@ sqlUpdate(conn, query); cartRemove(cart, "hgLogin_newEmail1"); cartRemove(cart, "hgLogin_newEmail2"); hPrintf("<div class=\"centeredContainer formBox\"><h2>%s</h2>", brwName); hPrintf("<h3>Your email address has been changed.</h3>"); hPrintf("<p>Your email address is now <b>%s</b>.</p></div>", email1); returnToURL(1500); } void signupPage(struct sqlConnection *conn) /* draw the signup page */ { hPrintf("<div id=\"signUpBox\" class=\"centeredContainer formBox\">" "<h2>%s</h2>", brwName); hPrintf( - "<p>Signing up enables you to save multiple sessions and to share your sessions with others.</p>" + "<p>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.</p>" "\n"); -hPrintf("<a class=\"socialButton\" href=\"%s?hgLogin.do.displayLoginPage=1\">" - "Sign in with username and password</a>", hgLoginUrl); -printEmailLinkButton(); -printSocialButtons(TRUE, TRUE); +hPrintf("<p>Already have an account? " + "<a href=\"%s?hgLogin.do.displayLoginPage=1\">Go to the login page</a>.</p>", hgLoginUrl); +printSocialButtons(FALSE, TRUE, "Sign up"); hPrintf("<h3>Sign Up Using Email</h3>" "<form method=\"post\" action=\"%s\" name=\"mainForm\">" "<span style='color:red;'>%s</span>" "\n", hgLoginUrl, errMsg ? errMsg : ""); printUsernameNote(); hPrintf("<div class=\"inputGroup\">" "<label for=\"userName\">Username</label>" "<input type=text name=\"hgLogin_userName\" value=\"%s\" size=\"30\" id=\"userName\">" "</div>" "\n" "<div class=\"inputGroup\">" "<label for=\"emailAddr\">Email address</label>" "<input type=text name=\"hgLogin_email\" value=\"%s\" size=\"30\" id=\"emailAddr\">" "</div>" "\n" @@ -1144,31 +1162,31 @@ "</div>" "\n" "<div class=\"inputGroup\">" "<label for=\"passwordCheck\">Re-enter Password</label>" "<span style=\"position:relative; display:inline-block;\">" "<input type=password name=\"hgLogin_password2\" value=\"%s\" size=\"30\" id=\"passwordCheck\">", cartUsualString(cart, "hgLogin_password2", "")); printPwdEyeIcon("signupPwCheckEyeIcon", "signupPwCheckEyeSlash"); hPrintf( "</span>" "\n" "</div>" "\n" "<div class=\"formControls\">" " <input type=\"submit\" name=\"hgLogin.do.signup\" value=\"Sign Up using Email\" class=\"largeButton\"> " - " <a href=\"%s\">Cancel</a>" + " <a href=\"%s\" class=\"cancelButton\">Cancel</a>" "</div>" "</form>" "</div><!-- END - signUpBox -->", getReturnToURL()); if (pwdEyeIconEnabled) { printPwdToggleJS(); jsOnEventById("click", "signupPwEyeIcon", "togglePwdVisibility('password','signupPwEyeSlash');"); jsOnEventById("click", "signupPwCheckEyeIcon", "togglePwdVisibility('passwordCheck','signupPwCheckEyeSlash');"); } cartSaveSession(cart); } void signup(struct sqlConnection *conn) /* process the signup form */ @@ -1505,58 +1523,65 @@ "<p align=\"left\">" "</p>" "<span style='color:red;'></span>" "\n"); struct dyString *javascript = dyStringNew(1024); struct slName *newCookies = loginLogoutUser(), *sl; for (sl = newCookies; sl != NULL; sl = sl->next) dyStringPrintf(javascript, " document.cookie = '%s';", sl->name); jsInline(javascript->string); /* return to "returnto" URL */ returnToURL(150); } /* ---- Social login (OAuth) and passwordless email-link login ---- */ -static void printSocialButtons(boolean dividerAbove, boolean dividerBelow) -/* Print sign-in buttons for any enabled social login providers, optionally bracketed by "or" - * dividers. The login page uses only the top divider (separating the buttons from the - * password form above); the signup page uses both, so the buttons sit in their own section - * between the "already have an account" link and the email signup form. Prints nothing if no - * provider is configured, so mirrors without OAuth credentials are unaffected. */ +static void printSocialButtons(boolean dividerAbove, boolean dividerBelow, char *action) +/* Print social login buttons for any enabled providers, optionally bracketed by "or" + * dividers. action is the button verb ("Sign in" on the login page, "Sign up" on the signup + * page). Prints nothing if no provider is configured, so mirrors without OAuth credentials + * are unaffected. */ { if (!oauthAnyProviderEnabled()) return; hPrintf("<div class=\"socialLogin\">"); if (dividerAbove) hPrintf("<div class=\"orDivider\"><span>or</span></div>"); struct slName *prov, *providers = oauthProviderNames(); for (prov = providers; prov != NULL; prov = prov->next) hPrintf("<a class=\"socialButton\" href=\"%s?hgLogin.do.oauthStart=1&provider=%s\">" - "Sign in with %s</a>", - hgLoginUrl, cgiEncode(prov->name), oauthProviderLabel(prov->name)); + "%s with %s</a>", + hgLoginUrl, cgiEncode(prov->name), action, oauthProviderLabel(prov->name)); if (dividerBelow) hPrintf("<div class=\"orDivider\"><span>or</span></div>"); hPrintf("</div>"); } +static boolean emailLinkEnabled() +/* Return TRUE if passwordless email-link login is turned on in hg.conf. It needs working + * outbound email, so it is off unless the admin explicitly enables it with login.emailLink=on. */ +{ +return cfgOptionBooleanDefault(CFG_LOGIN_EMAIL_LINK, FALSE); +} + static void printEmailLinkButton() -/* Print a grey button that opens the passwordless email-link login page. Always available - * (this is core hgLogin functionality, independent of any OAuth configuration). */ +/* Print a grey button that opens the passwordless email-link login page, if enabled. */ { +if (!emailLinkEnabled()) + return; hPrintf("<a class=\"socialButton\" href=\"%s?hgLogin.do.emailLinkPage=1\">" - "Sign in with an email link</a>", hgLoginUrl); + "Email me a sign-in link</a>", 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("<p style=\"font-size:0.9em\">Note: your username becomes part of every short link " "you create later (for example <code>%s/s/<b>username</b>/MySession</code>), so choose " "something short and easy to type.</p>", brwAddr); } static void loginAndReturn(char *userName, uint idx) /* Set the permanent login cookies for userName and bounce back to the returnto URL. * Every login method (password, social, email link) funnels through here, so they all * produce the same long-lived login cookies. */ @@ -1637,107 +1662,140 @@ static struct gbMembers *memberForIdentity(struct sqlConnection *conn, struct oauthIdentity *id) /* Return the gbMembers account already linked to this provider identity, or NULL. */ { char query[512]; sqlSafef(query, sizeof(query), "SELECT idx FROM gbMemberIdentity WHERE provider='%s' AND subject='%s'", id->provider, id->subject); uint idx = (uint)sqlQuickLongLong(conn, query); if (idx == 0) return NULL; sqlSafef(query, sizeof(query), "SELECT * FROM gbMembers WHERE idx=%u", idx); return gbMembersLoadByQuery(conn, query); } +static char *oauthPendingSig(char *provider, char *subject, char *email) +/* Signature over a pending social identity, keyed by the secret login.cookieSalt. Only + * resolveIdentity (which runs after a genuine provider verification) can produce a valid one, + * so a pending identity injected through cart/CGI variables will not validate. (Not bound to + * the session id: the hgsid is regenerated across the provider redirect, so a session-bound + * signature would never match on the way back.) Result is allocd. */ +{ +char buf[1024]; +safef(buf, sizeof(buf), "%s|%s|%s|%s", + emptyForNull(cfgOption(CFG_LOGIN_COOKIE_SALT)), + emptyForNull(provider), emptyForNull(subject), emptyForNull(email)); +return generateTokenMD5(buf); +} + +static boolean pendingIdentityValid() +/* TRUE only if the pending-identity cart variables carry a signature we minted this session. + * Guards the OAuth chooser and completeAccount against forged/injected pending identities. */ +{ +char *sig = cartUsualString(cart, "oauth_pending_sig", ""); +if (isEmpty(sig)) + return FALSE; +char *expected = oauthPendingSig(cartUsualString(cart, "oauth_pending_provider", ""), + cartUsualString(cart, "oauth_pending_subject", ""), + cartUsualString(cart, "oauth_pending_email", "")); +boolean ok = sameString(sig, expected); +freeMem(expected); +return ok; +} + static void setPendingIdentity(struct oauthIdentity *id) /* Stash an authenticated-but-not-yet-linked identity in the cart so it survives a form - * round-trip (the "choose a username" or "choose an account" page). */ + * round-trip (the "choose a username" or "choose an account" page). The signature is what + * proves, on the way back, that we really verified this identity. */ { cartSetString(cart, "oauth_pending_provider", id->provider); cartSetString(cart, "oauth_pending_subject", id->subject); cartSetString(cart, "oauth_pending_email", emptyForNull(id->email)); cartSetString(cart, "oauth_pending_name", emptyForNull(id->displayName)); +cartSetString(cart, "oauth_pending_sig", + oauthPendingSig(id->provider, id->subject, emptyForNull(id->email))); } static void clearPendingIdentity() /* Remove the pending-identity cart variables once the account is linked. */ { cartRemove(cart, "oauth_pending_provider"); cartRemove(cart, "oauth_pending_subject"); cartRemove(cart, "oauth_pending_email"); cartRemove(cart, "oauth_pending_name"); +cartRemove(cart, "oauth_pending_sig"); } static void linkIdentity(struct sqlConnection *conn, uint idx, struct oauthIdentity *id) /* Insert or refresh the gbMemberIdentity row linking idx to this provider identity. */ { char query[1024]; char *email = emptyForNull(id->email); sqlSafef(query, sizeof(query), "INSERT INTO gbMemberIdentity SET idx=%u, provider='%s', subject='%s', email='%s', " "created=NOW(), lastUse=NOW() " "ON DUPLICATE KEY UPDATE idx=%u, email='%s', lastUse=NOW()", idx, id->provider, id->subject, email, idx, email); sqlUpdate(conn, query); } void completeAccountPage(struct sqlConnection *conn) /* Ask a first-time social-login user to confirm a username (and email) for a new account. */ { char *provider = cartUsualString(cart, "oauth_pending_provider", ""); char *email = cartUsualString(cart, "oauth_pending_email", ""); char *name = cartUsualString(cart, "oauth_pending_name", ""); -if (isEmpty(provider)) +if (isEmpty(provider) || !pendingIdentityValid()) { displayLoginPage(conn); return; } char *suggested = cartUsualString(cart, "hgLogin_userName", ""); if (isEmpty(suggested)) suggested = suggestUsername(conn, email, name); hPrintf("<div id=\"completeAccountBox\" class=\"centeredContainer formBox\">" "<h2>%s</h2>", brwName); hPrintf("<h3>Choose a username</h3>"); hPrintf("<p>You signed in with %s. Pick a username for your new %s account. " "You can change the suggested name below.</p>", oauthProviderLabel(provider), brwName); printUsernameNote(); hPrintf("<span style='color:red;'>%s</span>", errMsg ? errMsg : ""); hPrintf("<form method=\"post\" action=\"%s\" name=\"completeAccountForm\">", hgLoginUrl); hPrintf("<div class=\"inputGroup\">" "<label for=\"userName\">Username</label>" "<input type=\"text\" name=\"hgLogin_userName\" value=\"%s\" size=\"30\" id=\"userName\">" "</div>", suggested); hPrintf("<div class=\"inputGroup\">" "<label for=\"emailAddr\">Email address</label>" "<input type=\"text\" name=\"hgLogin_email\" value=\"%s\" size=\"30\" id=\"emailAddr\">" "</div>", email); hPrintf("<div class=\"formControls\">" "<input type=\"submit\" name=\"hgLogin.do.completeAccount\" value=\"Create Account\" class=\"largeButton\">" - " <a href=\"%s\">Cancel</a>" + " <a href=\"%s\" class=\"cancelButton\">Cancel</a>" "</div></form></div><!-- END - completeAccountBox -->", getReturnToURL()); cartSaveSession(cart); } void completeAccount(struct sqlConnection *conn) /* Create the account for a first-time social-login user, link the identity, and log in. */ { char *provider = cartUsualString(cart, "oauth_pending_provider", ""); char *subject = cartUsualString(cart, "oauth_pending_subject", ""); -if (isEmpty(provider) || isEmpty(subject)) +if (isEmpty(provider) || isEmpty(subject) || !pendingIdentityValid()) { freez(&errMsg); errMsg = cloneString("Your login session expired. Please sign in again."); displayLoginPage(conn); return; } char *user = cartUsualString(cart, "hgLogin_userName", ""); char *encUserName = cgiEncodeFull(user); if (isEmpty(user)) { freez(&errMsg); errMsg = cloneString("Please enter a username."); completeAccountPage(conn); return; } @@ -1788,107 +1846,161 @@ 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(); loginAndReturn(user, idx); } void chooseAccountPage(struct sqlConnection *conn) -/* When a verified OAuth email matches several accounts, ask the user which one to sign - * in to (and link this social identity to). */ +/* 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", ""); -char *email = cartUsualString(cart, "oauth_pending_email", ""); -if (isEmpty(provider) || isEmpty(email)) +boolean emailMode = isEmpty(provider); +char *email = emailMode ? cartUsualString(cart, "emailLogin_email", "") + : cartUsualString(cart, "oauth_pending_email", ""); +if (isEmpty(email) || (!emailMode && !pendingIdentityValid())) { displayLoginPage(conn); return; } char query[512]; +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' " + "AND loginToken<>'' AND loginTokenExpires > NOW() ORDER BY idx", + email, email, cartUsualString(cart, "emailLogin_tokenMd5", "")); +else sqlSafef(query, sizeof(query), "SELECT * FROM gbMembers WHERE email='%s' ORDER BY idx", email); struct gbMembers *list = gbMembersLoadByQuery(conn, query), *m; hPrintf("<div id=\"chooseAccountBox\" class=\"centeredContainer formBox\">" "<h2>%s</h2>", brwName); hPrintf("<h3>Choose an account</h3>"); +if (emailMode) + hPrintf("<p>The email address <b>%s</b> is associated with more than one %s account. " + "Select the account you would like to sign in to.</p>", email, brwName); +else hPrintf("<p>The email address <b>%s</b> 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.</p>", email, brwName, oauthProviderLabel(provider)); hPrintf("<span style='color:red;'>%s</span>", errMsg ? errMsg : ""); hPrintf("<form method=\"post\" action=\"%s\" name=\"chooseAccountForm\">", hgLoginUrl); hPrintf("<div class=\"inputGroup\">"); boolean first = TRUE; for (m = list; m != NULL; m = m->next) { hPrintf("<div class=\"acctHelpSection\">" - "<input name=\"hgLogin_chosenUser\" type=\"radio\" value=\"%s\" id=\"acct_%u\"%s>" + "<input name=\"hgLogin_chosenIdx\" type=\"radio\" value=\"%u\" id=\"acct_%u\"%s>" "<label for=\"acct_%u\" class=\"radioLabel\">%s</label></div>", - m->userName, m->idx, first ? " checked" : "", m->idx, m->userName); + m->idx, m->idx, first ? " checked" : "", m->idx, m->userName); first = FALSE; } hPrintf("</div>"); hPrintf("<div class=\"formControls\">" "<input type=\"submit\" name=\"hgLogin.do.chooseAccount\" value=\"Sign In\" class=\"largeButton\">" - " <a href=\"%s\">Cancel</a>" + " <a href=\"%s\" class=\"cancelButton\">Cancel</a>" "</div></form></div><!-- END - chooseAccountBox -->", getReturnToURL()); cartSaveSession(cart); gbMembersFreeList(&list); } void chooseAccount(struct sqlConnection *conn) -/* Link the pending OAuth identity to the account the user picked, then log in. */ +/* 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]; + +if (isEmpty(provider)) + { + /* Passwordless email-link mode. */ + 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; + } + sqlSafef(query, sizeof(query), + "SELECT * FROM gbMembers WHERE idx=%d AND (email='%s' OR recovEmail='%s') " + "AND loginToken='%s' AND loginToken<>'' AND loginTokenExpires > NOW()", + 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. */ + sqlSafef(query, sizeof(query), + "UPDATE gbMembers SET loginToken='' WHERE (email='%s' OR recovEmail='%s') AND loginToken='%s'", + email, email, tokenMd5); + sqlUpdate(conn, query); + cartRemove(cart, "emailLogin_email"); + cartRemove(cart, "emailLogin_tokenMd5"); + cartRemove(cart, "hgLogin_chosenIdx"); + loginAndReturn(m->userName, m->idx); + gbMembersFree(&m); + return; + } + +/* OAuth mode. */ char *subject = cartUsualString(cart, "oauth_pending_subject", ""); char *email = cartUsualString(cart, "oauth_pending_email", ""); -char *chosen = cartUsualString(cart, "hgLogin_chosenUser", ""); -if (isEmpty(provider) || isEmpty(subject) || isEmpty(email)) +if (isEmpty(subject) || isEmpty(email) || !pendingIdentityValid()) { freez(&errMsg); errMsg = cloneString("Your login session expired. Please sign in again."); displayLoginPage(conn); return; } -/* Security: only allow linking to an account that really shares the verified email, - * not an arbitrary username the client might submit. */ -char query[512]; sqlSafef(query, sizeof(query), - "SELECT * FROM gbMembers WHERE userName='%s' AND email='%s'", chosen, email); + "SELECT * FROM gbMembers WHERE idx=%d AND email='%s'", chosenIdx, email); 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(); -cartRemove(cart, "hgLogin_chosenUser"); +cartRemove(cart, "hgLogin_chosenIdx"); loginAndReturn(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.) */ @@ -1990,149 +2102,178 @@ struct oauthIdentity *id = oauthFetchIdentity(provider, code, hgLoginUrl); if (id == NULL) { freez(&errMsg); errMsg = cloneString("We could not complete the social login. Please try again."); displayLoginPage(conn); return; } resolveIdentity(conn, id); oauthIdentityFree(&id); } void emailLinkPage(struct sqlConnection *conn) /* Standalone page that asks for an email address and sends a one-time login link. */ { +if (!emailLinkEnabled()) + { + displayLoginPage(conn); + return; + } hPrintf("<div id=\"emailLinkBox\" class=\"centeredContainer formBox\">" "<h2>%s</h2>", brwName); -hPrintf("<h3>Sign in with an email link</h3>"); +hPrintf("<h3>Email me a sign-in link</h3>"); hPrintf("<p>Enter your email address and we'll send you a link that signs you in without a " "password. This is handy on a computer where you don't have your password saved.</p>"); hPrintf("<span style='color:red;'>%s</span>", errMsg ? errMsg : ""); hPrintf("<form method=\"post\" action=\"%s\" name=\"emailLinkForm\">", hgLoginUrl); hPrintf("<div class=\"inputGroup\">" "<label for=\"emailLink\">Email address</label>" "<input type=\"text\" name=\"hgLogin_email\" value=\"%s\" size=\"30\" id=\"emailLink\">" "</div>", cartUsualString(cart, "hgLogin_email", "")); hPrintf("<div class=\"formControls\">" "<input type=\"submit\" name=\"hgLogin.do.sendEmailLink\" value=\"Send login link\" class=\"largeButton\">" - " <a href=\"%s\">Cancel</a>" + " <a href=\"%s\" class=\"cancelButton\">Cancel</a>" "</div></form></div><!-- END - emailLinkBox -->", getReturnToURL()); cartSaveSession(cart); } void displayLoginLinkSuccess() /* Confirmation shown after a passwordless login link is (possibly) emailed. Phrased so it * does not reveal whether an account exists for the address. */ { char *email = cartUsualString(cart, "hgLogin_sendMailTo", ""); hPrintf("<div id=\"confirmationBox\" class=\"centeredContainer formBox\">" "<h2>%s</h2>", brwName); hPrintf("<p id=\"confirmationMsg\" class=\"confirmationTxt\">If an account exists for " "<B>%s</B>, a login link has been sent to that address.<BR><BR>" "Click the link in that email to sign in — no password needed. " "The link works once and expires in one hour.</p>", email); hPrintf("<p>If you don't see the email, please check your spam folder.</p>"); hPrintf("<p><a href=\"%s?hgLogin.do.displayLoginPage=1\">Return to Login</a></p>\n", hgLoginUrl); cartRemove(cart, "hgLogin_email"); cartRemove(cart, "hgLogin_sendMailTo"); cartRemove(cart, "hgLogin_helpWith"); } -void sendLoginLinkMail(char *username, char *email, char *token) -/* Email a one-time passwordless login link to the user. */ +void sendLoginLinkMail(char *email, char *token) +/* Email a one-time passwordless login link to an address. The link identifies the address, + * not a single account: if the address has several accounts, the user picks one after + * clicking (see emailLogin), so one email covers them all. */ { char subject[256]; char msg[4096]; char url[512]; char *remoteAddr = getenv("REMOTE_ADDR"); -safef(url, sizeof(url), "%s?hgLogin.do.emailLogin=1&user=%s&token=%s", - hgLoginUrl, cgiEncode(username), cgiEncode(token)); +safef(url, sizeof(url), "%s?hgLogin.do.emailLogin=1&email=%s&token=%s", + hgLoginUrl, cgiEncode(email), cgiEncode(token)); safef(subject, sizeof(subject), "Your login link for the %s", brwName); safef(msg, sizeof(msg), "Someone (probably you, from IP address %s) requested a login link for the %s account " - "\"%s\".\nClick the link below to sign in without a password. It works once and expires " - "in one hour:\n\n%s\n\nIf you did not request this, you can safely ignore this email.\n\n%s\n%s", - remoteAddr, brwName, username, url, signature, returnAddr); + "registered to this email address.\nClick the link below to sign in without a password. " + "It works once and expires in one hour:\n\n%s\n\nIf you did not request this, you can " + "safely ignore this email.\n\n%s\n%s", + remoteAddr, brwName, url, signature, returnAddr); sendActMailOut(email, subject, msg); } void sendEmailLink(struct sqlConnection *conn) /* 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]; sqlSafef(query, sizeof(query), "SELECT * FROM gbMembers WHERE email='%s' OR recovEmail='%s'", email, email); struct gbMembers *list = gbMembersLoadByQuery(conn, query), *m; -for (m = list; m != NULL; m = m->next) +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); sqlUpdate(conn, query); - sendLoginLinkMail(m->userName, email, token); } + sendLoginLinkMail(email, token); + } +gbMembersFreeList(&list); /* Always show the same confirmation, even when no account matched, so we don't reveal * whether an address is registered. */ cartSetString(cart, "hgLogin_sendMailTo", email); displayLoginLinkSuccess(); } void emailLogin(struct sqlConnection *conn) -/* Validate a one-time email login token and log the user in. */ +/* 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()) { -char *user = cgiUsualString("user", ""); + displayLoginPage(conn); + return; + } +char *email = cgiUsualString("email", ""); char *token = cgiUsualString("token", ""); +char *tokenMD5 = generateTokenMD5(token); char query[512]; -sqlSafef(query, sizeof(query), "SELECT * FROM gbMembers WHERE userName='%s'", user); -struct gbMembers *m = gbMembersLoadByQuery(conn, query); -if (m == NULL) +sqlSafef(query, sizeof(query), + "SELECT * FROM gbMembers WHERE (email='%s' OR recovEmail='%s') AND loginToken='%s' " + "AND loginToken<>'' AND loginTokenExpires > NOW() 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); - return; } -char *tokenMD5 = generateTokenMD5(token); -sqlSafef(query, sizeof(query), - "SELECT count(*) FROM gbMembers WHERE userName='%s' AND loginToken='%s' " - "AND loginToken<>'' AND loginTokenExpires > NOW()", user, tokenMD5); -if (sqlQuickNum(conn, query) == 1) +else if (n == 1) { sqlSafef(query, sizeof(query), - "UPDATE gbMembers SET loginToken='', lastUse=NOW() WHERE userName='%s'", user); + "UPDATE gbMembers SET loginToken='', lastUse=NOW() WHERE idx=%u", list->idx); sqlUpdate(conn, query); - loginAndReturn(m->userName, m->idx); + loginAndReturn(list->userName, list->idx); } else { - freez(&errMsg); - errMsg = cloneString("This login link is invalid or has expired. Please request a new one."); - displayLoginPage(conn); + /* 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); } -gbMembersFree(&m); +gbMembersFreeList(&list); } 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, "''"); } // columns for the passwordless email-link login feature @@ -2209,29 +2350,31 @@ errAbort( "hgLogin - Stand alone CGI to handle Genome Browser login.\n" "usage:\n" " hgLogin <various CGI settings>\n" ); } int main(int argc, char *argv[]) /* Process command line. */ { long enteredMainTime = clock1000(); earlyBotCheck(enteredMainTime, "hgLogin", delayFraction, 0, 0, "html"); pushCarefulMemHandler(100000000); cgiSpoof(&argc, argv); -htmlSetStyleSheet("../style/userAccounts.css"); +/* Use the site's standard time-stamped resource link (appends ?v=<mtime>) so browsers pick + * up CSS changes after a release instead of serving a stale cached copy. */ +htmlSetStyleSheet(webTimeStampedLinkToResource("userAccounts.css", FALSE)); htmlSetStyle(htmlStyleUndecoratedLink); htmlSetBgColor(HG_CL_OUTSIDE); htmlSetFormClass("accountScreen"); struct dyString *dy; dy = dyStringCreate("%shgLogin", hLoginHostCgiBinUrl()); hgLoginUrl = dyStringCannibalize(&dy); oldCart = hashNew(10); cartHtmlShell("Login - UCSC Genome Browser", doMiddle, hUserCookie(), excludeVars, oldCart); cgiExitTime("hgLogin", enteredMainTime); return 0; }