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

View file

@ -67,31 +67,51 @@ func changePassword(w http.ResponseWriter, r *http.Request) {
// Check the two new passwords are identical // Check the two new passwords are identical
if r.PostFormValue("newpassword") != r.PostFormValue("new2password") { if r.PostFormValue("newpassword") != r.PostFormValue("new2password") {
renderError(http.StatusNotAcceptable, "New passwords are not identical. Please retry.") renderError(http.StatusNotAcceptable, "New passwords are not identical. Please retry.")
} else if len(r.PostFormValue("login")) == 0 { return
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)
}
} }
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/rand"
"crypto/tls" "crypto/tls"
"encoding/base64" "encoding/base64"
"errors"
"fmt" "fmt"
"strconv"
"strings"
"time"
"github.com/amoghe/go-crypt" "github.com/amoghe/go-crypt"
"github.com/go-ldap/ldap/v3" "github.com/go-ldap/ldap/v3"
@ -19,38 +21,45 @@ type LDAP struct {
BaseDN string BaseDN string
ServiceDN string ServiceDN string
ServicePassword string ServicePassword string
MailHost string }
MailPort int
MailUser string type SMTPConfig struct {
MailPassword string MailHost string
MailFrom string MailPort int
MailUser string
MailPassword string
MailFrom string
} }
func (l LDAP) Connect() (*LDAPConn, error) { func (l LDAP) Connect() (*LDAPConn, error) {
if l.Ssl { addr := fmt.Sprintf("%s:%d", l.Host, l.Port)
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{ var opts []ldap.DialOpt
LDAP: l, if l.Ssl {
connection: c, opts = append(opts, ldap.DialWithTLSConfig(&tls.Config{ServerName: l.Host}))
}, nil
} }
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 { type LDAPConn struct {
@ -58,6 +67,10 @@ type LDAPConn struct {
connection *ldap.Conn connection *ldap.Conn
} }
func (l LDAPConn) Close() {
l.connection.Close()
}
func (l LDAPConn) ServiceBind() error { func (l LDAPConn) ServiceBind() error {
return l.connection.Bind(l.ServiceDN, l.ServicePassword) return l.connection.Bind(l.ServiceDN, l.ServicePassword)
} }
@ -85,8 +98,11 @@ func (l LDAPConn) SearchDN(username string, person bool) (string, error) {
return "", err return "", err
} }
if len(sr.Entries) != 1 { if len(sr.Entries) == 0 {
return "", errors.New("User does not exist or too many entries returned") 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 return sr.Entries[0].DN, nil
@ -104,8 +120,11 @@ func (l LDAPConn) GetEntry(dn string) ([]*ldap.EntryAttribute, error) {
return nil, err return nil, err
} }
if len(sr.Entries) != 1 { if len(sr.Entries) == 0 {
return nil, errors.New("User does not exist or too many entries returned") 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 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 := ldap.NewModifyRequest(dn, nil)
modify.Replace("userPassword", []string{"{CRYPT}" + hashedpasswd}) modify.Replace("userPassword", []string{"{CRYPT}" + hashedpasswd})
modify.Replace("shadowLastChange", []string{strconv.FormatInt(time.Now().Unix()/86400, 10)})
return l.connection.Modify(modify) 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 { if err != nil || conn == nil {
return nil, err return nil, err
} }
defer conn.Close()
if err = conn.ServiceBind(); err != nil { if err = conn.ServiceBind(); err != nil {
return nil, err 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."}) displayTmplError(w, http.StatusInternalServerError, "lost.html", map[string]any{"error": "Unable to process your request. Please try again later."})
return return
} }
defer conn.Close()
// Generate the token // Generate the token
token, dn, err := lostPasswordToken(conn, r.PostFormValue("login")) token, dn, err := lostPasswordToken(conn, r.PostFormValue("login"))
@ -166,14 +167,14 @@ func lostPassword(w http.ResponseWriter, r *http.Request) {
// Send the email // Send the email
m := gomail.NewMessage() m := gomail.NewMessage()
m.SetHeader("From", myLDAP.MailFrom) m.SetHeader("From", mySMTP.MailFrom)
m.SetHeader("To", email) m.SetHeader("To", email)
m.SetHeader("Subject", "SSO nemunai.re: password recovery") 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") 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 var s gomail.Sender
if myLDAP.MailHost != "" { if mySMTP.MailHost != "" {
d := gomail.NewDialer(myLDAP.MailHost, myLDAP.MailPort, myLDAP.MailUser, myLDAP.MailPassword) d := gomail.NewDialer(mySMTP.MailHost, mySMTP.MailPort, mySMTP.MailUser, mySMTP.MailPassword)
s, err = d.Dial() s, err = d.Dial()
if err != nil { if err != nil {
log.Println("Unable to connect to email server: " + err.Error()) 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 allowedAliasDomains []string
var myLDAP = LDAP{ var myLDAP = LDAP{
Host: "localhost", Host: "localhost",
Port: 389, Port: 389,
BaseDN: "dc=example,dc=com", BaseDN: "dc=example,dc=com",
}
var mySMTP = SMTPConfig{
MailPort: 587, MailPort: 587,
MailFrom: "noreply@example.com", MailFrom: "noreply@example.com",
} }
@ -115,8 +118,13 @@ func main() {
log.Fatal(err) log.Fatal(err)
} else if cnt, err := io.ReadAll(fd); err != nil { } else if cnt, err := io.ReadAll(fd); err != nil {
log.Fatal(err) log.Fatal(err)
} else if err := json.Unmarshal(cnt, &myLDAP); err != nil { } else {
log.Fatal(err) 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 { if val, ok := os.LookupEnv("SMTP_HOST"); ok {
myLDAP.MailHost = val mySMTP.MailHost = val
} }
if val, ok := os.LookupEnv("SMTP_PORT"); ok { if val, ok := os.LookupEnv("SMTP_PORT"); ok {
if port, err := strconv.Atoi(val); err == nil { if port, err := strconv.Atoi(val); err == nil {
myLDAP.MailPort = port mySMTP.MailPort = port
} else { } else {
log.Println("Invalid value for SMTP_PORT:", val) log.Println("Invalid value for SMTP_PORT:", val)
} }
} }
if val, ok := os.LookupEnv("SMTP_USER"); ok { if val, ok := os.LookupEnv("SMTP_USER"); ok {
myLDAP.MailUser = val mySMTP.MailUser = val
} }
if val, ok := os.LookupEnv("SMTP_PASSWORD_FILE"); ok { if val, ok := os.LookupEnv("SMTP_PASSWORD_FILE"); ok {
if fd, err := os.Open(val); err != nil { if fd, err := os.Open(val); err != nil {
@ -176,13 +184,13 @@ func main() {
log.Fatal(err) log.Fatal(err)
} else { } else {
fd.Close() fd.Close()
myLDAP.MailPassword = string(cnt) mySMTP.MailPassword = string(cnt)
} }
} else if val, ok := os.LookupEnv("SMTP_PASSWORD"); ok { } else if val, ok := os.LookupEnv("SMTP_PASSWORD"); ok {
myLDAP.MailPassword = val mySMTP.MailPassword = val
} }
if val, ok := os.LookupEnv("SMTP_FROM"); ok { if val, ok := os.LookupEnv("SMTP_FROM"); ok {
myLDAP.MailFrom = val mySMTP.MailFrom = val
} }
if val, ok := os.LookupEnv("PUBLIC_URL"); ok { if val, ok := os.LookupEnv("PUBLIC_URL"); ok {
myPublicURL = val myPublicURL = val
@ -207,6 +215,7 @@ func main() {
if err != nil || conn == nil { if err != nil || conn == nil {
log.Fatalf("Unable to connect to LDAP: %s", err.Error()) log.Fatalf("Unable to connect to LDAP: %s", err.Error())
} }
defer conn.Close()
token, dn, err := lostPasswordToken(conn, login) token, dn, err := lostPasswordToken(conn, login)
if err != nil { 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.") renderError(http.StatusInternalServerError, "Unable to process your request. Please try again later.")
return return
} }
defer conn.Close()
// Bind as service to perform the password change // Bind as service to perform the password change
err = conn.ServiceBind() err = conn.ServiceBind()