Compare commits

...

8 Commits

Author SHA1 Message Date
Chris F Ravenscroft c07a1c978c ci: release 2.5.2 (#475) 2026-07-25 13:38:12 -07:00
Chris F Ravenscroft c1c57c62e9 fix: configure 2.5.2 release 2026-07-25 13:35:53 -07:00
Chris F Ravenscroft 700745e640 feat: check plugin compatibility at release time 2026-07-25 13:34:44 -07:00
Chris F Ravenscroft 1c497d76ba ci: release 2.5.1 (#472)
* ci: release 2.5.1

* chore: set version to v2.5.1
2026-07-18 15:20:33 -07:00
Chris F Ravenscroft cf0eb7f43f Pr 470 (#473)
* fix: reject disabled users on all backends in Bind

A user with disabled = true could still bind on the config backend, since
configHandler.FindUser and LDAPOpsHelper.Bind never checked Disabled while the
SQL backends drop such users at lookup. Enforce it in Bind so every backend
behaves the same.

* fix: use configHandler.Bind rather than helper.Bind in test harness

---------

Co-authored-by: kanywst <niwatakuma@icloud.com>
2026-07-18 14:59:42 -07:00
kt fb65713fbd fix: reject disabled users on all backends in Bind (#470)
A user with disabled = true could still bind on the config backend, since
configHandler.FindUser and LDAPOpsHelper.Bind never checked Disabled while the
SQL backends drop such users at lookup. Enforce it in Bind so every backend
behaves the same.
2026-07-18 14:53:08 -07:00
Chris F Ravenscroft cb121cdc2c fix: team atlanta arbitrary password (#471) 2026-07-18 14:32:05 -07:00
Jörn Friedrich Dreyer 4b2b1398b8 Fix typo in contributing section of README (#468) 2026-07-18 13:31:54 -07:00
12 changed files with 415 additions and 19 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
{
"v2": "2.5.0"
"v2": "2.5.2"
}
+1 -1
View File
@@ -21,7 +21,7 @@ Use it to centralize account management across your Linux servers, your OSX mach
### Contributing
- Please base all Pull Requests on [dev](https://github.com/glauth/glauth/tree/dev), not master.
- Format your code autonmatically using `gofmt -d ./` before committing
- Format your code automatically using `gofmt -d ./` before committing
### Quickstart
This quickstart is a great way to try out GLAuth in a non-production environment. *Be warned that you should take the extra steps to setup SSL (TLS) for production use!*
+1 -3
View File
@@ -1,5 +1,3 @@
go 1.21.0
toolchain go1.21.5
go 1.24.0
use ./v2
+2 -1
View File
@@ -3,9 +3,10 @@
"v2": {
"release-type": "go",
"package-name": "GLAuth",
"release-as": "2.5.2",
"pull-request-title-pattern": "ci: release ${version}",
"extra-files": [
"v2/internal/version/const.go"
"internal/version/const.go"
]
}
},
+15
View File
@@ -1,5 +1,20 @@
# Changelog
## [2.5.2](https://github.com/glauth/glauth/compare/GLAuth-v2.5.1...GLAuth-v2.5.2) (2026-07-25)
### Features
* check plugin compatibility at release time ([700745e](https://github.com/glauth/glauth/commit/700745e640c5371361a58ef3c90245a4637c5224))
## [2.5.1](https://github.com/glauth/glauth/compare/GLAuth-v2.5.0...GLAuth-v2.5.1) (2026-07-18)
### Bug Fixes
* reject disabled users on all backends in Bind ([#470](https://github.com/glauth/glauth/issues/470)) ([fb65713](https://github.com/glauth/glauth/commit/fb65713fbdfd626cb033675fb42724a5fb3d06ff))
* team atlanta arbitrary password ([#471](https://github.com/glauth/glauth/issues/471)) ([cb121cd](https://github.com/glauth/glauth/commit/cb121cdc2c9b831abdf7831835ec30ead2536a7a))
## [2.5.0](https://github.com/glauth/glauth/compare/GLAuth-v2.5.0...GLAuth-v2.5.0) (2026-04-19)
+39 -7
View File
@@ -58,10 +58,9 @@ mocks:
vet:
-go vet ./...
.PHONY: vet
#test: @ runs the integration test on linuxamd64 (eventually allow the binary to be set)
#test: @ runs package, root integration, and shell integration tests
test: mocks vet
# also run unit tests for packages, skip glauth_test.go for now
go test -v -cover -coverprofile coverage.out ./pkg/... ./internal/...
go test -v -cover -coverprofile coverage.out ./pkg/... ./internal/... .
$(MAKE) runtest
#all: @ run build process for all binaries
all: setup binaries verify
@@ -120,6 +119,39 @@ format:
devrun:
go run ${BUILD_FILES} -c sample-simple.cfg
#testplugin: @ start GLAuth with a dynamically loaded plugin and stop it cleanly
GLAUTH_BIN?=bin/glauth-linux-amd64
PLUGIN_BIN?=bin/sqlite-linux-amd64.so
PLUGIN_CONFIG?=pkg/plugins/glauth-sqlite/sample-database.cfg
PLUGIN_LDAP_PORT?=13893
PLUGIN_API_PORT?=15555
testplugin:
@set -e; \
test -x "$(GLAUTH_BIN)"; \
test -r "$(PLUGIN_BIN)"; \
test -f "$(PLUGIN_CONFIG)"; \
tmpdir=$$(mktemp -d); \
trap 'rm -rf "$$tmpdir"' EXIT; \
sed \
-e 's#^[[:space:]]*plugin = .*# plugin = "$(abspath $(PLUGIN_BIN))"#' \
-e "s#^[[:space:]]*database = .*# database = \"$$tmpdir/gl.db\"#" \
-e 's#listen = "0.0.0.0:3893"#listen = "127.0.0.1:$(PLUGIN_LDAP_PORT)"#' \
-e 's#listen = "0.0.0.0:5555"#listen = "127.0.0.1:$(PLUGIN_API_PORT)"#' \
"$(PLUGIN_CONFIG)" > "$$tmpdir/config.cfg"; \
"$(GLAUTH_BIN)" -c "$$tmpdir/config.cfg" > "$$tmpdir/glauth.log" 2>&1 & \
pid=$$!; \
sleep 2; \
if ! kill -0 "$$pid" 2>/dev/null; then \
set +e; wait "$$pid"; status=$$?; set -e; \
cat "$$tmpdir/glauth.log"; \
exit $$status; \
fi; \
kill -TERM "$$pid"; \
set +e; wait "$$pid"; status=$$?; set -e; \
cat "$$tmpdir/glauth.log"; \
exit $$status
mkbindir:
@echo "create directory bin/$(GOOS)$(GOARCH)"
@mkdir -p bin/$(GOOS)$(GOARCH)
@@ -269,7 +301,7 @@ _checkdockerauth:
builddockermain:
$(if $(TAG),,$(error ❌ Must set TAG))
$(if $(REPO),,$(error ❌ Must set REPO - glauth or other))
@mkdir -p docker/assets/linux/amd64 docker/assets/linux/arm64 docker/assets/linux/arm/v7 && \
@mkdir -p docker/assets/linux/amd64 docker/assets/linux/arm64 docker/assets/linux/arm/v7 docker/assets/linux/riscv64 && \
cp -f bin/glauth-linux-amd64 docker/assets/linux/amd64/glauth && \
cp -f bin/glauth-linux-arm64 docker/assets/linux/arm64/glauth && \
cp -f bin/glauth-linux-arm-7 docker/assets/linux/arm/v7/glauth && \
@@ -280,7 +312,7 @@ builddockermain:
builddockerplugins:
$(if $(TAG),,$(error ❌ Must set TAG))
$(if $(REPO),,$(error ❌ Must set REPO - glauth or other))
@mkdir -p docker/assets/linux/amd64 docker/assets/linux/arm64 docker/assets/linux/arm/v7 && \
@mkdir -p docker/assets/linux/amd64 docker/assets/linux/arm64 docker/assets/linux/arm/v7 docker/assets/linux/riscv64 && \
cp -f bin/sqlite-linux-amd64.so docker/assets/linux/amd64/sqlite.so && \
cp -f bin/sqlite-linux-arm64.so docker/assets/linux/arm64/sqlite.so && \
cp -f bin/sqlite-linux-arm-7.so docker/assets/linux/arm/v7/sqlite.so && \
@@ -298,7 +330,7 @@ builddockerplugins:
builddocker: _checkdockerauth builddockermain builddockerplugins
buildeverything: prepbuild forget-embed _buildmain _buildplugins remember-embed _buildmainembeds builddocker
buildeverything: prepbuild forget-embed _buildmain _buildplugins testplugin remember-embed _buildmainembeds builddocker
#testdocker: @ run integration test using docker
testdocker:
@@ -344,4 +376,4 @@ testdocker:
prepbuild run test fast binaries setup getdeps runtest updatetest format devrun \
linux386 linuxamd64 linuxarm linuxarm64 linuxriscv64 darwinamd64 darwinarm64 win386 winamd64 verify \
pull-plugin pull-base-plugins forget-plugin forget-plugins buildmain buildplugin build \
buildplugins builddockermain builddockerplugins builddocker testdocker buildeverything
buildplugins builddockermain builddockerplugins builddocker testdocker testplugin buildeverything
+117 -5
View File
@@ -1,8 +1,12 @@
package main
import (
"fmt"
"net"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
@@ -23,10 +27,22 @@ type testEnv struct {
checkemployeetype string
}
func glauthBinary() string {
if binary := os.Getenv("GLAUTH_TEST_BINARY"); binary != "" {
return binary
}
qualified := filepath.Join("bin", runtime.GOOS+runtime.GOARCH, "glauth")
if _, err := os.Stat(qualified); err == nil {
return qualified
}
return "bin/glauth"
}
func TestProperBuild(t *testing.T) {
info, err := os.Stat("bin/glauth")
info, err := os.Stat(glauthBinary())
if err != nil {
t.Error(err)
t.Fatal(err)
}
mode := uint32(info.Mode())
if mode&0b001001001 == 0 {
@@ -34,6 +50,102 @@ func TestProperBuild(t *testing.T) {
}
}
func TestConfigBackendRejectsPasswordlessUserBinds(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
port := listener.Addr().(*net.TCPAddr).Port
listener.Close()
configPath := filepath.Join(t.TempDir(), "passwordless.cfg")
config := fmt.Sprintf(`debug = false
[ldap]
enabled = true
listen = "127.0.0.1:%d"
tls = false
[ldaps]
enabled = false
[backend]
datastore = "config"
baseDN = "dc=glauth,dc=com"
[behaviors]
LimitFailedBinds = false
[[groups]]
name = "svc"
gidnumber = 5501
[[users]]
name = "nopass"
uidnumber = 5001
primarygroup = 5501
[[users.capabilities]]
action = "search"
object = "*"
`, port)
if err := os.WriteFile(configPath, []byte(config), 0600); err != nil {
t.Fatal(err)
}
svc := startSvc(SD, glauthBinary(), "-c", configPath)
defer stopSvc(svc)
bindDN := "cn=nopass,dc=glauth,dc=com"
for _, tc := range []struct {
name string
password string
want string
}{
{name: "arbitrary password", password: "definitely-wrong", want: "exit status 49"},
{name: "empty password", password: "", want: "exit status 53"},
} {
t.Run(tc.name, func(t *testing.T) {
got := doRunGetFirst(0, "ldapsearch", "-LLL", "-H", fmt.Sprintf("ldap://127.0.0.1:%d", port), "-D", bindDN, "-w", tc.password, "-x", "-b", "dc=glauth,dc=com", "(cn=nopass)")
if got != tc.want {
t.Fatalf("bind result = %q, want %q", got, tc.want)
}
})
}
}
func ldapOnlyConfig(t *testing.T, source string) string {
t.Helper()
data, err := os.ReadFile(source)
if err != nil {
t.Fatal(err)
}
contents := string(data)
sectionMarker := "\n[ldaps]\n"
markerStart := strings.Index(contents, sectionMarker)
if markerStart < 0 {
t.Fatalf("%s has no [ldaps] section", source)
}
start := markerStart + 1
relEnd := strings.Index(contents[start+len("[ldaps]"):], "\n[")
if relEnd < 0 {
t.Fatalf("%s has no section after [ldaps]", source)
}
end := start + len("[ldaps]") + relEnd
section := contents[start:end]
sectionWithoutLDAPS := strings.Replace(section, "enabled = true", "enabled = false", 1)
if section == sectionWithoutLDAPS {
t.Fatalf("%s [ldaps] section does not enable LDAPS", source)
}
contents = contents[:start] + sectionWithoutLDAPS + contents[end:]
path := filepath.Join(t.TempDir(), filepath.Base(source))
if err := os.WriteFile(path, []byte(contents), 0600); err != nil {
t.Fatal(err)
}
return path
}
func batteryOfTests(t *testing.T, env *testEnv) {
tests := []struct {
Name string
@@ -195,7 +307,7 @@ func TestSampleSimple(t *testing.T) {
checkemployeetype: "cn=hackers",
}
svc := startSvc(SD, "bin/glauth", "-c", "sample-simple.cfg")
svc := startSvc(SD, glauthBinary(), "-c", ldapOnlyConfig(t, "sample-simple.cfg"))
batteryOfTests(t, &env)
stopSvc(svc)
}
@@ -221,7 +333,7 @@ func TestSQLitePlugin(t *testing.T) {
checkemployeetype: "",
}
svc := startSvc(SD, "bin/glauth", "-c", "pkg/plugins/glauth-sqlite/sample-database.cfg")
svc := startSvc(SD, glauthBinary(), "-c", "pkg/plugins/glauth-sqlite/sample-database.cfg")
batteryOfTests(t, &env)
stopSvc(svc)
}
@@ -247,7 +359,7 @@ func TestLdapInjection(t *testing.T) {
checkemployeetype: "",
}
svc := startSvc(SD, "bin/glauth", "-c", "sample-ldap-injection.cfg")
svc := startSvc(SD, glauthBinary(), "-c", "sample-ldap-injection.cfg")
batteryOfTests(t, &env)
stopSvc(svc)
}
+1 -1
View File
@@ -2,4 +2,4 @@ package version
// Version is the tag of the latest release, it gets changed by the ci
// process when a release is happening
const Version = "v2.5.0" // x-release-please-version
const Version = "v2.5.2" // x-release-please-version
+69
View File
@@ -0,0 +1,69 @@
package handler
import (
"encoding/hex"
"net"
"testing"
"github.com/rs/zerolog"
"go.opentelemetry.io/otel/trace/noop"
"golang.org/x/crypto/bcrypt"
"github.com/glauth/glauth/v2/pkg/config"
"github.com/glauth/ldap"
)
type disabledBindTestMonitor struct{}
func (disabledBindTestMonitor) SetResponseTimeMetric(map[string]string, float64) error {
return nil
}
func (disabledBindTestMonitor) SetLDAPMetric(map[string]string, float64) error {
return nil
}
func TestConfigBackendDisabledUserCannotBind(t *testing.T) {
pw := "correct horse"
raw, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.MinCost)
if err != nil {
t.Fatal(err)
}
hexHash := hex.EncodeToString(raw)
cfg := &config.Config{}
cfg.Users = []config.User{
{Name: "activeuser", PrimaryGroup: 5501, PassBcrypt: hexHash, Disabled: false},
{Name: "disableduser", PrimaryGroup: 5501, PassBcrypt: hexHash, Disabled: true},
}
logger := zerolog.Nop()
tracer := noop.NewTracerProvider().Tracer("f001")
helper := NewLDAPOpsHelper(tracer)
h := configHandler{
backend: config.Backend{
BaseDN: "dc=example,dc=com",
NameFormatAsArray: []string{"cn"},
GroupFormatAsArray: []string{"ou"},
},
log: &logger,
cfg: cfg,
ldohelper: helper,
monitor: disabledBindTestMonitor{},
tracer: tracer,
}
_, server := net.Pipe()
defer server.Close()
bind := func(dn, pass string) ldap.LDAPResultCode {
code, _ := h.Bind(dn, pass, server)
return code
}
if code := bind("cn=activeuser,dc=example,dc=com", pw); code != ldap.LDAPResultSuccess {
t.Fatalf("active user with correct pw should bind, got %d", code)
}
if code := bind("cn=disableduser,dc=example,dc=com", pw); code != ldap.LDAPResultInvalidCredentials {
t.Errorf("disabled user must be rejected; got code %d, want InvalidCredentials(49)", code)
}
}
+2
View File
@@ -121,6 +121,8 @@ func (h ldapHandler) Bind(bindDN, bindSimplePw string, conn net.Conn) (result ld
validotp := false
// Local records augment proxy authentication with OTP/YubiKey checks;
// password authentication remains delegated to the upstream LDAP server.
// Find the user
// We are going to go through all backends and ask
// until we find our user or die of boredom.
+26
View File
@@ -65,6 +65,10 @@ func NewLDAPOpsHelper(tracer trace.Tracer) LDAPOpsHelper {
return helper
}
func isUnauthenticatedSimpleBind(bindDN, bindSimplePw string) bool {
return bindDN != "" && bindSimplePw == ""
}
func (l LDAPOpsHelper) Bind(ctx context.Context, h LDAPOpsHandler, bindDN, bindSimplePw string, conn net.Conn) (resultCode ldap.LDAPResultCode, err error) {
ctx, span := l.tracer.Start(ctx, "handler.LDAPOpsHelper.Bind")
defer span.End()
@@ -86,11 +90,24 @@ func (l LDAPOpsHelper) Bind(ctx context.Context, h LDAPOpsHandler, bindDN, bindS
return ldap.LDAPResultSuccess, nil
}
// A non-empty DN with an empty password is LDAP's unauthenticated bind
// mechanism. It must not establish the named user's authorization state.
if isUnauthenticatedSimpleBind(bindDN, bindSimplePw) {
h.GetLog().Info().Str("binddn", bindDN).Str("src", conn.RemoteAddr().String()).Msg("unauthenticated bind rejected")
return ldap.LDAPResultUnwillingToPerform, nil
}
user, ldapcode := l.findUser(ctx, h, bindDN, true /* checkGroup */)
if ldapcode != ldap.LDAPResultSuccess {
return ldapcode, nil
}
// a disabled account must never authenticate, on any backend
if user.Disabled {
h.GetLog().Info().Str("binddn", bindDN).Str("src", conn.RemoteAddr().String()).Msg("Bind attempt on disabled account")
return ldap.LDAPResultInvalidCredentials, nil
}
validotp := false
if len(user.Yubikey) == 0 && len(user.OTPSecret) == 0 {
@@ -175,6 +192,15 @@ func (l LDAPOpsHelper) Bind(ctx context.Context, h LDAPOpsHandler, bindDN, bindS
return ldap.LDAPResultInvalidCredentials, nil
}
// Reaching this point without a configured primary password verifier must
// not be treated as successful authentication. App-password matches and
// custom authentication return above when they succeed.
if user.PassBcrypt == "" && user.PassSHA256 == "" {
h.GetLog().Info().Str("binddn", bindDN).Str("src", conn.RemoteAddr().String()).Msg("invalid credentials")
l.maybePutInTimeout(ctx, h, conn, true)
return ldap.LDAPResultInvalidCredentials, nil
}
// Now, check the pasword hash
if user.PassBcrypt != "" {
decoded, err := hex.DecodeString(user.PassBcrypt)
+141
View File
@@ -0,0 +1,141 @@
package handler
import (
"context"
"crypto/sha256"
"encoding/hex"
"net"
"testing"
"github.com/GeertJohan/yubigo"
"github.com/rs/zerolog"
"go.opentelemetry.io/otel/trace"
"github.com/glauth/glauth/v2/pkg/config"
"github.com/glauth/ldap"
)
type bindTestHandler struct {
backend config.Backend
cfg config.Config
log zerolog.Logger
user config.User
}
func (h *bindTestHandler) GetBackend() config.Backend { return h.backend }
func (h *bindTestHandler) GetLog() *zerolog.Logger { return &h.log }
func (h *bindTestHandler) GetCfg() *config.Config { return &h.cfg }
func (h *bindTestHandler) GetYubikeyAuth() *yubigo.YubiAuth {
return nil
}
func (h *bindTestHandler) FindUser(_ context.Context, userName string, _ bool) (bool, config.User, error) {
if userName == h.user.Name {
return true, h.user, nil
}
return false, config.User{}, nil
}
func (h *bindTestHandler) FindGroup(context.Context, string) (bool, config.Group, error) {
return false, config.Group{}, nil
}
func (h *bindTestHandler) FindPosixAccounts(context.Context, string) ([]*ldap.Entry, error) {
return nil, nil
}
func (h *bindTestHandler) FindPosixGroups(context.Context, string) ([]*ldap.Entry, error) {
return nil, nil
}
func sha256Password(password string) string {
hash := sha256.Sum256([]byte(password))
return hex.EncodeToString(hash[:])
}
func TestLDAPOpsHelperBindRejectsUnauthenticatedAndPasswordlessBinds(t *testing.T) {
tests := []struct {
name string
password string
user config.User
wantResult ldap.LDAPResultCode
}{
{
name: "named empty password is unauthenticated bind",
password: "",
wantResult: ldap.LDAPResultUnwillingToPerform,
},
{
name: "arbitrary password without verifier",
password: "definitely-wrong",
wantResult: ldap.LDAPResultInvalidCredentials,
},
{
name: "failed app password does not fall through",
user: config.User{
Name: "nopass",
PassAppSHA256: []string{sha256Password("expected")},
},
password: "definitely-wrong",
wantResult: ldap.LDAPResultInvalidCredentials,
},
{
name: "matching primary password succeeds",
user: config.User{
Name: "nopass",
PassSHA256: sha256Password("correct"),
},
password: "correct",
wantResult: ldap.LDAPResultSuccess,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
user := tt.user
if user.Name == "" {
user.Name = "nopass"
}
h := &bindTestHandler{
backend: config.Backend{
BaseDN: "dc=glauth,dc=com",
NameFormatAsArray: []string{"cn"},
GroupFormatAsArray: []string{"ou"},
},
user: user,
log: zerolog.Nop(),
}
conn, peer := net.Pipe()
defer conn.Close()
defer peer.Close()
helper := NewLDAPOpsHelper(trace.NewNoopTracerProvider().Tracer("bind-test"))
got, err := helper.Bind(context.Background(), h, "cn=nopass,dc=glauth,dc=com", tt.password, conn)
if err != nil {
t.Fatalf("Bind returned error: %v", err)
}
if got != tt.wantResult {
t.Fatalf("Bind result = %v, want %v", got, tt.wantResult)
}
})
}
}
func TestLDAPOpsHelperBindAllowsAnonymousBind(t *testing.T) {
h := &bindTestHandler{
backend: config.Backend{BaseDN: "dc=glauth,dc=com"},
log: zerolog.Nop(),
}
conn, peer := net.Pipe()
defer conn.Close()
defer peer.Close()
helper := NewLDAPOpsHelper(trace.NewNoopTracerProvider().Tracer("bind-test"))
got, err := helper.Bind(context.Background(), h, "", "", conn)
if err != nil {
t.Fatalf("Bind returned error: %v", err)
}
if got != ldap.LDAPResultSuccess {
t.Fatalf("Bind result = %v, want %v", got, ldap.LDAPResultSuccess)
}
}