f85553903a3f87b3029f94e49f0a7d1bb805445b max Mon Aug 3 12:52:33 2026 -0700 hgLogin: configurable OIDC providers, GitHub login, top-level email-link button, sign-in wording. refs #37984 diff --git src/hg/hgLogin/hgLogin.c src/hg/hgLogin/hgLogin.c index fdb8ee643e7..f7cc0fdc79f 100644 --- src/hg/hgLogin/hgLogin.c +++ src/hg/hgLogin/hgLogin.c @@ -52,32 +52,34 @@ 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(); +static void printSocialButtons(boolean dividerAbove, boolean dividerBelow); +static void printEmailLinkButton(); 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)); } @@ -797,31 +799,32 @@ "</span>" "</div>" "\n" "<div class=\"formControls\">" " <input type=\"submit\" name=\"hgLogin.do.displayLogin\" value=\"Login\" class=\"largeButton\">" " <a href=\"%s\">Cancel</a>" "</div>" , getReturnToURL()); if (pwdEyeIconEnabled) { printPwdToggleJS(); jsOnEventById("click", "pwdEyeIcon", "togglePwdVisibility('password','pwdEyeSlash');"); } cartSaveSession(cart); hPrintf("</form>\n"); -printSocialButtons(); +printEmailLinkButton(); +printSocialButtons(TRUE, FALSE); 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); } void activateAccount(struct sqlConnection *conn) /* activate account */ { @@ -1084,34 +1087,36 @@ 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>" - "Already have an account? <a href=\"%s?hgLogin.do.displayLoginPage=1\">Login</a>.<br>" - "\n", hgLoginUrl); -printSocialButtons(); -hPrintf("<h3>Sign Up</h3>" + "\n"); +hPrintf("<a class=\"socialButton\" href=\"%s?hgLogin.do.displayLoginPage=1\">" + "Sign in with username and password</a>", hgLoginUrl); +printEmailLinkButton(); +printSocialButtons(TRUE, TRUE); +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" "<div class=\"inputGroup\">" @@ -1138,31 +1143,31 @@ "</span>" "</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\" class=\"largeButton\"> " + " <input type=\"submit\" name=\"hgLogin.do.signup\" value=\"Sign Up using Email\" class=\"largeButton\"> " " <a href=\"%s\">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) @@ -1500,47 +1505,60 @@ "<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() -/* Print sign-in buttons for any enabled social login providers. Prints nothing if no +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. */ { if (!oauthAnyProviderEnabled()) return; -hPrintf("<div class=\"socialLogin\">" - "<div class=\"orDivider\"><span>or</span></div>"); -if (oauthProviderEnabled(OAUTH_PROVIDER_GOOGLE)) - hPrintf("<a class=\"socialButton\" href=\"%s?hgLogin.do.oauthStart=1&provider=google\">" - "Sign in with Google</a>", hgLoginUrl); -if (oauthProviderEnabled(OAUTH_PROVIDER_ORCID)) - hPrintf("<a class=\"socialButton\" href=\"%s?hgLogin.do.oauthStart=1&provider=orcid\">" - "Sign in with ORCID</a>", hgLoginUrl); +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)); +if (dividerBelow) + hPrintf("<div class=\"orDivider\"><span>or</span></div>"); hPrintf("</div>"); } +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). */ +{ +hPrintf("<a class=\"socialButton\" href=\"%s?hgLogin.do.emailLinkPage=1\">" + "Sign in with an email 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. */ { hPrintf("<h2>%s</h2>", brwName); @@ -1552,31 +1570,31 @@ } static void createIdentityTable(struct sqlConnection *conn) /* Create the gbMemberIdentity table if it does not exist. On a mirror whose central db * is read-only this may fail; social login simply won't work there (and won't be enabled * without client secrets anyway), so ignore any error. */ { if (sqlTableExists(conn, "gbMemberIdentity")) return; struct errCatch *errCatch = errCatchNew(); if (errCatchStart(errCatch)) { struct dyString *dy = sqlDyStringCreate( "CREATE TABLE IF NOT EXISTS gbMemberIdentity (" "idx int unsigned NOT NULL," - "provider varchar(16) NOT NULL," + "provider varchar(64) NOT NULL," "subject varchar(255) NOT NULL," "email varchar(255) NOT NULL default ''," "created DATETIME NOT NULL," "lastUse DATETIME NOT NULL," "UNIQUE KEY provSub (provider, subject)," "INDEX(idx))"); sqlUpdate(conn, dyStringContents(dy)); dyStringFree(&dy); } errCatchEnd(errCatch); errCatchFree(&errCatch); } static boolean userNameTaken(struct sqlConnection *conn, char *userName) /* Return TRUE if userName already exists in gbMembers. */ @@ -1671,31 +1689,31 @@ char *name = cartUsualString(cart, "oauth_pending_name", ""); if (isEmpty(provider)) { 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>", - sameString(provider, OAUTH_PROVIDER_ORCID) ? "ORCID" : "Google", brwName); + 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>" "</div></form></div><!-- END - completeAccountBox -->", getReturnToURL()); @@ -1790,31 +1808,31 @@ if (isEmpty(provider) || isEmpty(email)) { displayLoginPage(conn); return; } char query[512]; 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>"); 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, sameString(provider, OAUTH_PROVIDER_ORCID) ? "ORCID" : "Google"); + 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>" "<label for=\"acct_%u\" class=\"radioLabel\">%s</label></div>", m->userName, 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\">" @@ -1969,30 +1987,51 @@ return; } char *code = cgiUsualString("code", ""); 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. */ +{ +hPrintf("<div id=\"emailLinkBox\" class=\"centeredContainer formBox\">" + "<h2>%s</h2>", brwName); +hPrintf("<h3>Sign in with an email 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>" + "</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"); @@ -2013,31 +2052,31 @@ "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); sendActMailOut(email, subject, msg); } void sendEmailLink(struct sqlConnection *conn) /* Generate and email a one-time passwordless login link to the address on file. */ { char *email = cartUsualString(cart, "hgLogin_email", ""); if (isEmpty(email) || spc_email_isvalid(email) == 0) { freez(&errMsg); errMsg = cloneString("Please enter a valid email address."); - displayAccHelpPage(conn); + 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) { char *token = makeRandomKey(128+33); char *tokenMD5 = generateTokenMD5(token); 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); @@ -2111,30 +2150,32 @@ safecpy(brwAddr,sizeof(brwAddr), browserAddr()); safecpy(signature,sizeof(signature), mailSignature()); safecpy(returnAddr,sizeof(returnAddr), mailReturnAddr()); pwdEyeIconEnabled = cfgOptionBooleanDefault(CFG_LOGIN_PWD_EYE_ICON, TRUE); // A provider's OAuth redirect back to us carries 'code' and 'state' but none of our own // hgLogin.do.* variables, so detect it up front. if (cgiOptionalString("code") != NULL && cgiOptionalString("state") != NULL) oauthReturn(conn); else if (cartVarExists(cart, "hgLogin.do.oauthStart")) oauthStart(conn); else if (cartVarExists(cart, "hgLogin.do.completeAccount")) completeAccount(conn); else if (cartVarExists(cart, "hgLogin.do.chooseAccount")) chooseAccount(conn); +else if (cartVarExists(cart, "hgLogin.do.emailLinkPage")) + 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.displayAccHelpPage")) displayAccHelpPage(conn); else if (cartVarExists(cart, "hgLogin.do.accountHelp"))