fix(security): redesign password reset tokens using crypto/rand with server-side storage

- Replace SHA512-based deterministic token with 32-byte crypto/rand token
- Store tokens server-side with 1-hour expiry and single-use semantics
- Remove genToken (previously broken due to time.Add immutability bug)
- Add CSRF double-submit cookie protection to change/lost/reset forms
- Remove token from form action URL (use hidden fields only, POST body)
- Add MailFrom field and SMTP_FROM env var for configurable sender address
- Add SMTP_PASSWORD_FILE env var for secure SMTP password loading
- Add PUBLIC_URL env var and --public-url flag for configurable reset link domain
- Use generic error messages in handlers to avoid information disclosure

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nemunaire 2026-03-06 14:44:29 +07:00
commit 57775bbf89
9 changed files with 193 additions and 83 deletions

View file

@ -3,7 +3,6 @@ package main
import (
"log"
"net/http"
"strings"
)
func resetPassword(w http.ResponseWriter, r *http.Request) {
@ -14,22 +13,46 @@ func resetPassword(w http.ResponseWriter, r *http.Request) {
base := map[string]interface{}{
"login": r.URL.Query().Get("l"),
"token": strings.Replace(r.URL.Query().Get("t"), " ", "+", -1),
"token": r.URL.Query().Get("t"),
}
if r.Method != "POST" {
csrfToken, err := setCSRFToken(w)
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
base["csrf_token"] = csrfToken
displayTmpl(w, "reset.html", base)
return
}
renderError := func(status int, msg string) {
csrfToken, _ := setCSRFToken(w)
base["error"] = msg
base["csrf_token"] = csrfToken
displayTmplError(w, status, "reset.html", base)
}
if !validateCSRF(r) {
renderError(http.StatusForbidden, "Invalid or missing CSRF token. Please try again.")
return
}
// Check the two new passwords are identical
if r.PostFormValue("newpassword") != r.PostFormValue("new2password") {
base["error"] = "New passwords are not identical. Please retry."
displayTmplError(w, http.StatusNotAcceptable, "reset.html", base)
renderError(http.StatusNotAcceptable, "New passwords are not identical. Please retry.")
return
} else if err := checkPasswdConstraint(r.PostFormValue("newpassword")); err != nil {
base["error"] = "The password you chose doesn't respect all constraints: " + err.Error()
displayTmplError(w, http.StatusNotAcceptable, "reset.html", base)
renderError(http.StatusNotAcceptable, "The password you chose doesn't respect all constraints: "+err.Error())
return
}
// Validate and consume the token (single-use, server-side)
token := r.PostFormValue("token")
dn, ok := consumeResetToken(token)
if !ok {
renderError(http.StatusNotAcceptable, "Token invalid or expired, please retry the lost password procedure. Tokens expire after 1 hour.")
return
}
@ -37,41 +60,22 @@ func resetPassword(w http.ResponseWriter, r *http.Request) {
conn, err := myLDAP.Connect()
if err != nil || conn == nil {
log.Println(err)
base["error"] = err.Error()
displayTmplError(w, http.StatusInternalServerError, "reset.html", base)
renderError(http.StatusInternalServerError, "Unable to process your request. Please try again later.")
return
}
// Bind as service to perform the search
// Bind as service to perform the password change
err = conn.ServiceBind()
if err != nil {
log.Println(err)
base["error"] = err.Error()
displayTmplError(w, http.StatusInternalServerError, "reset.html", base)
return
}
// Search the dn of the given user
dn, err := conn.SearchDN(r.PostFormValue("login"), true)
if err != nil {
log.Println(err)
base["error"] = err.Error()
displayTmplError(w, http.StatusInternalServerError, "reset.html", base)
return
}
// Check token validity (allow current token + last one)
if conn.genToken(dn, false) != r.PostFormValue("token") && conn.genToken(dn, true) != r.PostFormValue("token") {
base["error"] = "Token invalid, please retry the lost password procedure. Please note that our token expires after 1 hour."
displayTmplError(w, http.StatusNotAcceptable, "reset.html", base)
renderError(http.StatusInternalServerError, "Unable to process your request. Please try again later.")
return
}
// Replace the password by the new given
if err := conn.ChangePassword(dn, r.PostFormValue("newpassword")); err != nil {
log.Println(err)
base["error"] = err.Error()
displayTmplError(w, http.StatusInternalServerError, "reset.html", base)
renderError(http.StatusInternalServerError, "Unable to process your request. Please try again later.")
return
}