Compare commits

..

4 commits

Author SHA1 Message Date
e6bca3ac8f fix(ldap): split ambiguous error messages in SearchDN and GetEntry
All checks were successful
continuous-integration/drone/push Build is passing
Distinguish between "not found" and "multiple entries found" instead of
the generic "User does not exist or too many entries returned", making
it easier to diagnose issues in logs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 17:07:41 +07:00
f517be8afb refactor(ldap): use DialURL instead of deprecated Dial/DialTLS
ldap.Dial and ldap.DialTLS are deprecated in go-ldap/ldap/v3. Switch to
ldap.DialURL which is the recommended API. Also use fmt.Errorf with %w
for proper error wrapping.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 17:05:30 +07:00
3e6b95bf40 refactor: separate SMTP config from LDAP struct
The LDAP struct was mixing LDAP connection settings with unrelated mail
settings. Extract mail fields into a dedicated SMTPConfig struct with
its own global (mySMTP), keeping concerns cleanly separated.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 17:02:52 +07:00
4a68d0700d fix(ldap): add Close() method and defer conn.Close() at all call sites
LDAP connections were never closed, leaking TCP connections on every
request. Also refactors change.go from chained else-if to early returns
for cleaner defer placement.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 14:40:40 +07:00
7 changed files with 123 additions and 69 deletions

View file

@ -115,6 +115,7 @@ func addyAliasAPI(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer conn.Close()
err = conn.ServiceBind()
if err != nil {
@ -197,6 +198,7 @@ func addyAliasAPIDelete(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer conn.Close()
err = conn.ServiceBind()
if err != nil {

View file

@ -67,31 +67,51 @@ func changePassword(w http.ResponseWriter, r *http.Request) {
// Check the two new passwords are identical
if r.PostFormValue("newpassword") != r.PostFormValue("new2password") {
renderError(http.StatusNotAcceptable, "New passwords are not identical. Please retry.")
} else if len(r.PostFormValue("login")) == 0 {
renderError(http.StatusNotAcceptable, "Please provide a valid login")
} else if err := checkPasswdConstraint(r.PostFormValue("newpassword")); err != nil {
renderError(http.StatusNotAcceptable, "The password you chose doesn't respect all constraints: "+err.Error())
} else {
conn, err := myLDAP.Connect()
if err != nil || conn == nil {
log.Println(err)
renderError(http.StatusInternalServerError, "Unable to process your request. Please try again later.")
} else if err := conn.ServiceBind(); err != nil {
log.Println(err)
renderError(http.StatusInternalServerError, "Unable to process your request. Please try again later.")
} else if dn, err := conn.SearchDN(r.PostFormValue("login"), true); err != nil {
log.Println(err)
// User not found: perform a dummy bind to prevent username enumeration via timing.
conn.Bind("cn=dummy,"+myLDAP.BaseDN, r.PostFormValue("password"))
renderError(http.StatusUnauthorized, "Invalid login or password.")
} else if err := conn.Bind(dn, r.PostFormValue("password")); err != nil {
log.Println(err)
renderError(http.StatusUnauthorized, "Invalid login or password.")
} else if err := conn.ChangePassword(dn, r.PostFormValue("newpassword")); err != nil {
log.Println(err)
renderError(http.StatusInternalServerError, "Unable to process your request. Please try again later.")
} else {
displayMsg(w, "Password successfully changed!", http.StatusOK)
}
return
}
if len(r.PostFormValue("login")) == 0 {
renderError(http.StatusNotAcceptable, "Please provide a valid login")
return
}
if err := checkPasswdConstraint(r.PostFormValue("newpassword")); err != nil {
renderError(http.StatusNotAcceptable, "The password you chose doesn't respect all constraints: "+err.Error())
return
}
conn, err := myLDAP.Connect()
if err != nil || conn == nil {
log.Println(err)
renderError(http.StatusInternalServerError, "Unable to process your request. Please try again later.")
return
}
defer conn.Close()
if err := conn.ServiceBind(); err != nil {
log.Println(err)
renderError(http.StatusInternalServerError, "Unable to process your request. Please try again later.")
return
}
dn, err := conn.SearchDN(r.PostFormValue("login"), true)
if err != nil {
log.Println(err)
// User not found: perform a dummy bind to prevent username enumeration via timing.
conn.Bind("cn=dummy,"+myLDAP.BaseDN, r.PostFormValue("password"))
renderError(http.StatusUnauthorized, "Invalid login or password.")
return
}
if err := conn.Bind(dn, r.PostFormValue("password")); err != nil {
log.Println(err)
renderError(http.StatusUnauthorized, "Invalid login or password.")
return
}
if err := conn.ChangePassword(dn, r.PostFormValue("newpassword")); err != nil {
log.Println(err)
renderError(http.StatusInternalServerError, "Unable to process your request. Please try again later.")
return
}
displayMsg(w, "Password successfully changed!", http.StatusOK)
}

84
ldap.go
View file

@ -4,8 +4,10 @@ import (
"crypto/rand"
"crypto/tls"
"encoding/base64"
"errors"
"fmt"
"strconv"
"strings"
"time"
"github.com/amoghe/go-crypt"
"github.com/go-ldap/ldap/v3"
@ -19,38 +21,45 @@ type LDAP struct {
BaseDN string
ServiceDN string
ServicePassword string
MailHost string
MailPort int
MailUser string
MailPassword string
MailFrom string
}
type SMTPConfig struct {
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())
}
}
addr := fmt.Sprintf("%s:%d", l.Host, l.Port)
return &LDAPConn{
LDAP: l,
connection: c,
}, nil
var opts []ldap.DialOpt
if l.Ssl {
opts = append(opts, ldap.DialWithTLSConfig(&tls.Config{ServerName: l.Host}))
}
scheme := "ldap"
if l.Ssl {
scheme = "ldaps"
}
c, err := ldap.DialURL(fmt.Sprintf("%s://%s", scheme, addr), opts...)
if err != nil {
return nil, fmt.Errorf("unable to establish %s connection to %s: %w", strings.ToUpper(scheme), addr, err)
}
if l.Starttls {
if err = c.StartTLS(&tls.Config{ServerName: l.Host}); err != nil {
c.Close()
return nil, fmt.Errorf("unable to StartTLS: %w", err)
}
}
return &LDAPConn{
LDAP: l,
connection: c,
}, nil
}
type LDAPConn struct {
@ -58,6 +67,10 @@ type LDAPConn struct {
connection *ldap.Conn
}
func (l LDAPConn) Close() {
l.connection.Close()
}
func (l LDAPConn) ServiceBind() error {
return l.connection.Bind(l.ServiceDN, l.ServicePassword)
}
@ -85,8 +98,11 @@ func (l LDAPConn) SearchDN(username string, person bool) (string, error) {
return "", err
}
if len(sr.Entries) != 1 {
return "", errors.New("User does not exist or too many entries returned")
if len(sr.Entries) == 0 {
return "", fmt.Errorf("user %q not found", username)
}
if len(sr.Entries) > 1 {
return "", fmt.Errorf("multiple entries (%d) found for user %q", len(sr.Entries), username)
}
return sr.Entries[0].DN, nil
@ -104,8 +120,11 @@ func (l LDAPConn) GetEntry(dn string) ([]*ldap.EntryAttribute, error) {
return nil, err
}
if len(sr.Entries) != 1 {
return nil, errors.New("User does not exist or too many entries returned")
if len(sr.Entries) == 0 {
return nil, fmt.Errorf("entry not found for DN %q", dn)
}
if len(sr.Entries) > 1 {
return nil, fmt.Errorf("multiple entries (%d) found for DN %q", len(sr.Entries), dn)
}
return sr.Entries[0].Attributes, nil
@ -133,6 +152,7 @@ func (l LDAPConn) ChangePassword(dn string, rawpassword string) error {
modify := ldap.NewModifyRequest(dn, nil)
modify.Replace("userPassword", []string{"{CRYPT}" + hashedpasswd})
modify.Replace("shadowLastChange", []string{strconv.FormatInt(time.Now().Unix()/86400, 10)})
return l.connection.Modify(modify)
}

View file

@ -78,6 +78,7 @@ func login(login string, password string) ([]*ldap.EntryAttribute, error) {
if err != nil || conn == nil {
return nil, err
}
defer conn.Close()
if err = conn.ServiceBind(); err != nil {
return nil, err

View file

@ -129,6 +129,7 @@ func lostPassword(w http.ResponseWriter, r *http.Request) {
displayTmplError(w, http.StatusInternalServerError, "lost.html", map[string]any{"error": "Unable to process your request. Please try again later."})
return
}
defer conn.Close()
// Generate the token
token, dn, err := lostPasswordToken(conn, r.PostFormValue("login"))
@ -166,14 +167,14 @@ func lostPassword(w http.ResponseWriter, r *http.Request) {
// Send the email
m := gomail.NewMessage()
m.SetHeader("From", myLDAP.MailFrom)
m.SetHeader("From", mySMTP.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)
if mySMTP.MailHost != "" {
d := gomail.NewDialer(mySMTP.MailHost, mySMTP.MailPort, mySMTP.MailUser, mySMTP.MailPassword)
s, err = d.Dial()
if err != nil {
log.Println("Unable to connect to email server: " + err.Error())

31
main.go
View file

@ -31,9 +31,12 @@ var dockerRegistrySecret string
var allowedAliasDomains []string
var myLDAP = LDAP{
Host: "localhost",
Port: 389,
BaseDN: "dc=example,dc=com",
Host: "localhost",
Port: 389,
BaseDN: "dc=example,dc=com",
}
var mySMTP = SMTPConfig{
MailPort: 587,
MailFrom: "noreply@example.com",
}
@ -115,8 +118,13 @@ func main() {
log.Fatal(err)
} else if cnt, err := io.ReadAll(fd); err != nil {
log.Fatal(err)
} else if err := json.Unmarshal(cnt, &myLDAP); err != nil {
log.Fatal(err)
} else {
if err := json.Unmarshal(cnt, &myLDAP); err != nil {
log.Fatal(err)
}
if err := json.Unmarshal(cnt, &mySMTP); err != nil {
log.Fatal(err)
}
}
}
@ -156,17 +164,17 @@ func main() {
}
if val, ok := os.LookupEnv("SMTP_HOST"); ok {
myLDAP.MailHost = val
mySMTP.MailHost = val
}
if val, ok := os.LookupEnv("SMTP_PORT"); ok {
if port, err := strconv.Atoi(val); err == nil {
myLDAP.MailPort = port
mySMTP.MailPort = port
} else {
log.Println("Invalid value for SMTP_PORT:", val)
}
}
if val, ok := os.LookupEnv("SMTP_USER"); ok {
myLDAP.MailUser = val
mySMTP.MailUser = val
}
if val, ok := os.LookupEnv("SMTP_PASSWORD_FILE"); ok {
if fd, err := os.Open(val); err != nil {
@ -176,13 +184,13 @@ func main() {
log.Fatal(err)
} else {
fd.Close()
myLDAP.MailPassword = string(cnt)
mySMTP.MailPassword = string(cnt)
}
} else if val, ok := os.LookupEnv("SMTP_PASSWORD"); ok {
myLDAP.MailPassword = val
mySMTP.MailPassword = val
}
if val, ok := os.LookupEnv("SMTP_FROM"); ok {
myLDAP.MailFrom = val
mySMTP.MailFrom = val
}
if val, ok := os.LookupEnv("PUBLIC_URL"); ok {
myPublicURL = val
@ -207,6 +215,7 @@ func main() {
if err != nil || conn == nil {
log.Fatalf("Unable to connect to LDAP: %s", err.Error())
}
defer conn.Close()
token, dn, err := lostPasswordToken(conn, login)
if err != nil {

View file

@ -79,6 +79,7 @@ func resetPassword(w http.ResponseWriter, r *http.Request) {
renderError(http.StatusInternalServerError, "Unable to process your request. Please try again later.")
return
}
defer conn.Close()
// Bind as service to perform the password change
err = conn.ServiceBind()