diff --git a/api/errors.go b/api/errors.go index d044fdb..14b0d7c 100644 --- a/api/errors.go +++ b/api/errors.go @@ -46,3 +46,4 @@ var errorDatabaseMigration = errors.New("Encountered error applying database mig var errorNoSuchUnsubscribeSecretHex = errors.New("Invalid unsubscribe link.") var errorEmptyPaths = errors.New("Empty paths field.") var errorInvalidDomain = errors.New("Invalid domain name. Do not include the URL path after the forward slash.") +var errorInvalidEntity = errors.New("That entity does not exist.") diff --git a/api/forgot.go b/api/forgot.go new file mode 100644 index 0000000..ab69d36 --- /dev/null +++ b/api/forgot.go @@ -0,0 +1,97 @@ +package main + +import ( + "net/http" + "time" +) + +func forgot(email string, entity string) error { + if email == "" { + return errorMissingField + } + + if entity != "owner" && entity != "commenter" { + return errorInvalidEntity + } + + if !smtpConfigured { + return errorSmtpNotConfigured + } + + var hex string + var name string + if entity == "owner" { + o, err := ownerGetByEmail(email) + if err != nil { + if err == errorNoSuchEmail { + // TODO: use a more random time instead. + time.Sleep(1 * time.Second) + return nil + } else { + logger.Errorf("cannot get owner by email: %v", err) + return errorInternal + } + } + hex = o.OwnerHex + name = o.Name + } else { + c, err := commenterGetByEmail("commento", email) + if err != nil { + if err == errorNoSuchEmail { + // TODO: use a more random time instead. + time.Sleep(1 * time.Second) + return nil + } else { + logger.Errorf("cannot get commenter by email: %v", err) + return errorInternal + } + } + hex = c.CommenterHex + name = c.Name + } + + resetHex, err := randomHex(32) + if err != nil { + return err + } + + var statement string + + statement = ` + INSERT INTO + resetHexes (resetHex, hex, entity, sendDate) + VALUES ($1, $2, $3, $4 ); + ` + _, err = db.Exec(statement, resetHex, hex, entity, time.Now().UTC()) + if err != nil { + logger.Errorf("cannot insert resetHex: %v", err) + return errorInternal + } + + err = smtpResetHex(email, name, resetHex) + if err != nil { + return err + } + + return nil +} + +func forgotHandler(w http.ResponseWriter, r *http.Request) { + type request struct { + Email *string `json:"email"` + Entity *string `json:"entity"` + } + + var x request + if err := bodyUnmarshal(r, &x); err != nil { + bodyMarshal(w, response{"success": false, "message": err.Error()}) + return + } + + if err := forgot(*x.Email, *x.Entity); err != nil { + bodyMarshal(w, response{"success": false, "message": err.Error()}) + return + } + + bodyMarshal(w, response{"success": true}) +} diff --git a/api/owner_reset_hex.go b/api/owner_reset_hex.go deleted file mode 100644 index 62b5506..0000000 --- a/api/owner_reset_hex.go +++ /dev/null @@ -1,70 +0,0 @@ -package main - -import ( - "net/http" - "time" -) - -func ownerSendResetHex(email string) error { - if email == "" { - return errorMissingField - } - - if !smtpConfigured { - return errorSmtpNotConfigured - } - - o, err := ownerGetByEmail(email) - if err != nil { - if err == errorNoSuchEmail { - // TODO: use a more random time instead. - time.Sleep(1 * time.Second) - return nil - } else { - logger.Errorf("cannot get owner by email: %v", err) - return errorInternal - } - } - - resetHex, err := randomHex(32) - if err != nil { - return err - } - - statement := ` - INSERT INTO - ownerResetHexes (resetHex, ownerHex, sendDate) - VALUES ($1, $2, $3 ); - ` - _, err = db.Exec(statement, resetHex, o.OwnerHex, time.Now().UTC()) - if err != nil { - logger.Errorf("cannot insert resetHex: %v", err) - return errorInternal - } - - err = smtpOwnerResetHex(email, o.Name, resetHex) - if err != nil { - return err - } - - return nil -} - -func ownerSendResetHexHandler(w http.ResponseWriter, r *http.Request) { - type request struct { - Email *string `json:"email"` - } - - var x request - if err := bodyUnmarshal(r, &x); err != nil { - bodyMarshal(w, response{"success": false, "message": err.Error()}) - return - } - - if err := ownerSendResetHex(*x.Email); err != nil { - bodyMarshal(w, response{"success": false, "message": err.Error()}) - return - } - - bodyMarshal(w, response{"success": true}) -} diff --git a/api/owner_reset_password.go b/api/owner_reset_password.go deleted file mode 100644 index 10d6474..0000000 --- a/api/owner_reset_password.go +++ /dev/null @@ -1,73 +0,0 @@ -package main - -import ( - "golang.org/x/crypto/bcrypt" - "net/http" -) - -func ownerResetPassword(resetHex string, password string) error { - if resetHex == "" || password == "" { - return errorMissingField - } - - passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) - if err != nil { - logger.Errorf("cannot generate hash from password: %v\n", err) - return errorInternal - } - - statement := ` - UPDATE owners SET passwordHash=$1 - WHERE ownerHex = ( - SELECT ownerHex - FROM ownerResetHexes - WHERE resetHex=$2 - ); - ` - res, err := db.Exec(statement, string(passwordHash), resetHex) - if err != nil { - logger.Errorf("cannot change user's password: %v\n", err) - return errorInternal - } - - count, err := res.RowsAffected() - if err != nil { - logger.Errorf("cannot count rows affected: %v\n", err) - return errorInternal - } - - if count == 0 { - return errorNoSuchResetToken - } - - statement = ` - DELETE FROM ownerResetHexes - WHERE resetHex=$1; - ` - _, err = db.Exec(statement, resetHex) - if err != nil { - logger.Warningf("cannot remove reset token: %v\n", err) - } - - return nil -} - -func ownerResetPasswordHandler(w http.ResponseWriter, r *http.Request) { - type request struct { - ResetHex *string `json:"resetHex"` - Password *string `json:"password"` - } - - var x request - if err := bodyUnmarshal(r, &x); err != nil { - bodyMarshal(w, response{"success": false, "message": err.Error()}) - return - } - - if err := ownerResetPassword(*x.ResetHex, *x.Password); err != nil { - bodyMarshal(w, response{"success": false, "message": err.Error()}) - return - } - - bodyMarshal(w, response{"success": true}) -} diff --git a/api/owner_reset_password_test.go b/api/owner_reset_password_test.go deleted file mode 100644 index 8c11a12..0000000 --- a/api/owner_reset_password_test.go +++ /dev/null @@ -1,40 +0,0 @@ -package main - -import ( - "testing" - "time" -) - -func TestOwnerResetPasswordBasics(t *testing.T) { - failTestOnError(t, setupTestEnv()) - - ownerHex, _ := ownerNew("test@example.com", "Test", "hunter2") - - resetHex, _ := randomHex(32) - - statement := ` - INSERT INTO - ownerResetHexes (resetHex, ownerHex, sendDate) - VALUES ($1, $2, $3 ); - ` - _, err := db.Exec(statement, resetHex, ownerHex, time.Now().UTC()) - if err != nil { - t.Errorf("unexpected error inserting resetHex: %v", err) - return - } - - if err = ownerResetPassword(resetHex, "hunter3"); err != nil { - t.Errorf("unexpected error resetting password: %v", err) - return - } - - if _, err := ownerLogin("test@example.com", "hunter2"); err == nil { - t.Errorf("expected error not found when given old password") - return - } - - if _, err := ownerLogin("test@example.com", "hunter3"); err != nil { - t.Errorf("unexpected error when logging in: %v", err) - return - } -} diff --git a/api/reset.go b/api/reset.go new file mode 100644 index 0000000..1c8e947 --- /dev/null +++ b/api/reset.go @@ -0,0 +1,82 @@ +package main + +import ( + "golang.org/x/crypto/bcrypt" + "net/http" +) + +func reset(resetHex string, password string) (string, error) { + if resetHex == "" || password == "" { + return "", errorMissingField + } + + statement := ` + SELECT hex, entity + FROM resetHexes + WHERE resetHex = $1; + ` + row := db.QueryRow(statement, resetHex) + + var hex string + var entity string + if err := row.Scan(&hex, &entity); err != nil { + // TODO: is this the only error? + return "", errorNoSuchResetToken + } + + passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + logger.Errorf("cannot generate hash from password: %v\n", err) + return "", errorInternal + } + + if entity == "owner" { + statement = ` + UPDATE owners SET passwordHash = $1 + WHERE ownerHex = $2; + ` + } else { + statement = ` + UPDATE commenters SET passwordHash = $1 + WHERE commenterHex = $2; + ` + } + + _, err = db.Exec(statement, string(passwordHash), hex) + if err != nil { + logger.Errorf("cannot change %s's password: %v\n", entity, err) + return "", errorInternal + } + + statement = ` + DELETE FROM resetHexes + WHERE resetHex = $1; + ` + _, err = db.Exec(statement, resetHex) + if err != nil { + logger.Warningf("cannot remove resetHex: %v\n", err) + } + + return entity, nil +} + +func resetHandler(w http.ResponseWriter, r *http.Request) { + type request struct { + ResetHex *string `json:"resetHex"` + Password *string `json:"password"` + } + + var x request + if err := bodyUnmarshal(r, &x); err != nil { + bodyMarshal(w, response{"success": false, "message": err.Error()}) + return + } + + entity, err := reset(*x.ResetHex, *x.Password) + if err != nil { + bodyMarshal(w, response{"success": false, "message": err.Error()}) + return + } + + bodyMarshal(w, response{"success": true, "entity": entity}) +} diff --git a/api/router_api.go b/api/router_api.go index 17626c4..235bcae 100644 --- a/api/router_api.go +++ b/api/router_api.go @@ -8,8 +8,6 @@ func apiRouterInit(router *mux.Router) error { router.HandleFunc("/api/owner/new", ownerNewHandler).Methods("POST") router.HandleFunc("/api/owner/confirm-hex", ownerConfirmHexHandler).Methods("GET") router.HandleFunc("/api/owner/login", ownerLoginHandler).Methods("POST") - router.HandleFunc("/api/owner/send-reset-hex", ownerSendResetHexHandler).Methods("POST") - router.HandleFunc("/api/owner/reset-password", ownerResetPasswordHandler).Methods("POST") router.HandleFunc("/api/owner/self", ownerSelfHandler).Methods("POST") router.HandleFunc("/api/domain/new", domainNewHandler).Methods("POST") @@ -31,6 +29,9 @@ func apiRouterInit(router *mux.Router) error { router.HandleFunc("/api/commenter/self", commenterSelfHandler).Methods("POST") router.HandleFunc("/api/commenter/photo", commenterPhotoHandler).Methods("GET") + router.HandleFunc("/api/forgot", forgotHandler).Methods("POST") + router.HandleFunc("/api/reset", resetHandler).Methods("POST") + router.HandleFunc("/api/email/get", emailGetHandler).Methods("POST") router.HandleFunc("/api/email/update", emailUpdateHandler).Methods("POST") router.HandleFunc("/api/email/moderate", emailModerateHandler).Methods("GET") diff --git a/api/router_static.go b/api/router_static.go index 8e77c4e..b705b3b 100644 --- a/api/router_static.go +++ b/api/router_static.go @@ -96,7 +96,7 @@ func staticRouterInit(router *mux.Router) error { pages := []string{ "/login", "/forgot", - "/reset-password", + "/reset", "/signup", "/confirm-email", "/unsubscribe", diff --git a/api/smtp_owner_reset_hex.go b/api/smtp_reset_hex.go similarity index 72% rename from api/smtp_owner_reset_hex.go rename to api/smtp_reset_hex.go index 1d9dcfe..167a249 100644 --- a/api/smtp_owner_reset_hex.go +++ b/api/smtp_reset_hex.go @@ -6,17 +6,17 @@ import ( "os" ) -type ownerResetHexPlugs struct { +type resetHexPlugs struct { Origin string ResetHex string } -func smtpOwnerResetHex(to string, toName string, resetHex string) error { +func smtpResetHex(to string, toName string, resetHex string) error { var header bytes.Buffer headerTemplate.Execute(&header, &headerPlugs{FromAddress: os.Getenv("SMTP_FROM_ADDRESS"), ToAddress: to, ToName: toName, Subject: "Reset your password"}) var body bytes.Buffer - templates["reset-hex"].Execute(&body, &ownerResetHexPlugs{Origin: os.Getenv("ORIGIN"), ResetHex: resetHex}) + templates["reset-hex"].Execute(&body, &resetHexPlugs{Origin: os.Getenv("ORIGIN"), ResetHex: resetHex}) err := smtp.SendMail(os.Getenv("SMTP_HOST")+":"+os.Getenv("SMTP_PORT"), smtpAuth, os.Getenv("SMTP_FROM_ADDRESS"), []string{to}, concat(header, body)) if err != nil { diff --git a/db/20190606000842-reset-hex.sql b/db/20190606000842-reset-hex.sql new file mode 100644 index 0000000..1b2a0b8 --- /dev/null +++ b/db/20190606000842-reset-hex.sql @@ -0,0 +1,8 @@ +-- Create the resetHexes table + +ALTER TABLE ownerResetHexes RENAME TO resetHexes; + +ALTER TABLE resetHexes RENAME ownerHex TO hex; + +ALTER TABLE resetHexes + ADD entity TEXT NOT NULL DEFAULT 'owner'; diff --git a/frontend/js/commento.js b/frontend/js/commento.js index e779c4f..5b08b9f 100644 --- a/frontend/js/commento.js +++ b/frontend/js/commento.js @@ -22,8 +22,8 @@ var ID_LOGIN_BOX_NAME_INPUT = "commento-login-box-name-input"; var ID_LOGIN_BOX_WEBSITE_INPUT = "commento-login-box-website-input"; var ID_LOGIN_BOX_EMAIL_BUTTON = "commento-login-box-email-button"; + var ID_LOGIN_BOX_FORGOT_LINK_CONTAINER = "commento-login-box-forgot-link-container"; var ID_LOGIN_BOX_LOGIN_LINK_CONTAINER = "commento-login-box-login-link-container"; - var ID_LOGIN_BOX_LOGIN_LINK = "commento-login-box-login-link"; var ID_LOGIN_BOX_SSO_PRETEXT = "commento-login-box-sso-pretext"; var ID_LOGIN_BOX_SSO_BUTTON_CONTAINER = "commento-login-box-sso-buttton-container"; var ID_LOGIN_BOX_HR1 = "commento-login-box-hr1"; @@ -1448,6 +1448,8 @@ var email = create("div"); var emailInput = create("input"); var emailButton = create("button"); + var forgotLinkContainer = create("div"); + var forgotLink = create("a"); var loginLinkContainer = create("div"); var loginLink = create("a"); var close = create("div"); @@ -1456,7 +1458,7 @@ emailSubtitle.id = ID_LOGIN_BOX_EMAIL_SUBTITLE; emailInput.id = ID_LOGIN_BOX_EMAIL_INPUT; emailButton.id = ID_LOGIN_BOX_EMAIL_BUTTON; - loginLink.id = ID_LOGIN_BOX_LOGIN_LINK; + forgotLinkContainer.id = ID_LOGIN_BOX_FORGOT_LINK_CONTAINER loginLinkContainer.id = ID_LOGIN_BOX_LOGIN_LINK_CONTAINER; ssoButtonContainer.id = ID_LOGIN_BOX_SSO_BUTTON_CONTAINER; ssoSubtitle.id = ID_LOGIN_BOX_SSO_PRETEXT; @@ -1472,6 +1474,8 @@ classAdd(email, "email"); classAdd(emailInput, "input"); classAdd(emailButton, "email-button"); + classAdd(forgotLinkContainer, "forgot-link-container"); + classAdd(forgotLink, "forgot-link"); classAdd(loginLinkContainer, "login-link-container"); classAdd(loginLink, "login-link"); classAdd(ssoSubtitle, "login-box-subtitle"); @@ -1483,6 +1487,7 @@ classAdd(close, "login-box-close"); classAdd(root, "root-min-height"); + forgotLink.innerText = "Forgot your password?"; loginLink.innerText = "Don't have an account? Sign up."; emailSubtitle.innerText = "Login with your email address"; emailButton.innerText = "Continue"; @@ -1490,6 +1495,7 @@ ssoSubtitle.innerText = "Proceed with " + parent.location.host + " authentication"; onclick(emailButton, global.passwordAsk, id); + onclick(forgotLink, global.forgotPassword, id); onclick(loginLink, global.popupSwitch, id); onclick(close, global.loginBoxClose); @@ -1522,7 +1528,7 @@ classAdd(button, "button"); classAdd(button, "sso-button"); - button.innerText = "Login with Single Sign-On"; + button.innerText = "Single Sign-On"; onclick(button, global.commentoAuth, {"provider": "sso", "id": id}); @@ -1549,6 +1555,8 @@ append(email, emailButton); append(emailContainer, email); + append(forgotLinkContainer, forgotLink); + append(loginLinkContainer, loginLink); if (numOauthConfigured > 0 && configuredOauths["commento"]) { @@ -1558,6 +1566,7 @@ if (configuredOauths["commento"]) { append(loginBox, emailSubtitle); append(loginBox, emailContainer); + append(loginBox, forgotLinkContainer); append(loginBox, loginLinkContainer); } @@ -1569,9 +1578,15 @@ } + global.forgotPassword = function() { + var popup = window.open("", "_blank"); + popup.location = origin + "/forgot?commenter=true"; + global.loginBoxClose(); + } + + global.popupSwitch = function(id) { var emailSubtitle = $(ID_LOGIN_BOX_EMAIL_SUBTITLE); - var loginLink = $(ID_LOGIN_BOX_LOGIN_LINK); if (oauthButtonsShown) { remove($(ID_LOGIN_BOX_OAUTH_BUTTONS_CONTAINER)); @@ -1587,7 +1602,9 @@ remove($(ID_LOGIN_BOX_HR2)); } - remove(loginLink); + remove($(ID_LOGIN_BOX_LOGIN_LINK_CONTAINER)); + remove($(ID_LOGIN_BOX_FORGOT_LINK_CONTAINER)); + emailSubtitle.innerText = "Create an account"; popupBoxType = "signup"; global.passwordAsk(id); @@ -1665,21 +1682,16 @@ global.passwordAsk = function(id) { var loginBox = $(ID_LOGIN_BOX); var subtitle = $(ID_LOGIN_BOX_EMAIL_SUBTITLE); - var emailButton = $(ID_LOGIN_BOX_EMAIL_BUTTON); - var loginLinkContainer = $(ID_LOGIN_BOX_LOGIN_LINK_CONTAINER); - var hr1 = $(ID_LOGIN_BOX_HR1); - var hr2 = $(ID_LOGIN_BOX_HR2); - var oauthButtonsContainer = $(ID_LOGIN_BOX_OAUTH_BUTTONS_CONTAINER); - var oauthPretext = $(ID_LOGIN_BOX_OAUTH_PRETEXT); - remove(emailButton); - remove(loginLinkContainer); + remove($(ID_LOGIN_BOX_EMAIL_BUTTON)); + remove($(ID_LOGIN_BOX_LOGIN_LINK_CONTAINER)); + remove($(ID_LOGIN_BOX_FORGOT_LINK_CONTAINER)); if (oauthButtonsShown) { if (configuredOauths.length > 0) { - remove(hr1); - remove(hr2); - remove(oauthPretext); - remove(oauthButtonsContainer); + remove($(ID_LOGIN_BOX_HR1)); + remove($(ID_LOGIN_BOX_HR2)); + remove($(ID_LOGIN_BOX_OAUTH_PRETEXT)); + remove($(ID_LOGIN_BOX_OAUTH_BUTTONS_CONTAINER)); } } diff --git a/frontend/js/forgot.js b/frontend/js/forgot.js index dced2d3..a7c3bd2 100644 --- a/frontend/js/forgot.js +++ b/frontend/js/forgot.js @@ -16,12 +16,18 @@ return; } + var entity = "owner"; + if (global.paramGet("commenter") === "true") { + entity = "commenter"; + } + var json = { "email": $("#email").val(), + "entity": entity, }; global.buttonDisable("#reset-button"); - global.post(global.origin + "/api/owner/send-reset-hex", json, function(resp) { + global.post(global.origin + "/api/forgot", json, function(resp) { global.buttonEnable("#reset-button"); global.textSet("#err", ""); diff --git a/frontend/js/reset.js b/frontend/js/reset.js index 717b0db..457f30f 100644 --- a/frontend/js/reset.js +++ b/frontend/js/reset.js @@ -24,7 +24,7 @@ }; global.buttonDisable("#reset-button"); - global.post(global.origin + "/api/owner/reset-password", json, function(resp) { + global.post(global.origin + "/api/reset", json, function(resp) { global.buttonEnable("#reset-button"); global.textSet("#err", ""); @@ -33,8 +33,14 @@ return } - document.location = global.origin + "/login?changed=true"; + if (resp.entity === "owner") { + document.location = global.origin + "/login?changed=true"; + } else { + $("#msg").html("Your password has been reset. You may close this window and try logging in again."); + } }); } + self.close(); + } (window.commento, document)); diff --git a/frontend/reset-password.html b/frontend/reset.html similarity index 100% rename from frontend/reset-password.html rename to frontend/reset.html diff --git a/frontend/sass/commento-login.scss b/frontend/sass/commento-login.scss index d0ed1ae..43cc699 100644 --- a/frontend/sass/commento-login.scss +++ b/frontend/sass/commento-login.scss @@ -38,16 +38,24 @@ @import "email-main.scss"; + .commento-forgot-link-container, .commento-login-link-container { margin: 16px; width: calc(100% - 32px); text-align: center; + } - .commento-login-link { - font-size: 14px; - font-weight: bold; - border-bottom: none; - } + .commento-forgot-link, + .commento-login-link { + font-size: 14px; + font-weight: bold; + border-bottom: none; + } + + .commento-forgot-link { + font-size: 13px; + color: $gray-6; + font-weight: normal; } .commento-login-box-close { diff --git a/templates/reset-hex.txt b/templates/reset-hex.txt index ec7c4b0..f99502e 100644 --- a/templates/reset-hex.txt +++ b/templates/reset-hex.txt @@ -2,6 +2,6 @@ Hi, Someone (probably you) recently initiated the procedure to reset your Commento account password. To do this, use the link below: -{{.Origin}}/reset-password?hex={{.ResetHex}} +{{.Origin}}/reset?hex={{.ResetHex}} If you did not initiate this request, you can safely ignore this email.