chldapasswd/lost.go
Pierre-Olivier Mercier 439dc2cd07 refactor: modernize Go idioms across codebase
Replace map[string]interface{} with map[string]any, ioutil.ReadAll with
io.ReadAll, and simplify redundant fmt.Sprintf/w.Write calls.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 11:48:34 +07:00

210 lines
5.5 KiB
Go

package main
import (
"crypto/rand"
"encoding/base64"
"io"
"log"
"net/http"
"os"
"os/exec"
"sync"
"time"
"gopkg.in/gomail.v2"
)
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
}
func storeResetToken(token string, dn string) {
resetTokenStore.mu.Lock()
defer resetTokenStore.mu.Unlock()
// 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),
}
}
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) {
// Bind as service to perform the search
err := conn.ServiceBind()
if err != nil {
return "", "", err
}
// Search the dn of the given user
dn, err := conn.SearchDN(login, true)
if err != nil {
return "", "", err
}
// 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" && !lostLimiter.Allow(remoteIP(r)) {
displayTmplError(w, http.StatusTooManyRequests, "lost.html", map[string]any{"error": "Too many requests. Please try again later."})
return
}
if r.Method != "POST" {
csrfToken, err := setCSRFToken(w)
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
displayTmpl(w, "lost.html", map[string]any{"csrf_token": csrfToken})
return
}
if !validateCSRF(r) {
displayTmplError(w, http.StatusForbidden, "lost.html", map[string]any{"error": "Invalid or missing CSRF token. Please try again."})
return
}
if !validateAltcha(r) {
displayTmplError(w, http.StatusForbidden, "lost.html", map[string]any{"error": "Invalid or missing altcha response. Please try again."})
return
}
// Connect to the LDAP server
conn, err := myLDAP.Connect()
if err != nil || conn == nil {
log.Println(err)
displayTmplError(w, http.StatusInternalServerError, "lost.html", map[string]any{"error": "Unable to process your request. Please try again later."})
return
}
// Generate the token
token, dn, err := lostPasswordToken(conn, r.PostFormValue("login"))
if err != nil {
log.Println(err)
// 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
}
// Search the email address
entries, err := conn.GetEntry(dn)
if err != nil {
log.Println(err)
displayMsg(w, "If an account with that login exists, a password recovery email has been sent.", http.StatusOK)
return
}
email := ""
cn := ""
for _, e := range entries {
if e.Name == "mail" {
email = e.Values[0]
}
if e.Name == "cn" {
cn = e.Values[0]
}
}
if email == "" {
log.Println("Unable to find a valid adress for user " + dn)
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", 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"+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 != "" {
d := gomail.NewDialer(myLDAP.MailHost, myLDAP.MailPort, myLDAP.MailUser, myLDAP.MailPassword)
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]any{"error": "Unable to send password recovery email. Please try again later."})
return
}
} else {
// Using local sendmail: delegate to the local admin sys the responsability to transport the mail
s = gomail.SendFunc(func(from string, to []string, msg io.WriterTo) error {
cmd := exec.Command("sendmail", "-t")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
pw, err := cmd.StdinPipe()
if err != nil {
return err
}
err = cmd.Start()
if err != nil {
return err
}
var errs [3]error
_, errs[0] = m.WriteTo(pw)
errs[1] = pw.Close()
errs[2] = cmd.Wait()
for _, err = range errs {
if err != nil {
return err
}
}
return nil
})
}
if err := gomail.Send(s, m); err != nil {
log.Println("Unable to send email: " + err.Error())
displayTmplError(w, http.StatusInternalServerError, "lost.html", map[string]any{"error": "Unable to send password recovery email. Please try again later."})
return
}
displayMsg(w, "Password recovery email sent, check your inbox.", http.StatusOK)
}