chldapasswd/ldap.go
Pierre-Olivier Mercier 57775bbf89 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>
2026-03-06 15:30:48 +07:00

169 lines
3.9 KiB
Go

package main
import (
"crypto/rand"
"crypto/tls"
"encoding/base64"
"errors"
"fmt"
"github.com/amoghe/go-crypt"
"github.com/go-ldap/ldap/v3"
)
type LDAP struct {
Host string
Port int
Starttls bool
Ssl bool
BaseDN string
ServiceDN string
ServicePassword string
MailHost string
MailPort int
MailUser string
MailPassword string
MailFrom string
}
func (l LDAP) Connect() (*LDAPConn, error) {
if l.Ssl {
if c, err := ldap.DialTLS("tcp", fmt.Sprintf("%s:%d", l.Host, l.Port), &tls.Config{ServerName: l.Host}); err != nil {
return nil, errors.New("unable to establish LDAPS connection to " + fmt.Sprintf("%s:%d", l.Host, l.Port) + ": " + err.Error())
} else {
return &LDAPConn{
LDAP: l,
connection: c,
}, nil
}
} else if c, err := ldap.Dial("tcp", fmt.Sprintf("%s:%d", l.Host, l.Port)); err != nil {
return nil, errors.New("unable to establish LDAP connection to " + fmt.Sprintf("%s:%d", l.Host, l.Port) + ": " + err.Error())
} else {
if l.Starttls {
if err = c.StartTLS(&tls.Config{ServerName: l.Host}); err != nil {
c.Close()
return nil, errors.New("unable to StartTLS: " + err.Error())
}
}
return &LDAPConn{
LDAP: l,
connection: c,
}, nil
}
}
type LDAPConn struct {
LDAP
connection *ldap.Conn
}
func (l LDAPConn) ServiceBind() error {
return l.connection.Bind(l.ServiceDN, l.ServicePassword)
}
func (l LDAPConn) Bind(username string, password string) error {
return l.connection.Bind(username, password)
}
func (l LDAPConn) SearchDN(username string, person bool) (string, error) {
objectClass := "organizationalPerson"
if !person {
objectClass = "simpleSecurityObject"
}
searchRequest := ldap.NewSearchRequest(
l.BaseDN,
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
fmt.Sprintf("(&(objectClass=%s)(uid=%s))", ldap.EscapeFilter(objectClass), ldap.EscapeFilter(username)),
[]string{"dn"},
nil,
)
sr, err := l.connection.Search(searchRequest)
if err != nil {
return "", err
}
if len(sr.Entries) != 1 {
return "", errors.New("User does not exist or too many entries returned")
}
return sr.Entries[0].DN, nil
}
func (l LDAPConn) GetEntry(dn string) ([]*ldap.EntryAttribute, error) {
searchRequest := ldap.NewSearchRequest(
dn,
ldap.ScopeBaseObject, ldap.NeverDerefAliases, 0, 0, false,
"(objectClass=*)", []string{}, nil,
)
sr, err := l.connection.Search(searchRequest)
if err != nil {
return nil, err
}
if len(sr.Entries) != 1 {
return nil, errors.New("User does not exist or too many entries returned")
}
return sr.Entries[0].Attributes, nil
}
func genSalt() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(b), nil
}
func (l LDAPConn) ChangePassword(dn string, rawpassword string) error {
salt, err := genSalt()
if err != nil {
return err
}
hashedpasswd, err := crypt.Crypt(rawpassword, "$6$"+salt+"$")
if err != nil {
return err
}
modify := ldap.NewModifyRequest(dn, nil)
modify.Replace("userPassword", []string{"{CRYPT}" + hashedpasswd})
return l.connection.Modify(modify)
}
func (l LDAPConn) AddMailAlias(dn string, alias string) error {
modify := ldap.NewModifyRequest(dn, nil)
modify.Add("mailAlias", []string{alias})
return l.connection.Modify(modify)
}
func (l LDAPConn) SearchMailAlias(address string) (int, error) {
searchRequest := ldap.NewSearchRequest(
l.BaseDN,
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
fmt.Sprintf("(&(objectClass=*)(mailAlias=%s))", ldap.EscapeFilter(address)),
[]string{"dn"},
nil,
)
sr, err := l.connection.Search(searchRequest)
if err != nil {
return -1, err
}
return len(sr.Entries), nil
}
func (l LDAPConn) DelMailAlias(dn string, alias string) error {
modify := ldap.NewModifyRequest(dn, nil)
modify.Delete("mailAlias", []string{alias})
return l.connection.Modify(modify)
}