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:
parent
a2f368eb02
commit
57775bbf89
9 changed files with 193 additions and 83 deletions
111
lost.go
111
lost.go
|
|
@ -1,54 +1,64 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha512"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gopkg.in/gomail.v2"
|
||||
)
|
||||
|
||||
func (l LDAPConn) genToken(dn string, previous bool) string {
|
||||
hour := time.Now()
|
||||
// Generate the previous token?
|
||||
if previous {
|
||||
hour.Add(time.Hour * -1)
|
||||
type resetTokenEntry struct {
|
||||
dn string
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
var resetTokenStore = struct {
|
||||
mu sync.Mutex
|
||||
tokens map[string]resetTokenEntry
|
||||
}{tokens: make(map[string]resetTokenEntry)}
|
||||
|
||||
func generateResetToken() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.URLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
b := make([]byte, binary.MaxVarintLen64)
|
||||
binary.PutVarint(b, hour.Round(time.Hour).Unix())
|
||||
func storeResetToken(token string, dn string) {
|
||||
resetTokenStore.mu.Lock()
|
||||
defer resetTokenStore.mu.Unlock()
|
||||
|
||||
// Search the email address and current password
|
||||
entries, err := l.GetEntry(dn)
|
||||
if err != nil {
|
||||
log.Println("Unable to generate token:", err)
|
||||
return "#err"
|
||||
}
|
||||
|
||||
email := ""
|
||||
curpasswd := ""
|
||||
for _, e := range entries {
|
||||
if e.Name == "mail" {
|
||||
email += e.Values[0]
|
||||
} else if e.Name == "userPassword" {
|
||||
curpasswd += e.Values[0]
|
||||
// Clean expired tokens
|
||||
now := time.Now()
|
||||
for t, e := range resetTokenStore.tokens {
|
||||
if now.After(e.expiresAt) {
|
||||
delete(resetTokenStore.tokens, t)
|
||||
}
|
||||
}
|
||||
resetTokenStore.tokens[token] = resetTokenEntry{
|
||||
dn: dn,
|
||||
expiresAt: now.Add(time.Hour),
|
||||
}
|
||||
}
|
||||
|
||||
// Hash that
|
||||
hash := sha512.New()
|
||||
hash.Write(b)
|
||||
hash.Write([]byte(dn))
|
||||
hash.Write([]byte(email))
|
||||
hash.Write([]byte(curpasswd))
|
||||
|
||||
return base64.StdEncoding.EncodeToString(hash.Sum(nil)[:])
|
||||
func consumeResetToken(token string) (string, bool) {
|
||||
resetTokenStore.mu.Lock()
|
||||
defer resetTokenStore.mu.Unlock()
|
||||
entry, ok := resetTokenStore.tokens[token]
|
||||
if !ok || time.Now().After(entry.expiresAt) {
|
||||
delete(resetTokenStore.tokens, token)
|
||||
return "", false
|
||||
}
|
||||
delete(resetTokenStore.tokens, token)
|
||||
return entry.dn, true
|
||||
}
|
||||
|
||||
func lostPasswordToken(conn *LDAPConn, login string) (string, string, error) {
|
||||
|
|
@ -64,15 +74,31 @@ func lostPasswordToken(conn *LDAPConn, login string) (string, string, error) {
|
|||
return "", "", err
|
||||
}
|
||||
|
||||
// Generate the token
|
||||
token := conn.genToken(dn, false)
|
||||
// Generate a cryptographically random token
|
||||
token, err := generateResetToken()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
// Store token server-side with expiration
|
||||
storeResetToken(token, dn)
|
||||
|
||||
return token, dn, nil
|
||||
}
|
||||
|
||||
func lostPassword(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
displayTmpl(w, "lost.html", map[string]interface{}{})
|
||||
csrfToken, err := setCSRFToken(w)
|
||||
if err != nil {
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
displayTmpl(w, "lost.html", map[string]interface{}{"csrf_token": csrfToken})
|
||||
return
|
||||
}
|
||||
|
||||
if !validateCSRF(r) {
|
||||
displayTmplError(w, http.StatusForbidden, "lost.html", map[string]interface{}{"error": "Invalid or missing CSRF token. Please try again."})
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -80,7 +106,7 @@ func lostPassword(w http.ResponseWriter, r *http.Request) {
|
|||
conn, err := myLDAP.Connect()
|
||||
if err != nil || conn == nil {
|
||||
log.Println(err)
|
||||
displayTmplError(w, http.StatusInternalServerError, "lost.html", map[string]interface{}{"error": err.Error()})
|
||||
displayTmplError(w, http.StatusInternalServerError, "lost.html", map[string]interface{}{"error": "Unable to process your request. Please try again later."})
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -88,7 +114,8 @@ func lostPassword(w http.ResponseWriter, r *http.Request) {
|
|||
token, dn, err := lostPasswordToken(conn, r.PostFormValue("login"))
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
displayTmplError(w, http.StatusInternalServerError, "lost.html", map[string]interface{}{"error": err.Error()})
|
||||
// Return generic message to avoid user enumeration
|
||||
displayMsg(w, "If an account with that login exists, a password recovery email has been sent.", http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -96,7 +123,7 @@ func lostPassword(w http.ResponseWriter, r *http.Request) {
|
|||
entries, err := conn.GetEntry(dn)
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
displayTmplError(w, http.StatusInternalServerError, "lost.html", map[string]interface{}{"error": err.Error()})
|
||||
displayMsg(w, "If an account with that login exists, a password recovery email has been sent.", http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -113,16 +140,16 @@ func lostPassword(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
if email == "" {
|
||||
log.Println("Unable to find a valid adress for user " + dn)
|
||||
displayTmplError(w, http.StatusBadRequest, "lost.html", map[string]interface{}{"error": "We were unable to find a valid email address associated with your account. Please contact an administrator."})
|
||||
displayMsg(w, "If an account with that login exists, a password recovery email has been sent.", http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
// Send the email
|
||||
m := gomail.NewMessage()
|
||||
m.SetHeader("From", "noreply@nemunai.re")
|
||||
m.SetHeader("From", myLDAP.MailFrom)
|
||||
m.SetHeader("To", email)
|
||||
m.SetHeader("Subject", "SSO nemunai.re: password recovery")
|
||||
m.SetBody("text/plain", "Hello "+cn+"!\n\nSomeone, and we hope it's you, requested to reset your account password. \nIn order to continue, go to:\n"+BASEURL+"/reset?l="+r.PostFormValue("login")+"&t="+token+"\n\nBest regards,\n-- \nnemunai.re SSO")
|
||||
m.SetBody("text/plain", "Hello "+cn+"!\n\nSomeone, and we hope it's you, requested to reset your account password. \nIn order to continue, go to:\n"+myPublicURL+"/reset?l="+r.PostFormValue("login")+"&t="+token+"\n\nThis link expires in 1 hour and can only be used once.\n\nBest regards,\n-- \nnemunai.re SSO")
|
||||
|
||||
var s gomail.Sender
|
||||
if myLDAP.MailHost != "" {
|
||||
|
|
@ -130,7 +157,7 @@ func lostPassword(w http.ResponseWriter, r *http.Request) {
|
|||
s, err = d.Dial()
|
||||
if err != nil {
|
||||
log.Println("Unable to connect to email server: " + err.Error())
|
||||
displayTmplError(w, http.StatusInternalServerError, "lost.html", map[string]interface{}{"error": "Unable to connect to email server: " + err.Error()})
|
||||
displayTmplError(w, http.StatusInternalServerError, "lost.html", map[string]interface{}{"error": "Unable to send password recovery email. Please try again later."})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
|
|
@ -165,7 +192,7 @@ func lostPassword(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
if err := gomail.Send(s, m); err != nil {
|
||||
log.Println("Unable to send email: " + err.Error())
|
||||
displayTmplError(w, http.StatusInternalServerError, "lost.html", map[string]interface{}{"error": "Unable to send email: " + err.Error()})
|
||||
displayTmplError(w, http.StatusInternalServerError, "lost.html", map[string]interface{}{"error": "Unable to send password recovery email. Please try again later."})
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue