api,frontend: add commenter password resets

This commit is contained in:
Adhityaa Chandrasekar 2019-06-06 01:27:42 -07:00
parent 36fea6e95b
commit 85456a019e
16 changed files with 253 additions and 215 deletions

View File

@ -46,3 +46,4 @@ var errorDatabaseMigration = errors.New("Encountered error applying database mig
var errorNoSuchUnsubscribeSecretHex = errors.New("Invalid unsubscribe link.") var errorNoSuchUnsubscribeSecretHex = errors.New("Invalid unsubscribe link.")
var errorEmptyPaths = errors.New("Empty paths field.") 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 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.")

97
api/forgot.go Normal file
View File

@ -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})
}

View File

@ -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})
}

View File

@ -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})
}

View File

@ -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
}
}

82
api/reset.go Normal file
View File

@ -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})
}

View File

@ -8,8 +8,6 @@ func apiRouterInit(router *mux.Router) error {
router.HandleFunc("/api/owner/new", ownerNewHandler).Methods("POST") router.HandleFunc("/api/owner/new", ownerNewHandler).Methods("POST")
router.HandleFunc("/api/owner/confirm-hex", ownerConfirmHexHandler).Methods("GET") router.HandleFunc("/api/owner/confirm-hex", ownerConfirmHexHandler).Methods("GET")
router.HandleFunc("/api/owner/login", ownerLoginHandler).Methods("POST") 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/owner/self", ownerSelfHandler).Methods("POST")
router.HandleFunc("/api/domain/new", domainNewHandler).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/self", commenterSelfHandler).Methods("POST")
router.HandleFunc("/api/commenter/photo", commenterPhotoHandler).Methods("GET") 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/get", emailGetHandler).Methods("POST")
router.HandleFunc("/api/email/update", emailUpdateHandler).Methods("POST") router.HandleFunc("/api/email/update", emailUpdateHandler).Methods("POST")
router.HandleFunc("/api/email/moderate", emailModerateHandler).Methods("GET") router.HandleFunc("/api/email/moderate", emailModerateHandler).Methods("GET")

View File

@ -96,7 +96,7 @@ func staticRouterInit(router *mux.Router) error {
pages := []string{ pages := []string{
"/login", "/login",
"/forgot", "/forgot",
"/reset-password", "/reset",
"/signup", "/signup",
"/confirm-email", "/confirm-email",
"/unsubscribe", "/unsubscribe",

View File

@ -6,17 +6,17 @@ import (
"os" "os"
) )
type ownerResetHexPlugs struct { type resetHexPlugs struct {
Origin string Origin string
ResetHex 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 var header bytes.Buffer
headerTemplate.Execute(&header, &headerPlugs{FromAddress: os.Getenv("SMTP_FROM_ADDRESS"), ToAddress: to, ToName: toName, Subject: "Reset your password"}) headerTemplate.Execute(&header, &headerPlugs{FromAddress: os.Getenv("SMTP_FROM_ADDRESS"), ToAddress: to, ToName: toName, Subject: "Reset your password"})
var body bytes.Buffer 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)) 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 { if err != nil {

View File

@ -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';

View File

@ -22,8 +22,8 @@
var ID_LOGIN_BOX_NAME_INPUT = "commento-login-box-name-input"; 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_WEBSITE_INPUT = "commento-login-box-website-input";
var ID_LOGIN_BOX_EMAIL_BUTTON = "commento-login-box-email-button"; 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_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_PRETEXT = "commento-login-box-sso-pretext";
var ID_LOGIN_BOX_SSO_BUTTON_CONTAINER = "commento-login-box-sso-buttton-container"; var ID_LOGIN_BOX_SSO_BUTTON_CONTAINER = "commento-login-box-sso-buttton-container";
var ID_LOGIN_BOX_HR1 = "commento-login-box-hr1"; var ID_LOGIN_BOX_HR1 = "commento-login-box-hr1";
@ -1448,6 +1448,8 @@
var email = create("div"); var email = create("div");
var emailInput = create("input"); var emailInput = create("input");
var emailButton = create("button"); var emailButton = create("button");
var forgotLinkContainer = create("div");
var forgotLink = create("a");
var loginLinkContainer = create("div"); var loginLinkContainer = create("div");
var loginLink = create("a"); var loginLink = create("a");
var close = create("div"); var close = create("div");
@ -1456,7 +1458,7 @@
emailSubtitle.id = ID_LOGIN_BOX_EMAIL_SUBTITLE; emailSubtitle.id = ID_LOGIN_BOX_EMAIL_SUBTITLE;
emailInput.id = ID_LOGIN_BOX_EMAIL_INPUT; emailInput.id = ID_LOGIN_BOX_EMAIL_INPUT;
emailButton.id = ID_LOGIN_BOX_EMAIL_BUTTON; 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; loginLinkContainer.id = ID_LOGIN_BOX_LOGIN_LINK_CONTAINER;
ssoButtonContainer.id = ID_LOGIN_BOX_SSO_BUTTON_CONTAINER; ssoButtonContainer.id = ID_LOGIN_BOX_SSO_BUTTON_CONTAINER;
ssoSubtitle.id = ID_LOGIN_BOX_SSO_PRETEXT; ssoSubtitle.id = ID_LOGIN_BOX_SSO_PRETEXT;
@ -1472,6 +1474,8 @@
classAdd(email, "email"); classAdd(email, "email");
classAdd(emailInput, "input"); classAdd(emailInput, "input");
classAdd(emailButton, "email-button"); classAdd(emailButton, "email-button");
classAdd(forgotLinkContainer, "forgot-link-container");
classAdd(forgotLink, "forgot-link");
classAdd(loginLinkContainer, "login-link-container"); classAdd(loginLinkContainer, "login-link-container");
classAdd(loginLink, "login-link"); classAdd(loginLink, "login-link");
classAdd(ssoSubtitle, "login-box-subtitle"); classAdd(ssoSubtitle, "login-box-subtitle");
@ -1483,6 +1487,7 @@
classAdd(close, "login-box-close"); classAdd(close, "login-box-close");
classAdd(root, "root-min-height"); classAdd(root, "root-min-height");
forgotLink.innerText = "Forgot your password?";
loginLink.innerText = "Don't have an account? Sign up."; loginLink.innerText = "Don't have an account? Sign up.";
emailSubtitle.innerText = "Login with your email address"; emailSubtitle.innerText = "Login with your email address";
emailButton.innerText = "Continue"; emailButton.innerText = "Continue";
@ -1490,6 +1495,7 @@
ssoSubtitle.innerText = "Proceed with " + parent.location.host + " authentication"; ssoSubtitle.innerText = "Proceed with " + parent.location.host + " authentication";
onclick(emailButton, global.passwordAsk, id); onclick(emailButton, global.passwordAsk, id);
onclick(forgotLink, global.forgotPassword, id);
onclick(loginLink, global.popupSwitch, id); onclick(loginLink, global.popupSwitch, id);
onclick(close, global.loginBoxClose); onclick(close, global.loginBoxClose);
@ -1522,7 +1528,7 @@
classAdd(button, "button"); classAdd(button, "button");
classAdd(button, "sso-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}); onclick(button, global.commentoAuth, {"provider": "sso", "id": id});
@ -1549,6 +1555,8 @@
append(email, emailButton); append(email, emailButton);
append(emailContainer, email); append(emailContainer, email);
append(forgotLinkContainer, forgotLink);
append(loginLinkContainer, loginLink); append(loginLinkContainer, loginLink);
if (numOauthConfigured > 0 && configuredOauths["commento"]) { if (numOauthConfigured > 0 && configuredOauths["commento"]) {
@ -1558,6 +1566,7 @@
if (configuredOauths["commento"]) { if (configuredOauths["commento"]) {
append(loginBox, emailSubtitle); append(loginBox, emailSubtitle);
append(loginBox, emailContainer); append(loginBox, emailContainer);
append(loginBox, forgotLinkContainer);
append(loginBox, loginLinkContainer); 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) { global.popupSwitch = function(id) {
var emailSubtitle = $(ID_LOGIN_BOX_EMAIL_SUBTITLE); var emailSubtitle = $(ID_LOGIN_BOX_EMAIL_SUBTITLE);
var loginLink = $(ID_LOGIN_BOX_LOGIN_LINK);
if (oauthButtonsShown) { if (oauthButtonsShown) {
remove($(ID_LOGIN_BOX_OAUTH_BUTTONS_CONTAINER)); remove($(ID_LOGIN_BOX_OAUTH_BUTTONS_CONTAINER));
@ -1587,7 +1602,9 @@
remove($(ID_LOGIN_BOX_HR2)); 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"; emailSubtitle.innerText = "Create an account";
popupBoxType = "signup"; popupBoxType = "signup";
global.passwordAsk(id); global.passwordAsk(id);
@ -1665,21 +1682,16 @@
global.passwordAsk = function(id) { global.passwordAsk = function(id) {
var loginBox = $(ID_LOGIN_BOX); var loginBox = $(ID_LOGIN_BOX);
var subtitle = $(ID_LOGIN_BOX_EMAIL_SUBTITLE); 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($(ID_LOGIN_BOX_EMAIL_BUTTON));
remove(loginLinkContainer); remove($(ID_LOGIN_BOX_LOGIN_LINK_CONTAINER));
remove($(ID_LOGIN_BOX_FORGOT_LINK_CONTAINER));
if (oauthButtonsShown) { if (oauthButtonsShown) {
if (configuredOauths.length > 0) { if (configuredOauths.length > 0) {
remove(hr1); remove($(ID_LOGIN_BOX_HR1));
remove(hr2); remove($(ID_LOGIN_BOX_HR2));
remove(oauthPretext); remove($(ID_LOGIN_BOX_OAUTH_PRETEXT));
remove(oauthButtonsContainer); remove($(ID_LOGIN_BOX_OAUTH_BUTTONS_CONTAINER));
} }
} }

View File

@ -16,12 +16,18 @@
return; return;
} }
var entity = "owner";
if (global.paramGet("commenter") === "true") {
entity = "commenter";
}
var json = { var json = {
"email": $("#email").val(), "email": $("#email").val(),
"entity": entity,
}; };
global.buttonDisable("#reset-button"); 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.buttonEnable("#reset-button");
global.textSet("#err", ""); global.textSet("#err", "");

View File

@ -24,7 +24,7 @@
}; };
global.buttonDisable("#reset-button"); 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.buttonEnable("#reset-button");
global.textSet("#err", ""); global.textSet("#err", "");
@ -33,8 +33,14 @@
return 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)); } (window.commento, document));

View File

@ -38,16 +38,24 @@
@import "email-main.scss"; @import "email-main.scss";
.commento-forgot-link-container,
.commento-login-link-container { .commento-login-link-container {
margin: 16px; margin: 16px;
width: calc(100% - 32px); width: calc(100% - 32px);
text-align: center; text-align: center;
}
.commento-login-link { .commento-forgot-link,
font-size: 14px; .commento-login-link {
font-weight: bold; font-size: 14px;
border-bottom: none; font-weight: bold;
} border-bottom: none;
}
.commento-forgot-link {
font-size: 13px;
color: $gray-6;
font-weight: normal;
} }
.commento-login-box-close { .commento-login-box-close {

View File

@ -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: 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. If you did not initiate this request, you can safely ignore this email.