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>
210 lines
5.5 KiB
Go
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)
|
|
}
|