Skip to content

Commit 4a27bfd

Browse files
authored
✨ Add simple bind authentication (#9)
Handle simple bind requests of the server using the username and password fields. The username is the DN of an entry that must be of an appropriate `objectClass` for authentication and contain a `userPassword` attribute containing a value with a supported hash scheme. The password passed to the bind method is hashed according to the scheme and compared to the stored hash, and if it matches, the bind method returns success. Pull-request: #9
1 parent 44f5d69 commit 4a27bfd

File tree

3 files changed

+275
-5
lines changed

3 files changed

+275
-5
lines changed

db.go

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,29 @@
11
package main
22

33
import (
4+
"bytes"
5+
"cmp"
6+
"crypto/sha1" //nolint:gosec // may be weak, but we use it
7+
"crypto/sha256"
8+
"crypto/sha512"
9+
"encoding/base64"
410
"errors"
511
"fmt"
12+
"hash"
613
"iter"
714
"slices"
815
"strconv"
916
"strings"
1017
)
1118

19+
var (
20+
ErrInvalidEntryForAuth = errors.New("invalid entry for authentication")
21+
ErrAuthenticationFailed = errors.New("authentication failed")
22+
ErrMalformedBase64 = errors.New("hashtext base64 encoding malformed")
23+
ErrHashtextTooShort = errors.New("hashtext too short")
24+
ErrMissingSalt = errors.New("hashtext has missing salt")
25+
)
26+
1227
// DB is an LDAP database, which is just a collection of entries and indicies
1328
// over that collection. The DIT is the hierarchy of entries based on each
1429
// entries DN.
@@ -54,6 +69,94 @@ func (e *Entry) GetAttr(attr string) (Attr, bool) {
5469
return v, ok
5570
}
5671

72+
// Authenticate checks that the LDAP Entry is valid for authentication and that
73+
// the entry has a password that matches the given password. If so nil is
74+
// returned. Otherwise an error is returned.
75+
//
76+
// The form of hashed password in an entry is described by "[RFC 2307, 5.3]
77+
// Interpreting user and group entries" with the supported schemes being SSHA
78+
// (salted SHA-1), SSHA256 (salted SHA-256) and SSHA512 (salted SHA-512). This
79+
// is how [OpenLDAP passwords] are done, so this does it the same. e.g.
80+
// "{SSHA}<base64-encoded-hash+salt>".
81+
//
82+
// [RFC 2307, 5.3]: https://datatracker.ietf.org/doc/html/rfc2307#section-5.3
83+
// [OpenLDAP passwords]: https://www.openldap.org/faq/data/cache/347.html
84+
func (e *Entry) Authenticate(password string) error {
85+
if !e.isValidForAuthentication() {
86+
return ErrInvalidEntryForAuth
87+
}
88+
89+
// hashSchemes is a list of supported password hashing schemes
90+
// with their functions for creating a hash.Hash for that scheme.
91+
hashSchemes := map[string]func() hash.Hash{
92+
"{SSHA}": sha1.New,
93+
"{SSHA256}": sha256.New,
94+
"{SSHA512}": sha512.New,
95+
}
96+
97+
var firstErr error
98+
hashedPasswords, _ := e.GetAttr("userPassword")
99+
for _, hashedPassword := range hashedPasswords.Vals {
100+
scheme, hashtext, ok := splitScheme(hashedPassword)
101+
if !ok || hashSchemes[scheme] == nil {
102+
continue
103+
}
104+
ok, err := pwCheckSaltedHash(password, hashtext, hashSchemes[scheme])
105+
if ok {
106+
return nil
107+
}
108+
firstErr = cmp.Or(firstErr, err)
109+
}
110+
111+
return cmp.Or(firstErr, ErrAuthenticationFailed)
112+
}
113+
114+
func (e *Entry) isValidForAuthentication() bool {
115+
// authClasses is a list of objectClasses for entries that are
116+
// valid for authentication. If an entry does not have one of
117+
// these objectClasses, an attempt to authenticate with its DN
118+
// will fail.
119+
authClasses := []string{"posixAccount", "posixGroup", "shadowAccount"}
120+
121+
entryClasses, _ := e.GetAttr("objectClass")
122+
for _, authClass := range authClasses {
123+
if entryClasses.HasValue(authClass) {
124+
return true
125+
}
126+
}
127+
return false
128+
}
129+
130+
func splitScheme(hashedPassword string) (string, string, bool) {
131+
idx := strings.IndexRune(hashedPassword, '}')
132+
if idx == -1 || (len(hashedPassword) > 0 && hashedPassword[0] != '{') {
133+
return "", "", false
134+
}
135+
return hashedPassword[:idx+1], hashedPassword[idx+1:], true
136+
}
137+
138+
func pwCheckSaltedHash(password string, hashtext string, newHash func() hash.Hash) (bool, error) {
139+
h := newHash()
140+
hps, err := base64.StdEncoding.DecodeString(hashtext)
141+
if err != nil {
142+
return false, ErrMalformedBase64
143+
}
144+
if len(hps) < h.Size() {
145+
return false, ErrHashtextTooShort
146+
}
147+
if len(hps) == h.Size() {
148+
return false, ErrMissingSalt
149+
}
150+
151+
expected := hps[:h.Size()]
152+
salt := hps[h.Size():]
153+
154+
h.Write([]byte(password))
155+
h.Write(salt)
156+
157+
return bytes.Equal(h.Sum(nil), expected), nil
158+
}
159+
57160
// HasValue returns true if val is one of the values of the attribute. The
58161
// value is compared case-sensitively, regardless of what an LDAP schema
59162
// may say for that attribute.

db_test.go

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
package main
22

33
import (
4+
"crypto/sha1" //nolint:gosec // may be weak, but we use it
5+
"crypto/sha256"
6+
"crypto/sha512"
7+
"encoding/base64"
8+
"hash"
9+
"io"
410
"testing"
511

612
"github.com/stretchr/testify/require"
@@ -147,3 +153,142 @@ func Test_Entry_CaseInsensitiveAttrs(t *testing.T) {
147153
require.Equal(t, "objectClass", a.Name)
148154
require.Equal(t, emap["objectClass"], a.Vals[0])
149155
}
156+
157+
func Test_Entry_Auth(t *testing.T) {
158+
type testcase struct {
159+
name string
160+
objectClass string
161+
scheme string
162+
userPassword string
163+
expectErr error
164+
}
165+
166+
testfunc := func(t *testing.T, tt testcase) { //nolint:thelper // not a helper
167+
password := "password"
168+
entryMap := map[string]any{
169+
"dn": "uid=alice,dc=example,dc=com",
170+
"objectClass": tt.objectClass,
171+
}
172+
var userPassword []any
173+
if tt.userPassword != "" {
174+
userPassword = append(userPassword, tt.userPassword)
175+
}
176+
if tt.scheme != "" {
177+
hashedPassword := hashPassword(t, password, tt.scheme)
178+
userPassword = append(userPassword, hashedPassword)
179+
}
180+
if userPassword != nil {
181+
entryMap["userPassword"] = userPassword
182+
}
183+
e, err := NewEntryFromMap(entryMap)
184+
require.NoError(t, err)
185+
err = e.Authenticate(password)
186+
if tt.expectErr != nil {
187+
require.ErrorIs(t, err, tt.expectErr)
188+
} else {
189+
require.NoError(t, err)
190+
}
191+
}
192+
193+
testcases := []testcase{
194+
{
195+
name: "Salted SHA-1",
196+
objectClass: "posixAccount",
197+
scheme: "SSHA",
198+
},
199+
{
200+
name: "Salted SHA-256",
201+
objectClass: "posixAccount",
202+
scheme: "SSHA256",
203+
},
204+
{
205+
name: "Salted SHA-512",
206+
objectClass: "posixAccount",
207+
scheme: "SSHA512",
208+
},
209+
{
210+
name: "posixGroup entry",
211+
objectClass: "posixGroup",
212+
scheme: "SSHA",
213+
},
214+
{
215+
name: "shadowAccount entry",
216+
objectClass: "shadowAccount",
217+
scheme: "SSHA",
218+
},
219+
{
220+
name: "multiple schemes",
221+
objectClass: "posixAccount",
222+
userPassword: "{UNKNOWN}R09BVCBpcyBteSBzaGVwaGFyZAo=",
223+
scheme: "SSHA",
224+
},
225+
{
226+
name: "invalid objectClass",
227+
objectClass: "person",
228+
expectErr: ErrInvalidEntryForAuth,
229+
},
230+
{
231+
name: "unknown scheme in entry",
232+
objectClass: "posixAccount",
233+
userPassword: "{UNKNOWN}R09BVCBpcyBteSBzaGVwaGFyZAo=",
234+
expectErr: ErrAuthenticationFailed,
235+
},
236+
{
237+
name: "missing scheme",
238+
objectClass: "posixAccount",
239+
userPassword: "R09BVCBpcyBteSBzaGVwaGFyZAo=",
240+
expectErr: ErrAuthenticationFailed,
241+
},
242+
{
243+
name: "invalid scheme",
244+
objectClass: "posixAccount",
245+
userPassword: "SSHA}R09BVCBpcyBteSBzaGVwaGFyZAo=",
246+
expectErr: ErrAuthenticationFailed,
247+
},
248+
{
249+
name: "malformed base64",
250+
objectClass: "posixAccount",
251+
userPassword: "{SSHA}#$@!@#$",
252+
expectErr: ErrMalformedBase64,
253+
},
254+
{
255+
name: "short hash",
256+
objectClass: "posixAccount",
257+
// echo -n not-hashed | openssl base64
258+
userPassword: "{SSHA}bm90LWhhc2hlZA==",
259+
expectErr: ErrHashtextTooShort,
260+
},
261+
{
262+
name: "missing salt",
263+
objectClass: "posixAccount",
264+
// echo -n password | openssl dgst -binary -sha1 | openssl base64
265+
userPassword: "{SSHA}W6ph5Mm5Pz8GgiULbPgzG37mj9g=",
266+
expectErr: ErrMissingSalt,
267+
},
268+
}
269+
270+
for _, tt := range testcases {
271+
t.Run(tt.name, func(t *testing.T) { testfunc(t, tt) })
272+
}
273+
}
274+
275+
func hashPassword(t *testing.T, password string, scheme string) string {
276+
t.Helper()
277+
278+
newHash := map[string]func() hash.Hash{
279+
"SSHA": sha1.New,
280+
"SSHA256": sha256.New,
281+
"SSHA512": sha512.New,
282+
}
283+
284+
require.Contains(t, newHash, scheme)
285+
286+
// Use a fixed salt in tests
287+
salt := "0123456789ABCDEF"
288+
289+
h := newHash[scheme]()
290+
io.WriteString(h, password) //nolint:errcheck,gosec // cannot error
291+
io.WriteString(h, salt) //nolint:errcheck,gosec // cannot error
292+
293+
return "{" + scheme + "}" + base64.StdEncoding.EncodeToString(append(h.Sum(nil), salt...))
294+
}

server.go

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ func (s *Server) Run(listen string) error {
4444
}
4545

4646
func (s *Server) handleBind(w *gldap.ResponseWriter, r *gldap.Request) {
47+
// Set the default response to InvalidCredentials, so we only
48+
// return success if explicitly overridden.
4749
resp := r.NewBindResponse(gldap.WithResponseCode(gldap.ResultInvalidCredentials))
4850
defer w.Write(resp) //nolint:errcheck // not much to do if it fails
4951

@@ -53,14 +55,34 @@ func (s *Server) handleBind(w *gldap.ResponseWriter, r *gldap.Request) {
5355
return
5456
}
5557

56-
if m.UserName == "" && m.Password == "" {
58+
switch {
59+
case m.UserName == "" && m.Password == "":
5760
slog.Info("anonymous bind")
58-
} else if m.UserName != "" && m.Password != "" {
59-
slog.Info("simple bind", "username", m.UserName, "password", m.Password)
60-
} else {
61-
slog.Error("invalid bind")
61+
case m.UserName != "" && m.Password != "":
62+
bindDN, err := NewDN(m.UserName)
63+
if err != nil {
64+
slog.Error("bind with invalid DN", "error", err.Error(), "username", m.UserName)
65+
return
66+
}
67+
node := s.db.DIT.Find(bindDN)
68+
if node == nil {
69+
slog.Error("bind with unknown DN", "username", m.UserName)
70+
return
71+
}
72+
err = node.Entry.Authenticate(string(m.Password))
73+
if err != nil {
74+
slog.Error("bind failed", "username", m.UserName, "error", err)
75+
return
76+
}
77+
slog.Info("simple bind", "username", m.UserName)
78+
case m.UserName == "":
79+
slog.Error("invalid bind: missing username")
80+
return
81+
case m.Password == "":
82+
slog.Error("invalid bind: missing password")
6283
return
6384
}
85+
// Override InvalidCredentials set above.
6486
resp.SetResultCode(gldap.ResultSuccess)
6587
}
6688

0 commit comments

Comments
 (0)