Skip to content

Commit 6ec8c97

Browse files
authored
feat: add ldap support (#232)
* feat: add ldap support * feat: add insecure option for self-signed certificates * fix: recognize ldap as a username provider * test: fix tests * feat: add configurable search filter * fix: fix error message in ldap search result * refactor: bot suggestions
1 parent 4524e33 commit 6ec8c97

12 files changed

Lines changed: 341 additions & 71 deletions

File tree

cmd/root.go

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"tinyauth/internal/docker"
1414
"tinyauth/internal/handlers"
1515
"tinyauth/internal/hooks"
16+
"tinyauth/internal/ldap"
1617
"tinyauth/internal/providers"
1718
"tinyauth/internal/server"
1819
"tinyauth/internal/types"
@@ -58,10 +59,6 @@ var rootCmd = &cobra.Command{
5859
users, err := utils.GetUsers(config.Users, config.UsersFile)
5960
HandleError(err, "Failed to parse users")
6061

61-
if len(users) == 0 && !utils.OAuthConfigured(config) {
62-
HandleError(errors.New("no users or OAuth configured"), "No users or OAuth configured")
63-
}
64-
6562
// Get domain
6663
log.Debug().Msg("Getting domain")
6764
domain, err := utils.GetUpperDomain(config.AppURL)
@@ -143,8 +140,35 @@ var rootCmd = &cobra.Command{
143140
docker, err := docker.NewDocker()
144141
HandleError(err, "Failed to initialize docker")
145142

143+
// Create LDAP service if configured
144+
var ldapService *ldap.LDAP
145+
146+
if config.LdapAddress != "" {
147+
log.Info().Msg("Using LDAP for authentication")
148+
149+
ldapConfig := types.LdapConfig{
150+
Address: config.LdapAddress,
151+
BindDN: config.LdapBindDN,
152+
BindPassword: config.LdapBindPassword,
153+
BaseDN: config.LdapBaseDN,
154+
Insecure: config.LdapInsecure,
155+
SearchFilter: config.LdapSearchFilter,
156+
}
157+
158+
// Create LDAP service
159+
ldapService, err = ldap.NewLDAP(ldapConfig)
160+
HandleError(err, "Failed to create LDAP service")
161+
} else {
162+
log.Info().Msg("LDAP not configured, using local users or OAuth")
163+
}
164+
165+
// Check if we have any users configured
166+
if len(users) == 0 && !utils.OAuthConfigured(config) && ldapService == nil {
167+
HandleError(errors.New("err no users"), "Unable to find a source of users")
168+
}
169+
146170
// Create auth service
147-
auth := auth.NewAuth(authConfig, docker)
171+
auth := auth.NewAuth(authConfig, docker, ldapService)
148172

149173
// Create OAuth providers service
150174
providers := providers.NewProviders(oauthConfig)
@@ -221,6 +245,12 @@ func init() {
221245
rootCmd.Flags().String("app-title", "Tinyauth", "Title of the app.")
222246
rootCmd.Flags().String("forgot-password-message", "You can reset your password by changing the `USERS` environment variable.", "Message to show on the forgot password page.")
223247
rootCmd.Flags().String("background-image", "/background.jpg", "Background image URL for the login page.")
248+
rootCmd.Flags().String("ldap-address", "", "LDAP server address (e.g. ldap://localhost:389).")
249+
rootCmd.Flags().String("ldap-bind-dn", "", "LDAP bind DN (e.g. uid=user,dc=example,dc=com).")
250+
rootCmd.Flags().String("ldap-bind-password", "", "LDAP bind password.")
251+
rootCmd.Flags().String("ldap-base-dn", "", "LDAP base DN (e.g. dc=example,dc=com).")
252+
rootCmd.Flags().Bool("ldap-insecure", false, "Skip certificate verification for the LDAP server.")
253+
rootCmd.Flags().String("ldap-search-filter", "(uid=%s)", "LDAP search filter for user lookup.")
224254

225255
// Bind flags to environment
226256
viper.BindEnv("port", "PORT")
@@ -256,6 +286,12 @@ func init() {
256286
viper.BindEnv("login-max-retries", "LOGIN_MAX_RETRIES")
257287
viper.BindEnv("forgot-password-message", "FORGOT_PASSWORD_MESSAGE")
258288
viper.BindEnv("background-image", "BACKGROUND_IMAGE")
289+
viper.BindEnv("ldap-address", "LDAP_ADDRESS")
290+
viper.BindEnv("ldap-bind-dn", "LDAP_BIND_DN")
291+
viper.BindEnv("ldap-bind-password", "LDAP_BIND_PASSWORD")
292+
viper.BindEnv("ldap-base-dn", "LDAP_BASE_DN")
293+
viper.BindEnv("ldap-insecure", "LDAP_INSECURE")
294+
viper.BindEnv("ldap-search-filter", "LDAP_SEARCH_FILTER")
259295

260296
// Bind flags to viper
261297
viper.BindPFlags(rootCmd.Flags())

frontend/src/pages/login-page.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -162,9 +162,9 @@ export const LoginPage = () => {
162162
/>
163163
)}
164164
{configuredProviders.length == 0 && (
165-
<h3 className="text-center text-xl text-red-600">
165+
<p className="text-center text-red-600 max-w-sm">
166166
{t("failedToFetchProvidersTitle")}
167-
</h3>
167+
</p>
168168
)}
169169
</CardContent>
170170
</Card>

go.mod

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,13 @@ require (
1616
)
1717

1818
require (
19+
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
1920
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
2021
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
2122
github.com/containerd/errdefs v1.0.0 // indirect
2223
github.com/containerd/errdefs/pkg v0.3.0 // indirect
2324
github.com/containerd/log v0.1.0 // indirect
25+
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
2426
github.com/go-viper/mapstructure/v2 v2.3.0 // indirect
2527
github.com/moby/sys/atomicwriter v0.1.0 // indirect
2628
github.com/moby/term v0.5.2 // indirect
@@ -60,6 +62,7 @@ require (
6062
github.com/fsnotify/fsnotify v1.8.0 // indirect
6163
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
6264
github.com/gin-contrib/sse v1.0.0 // indirect
65+
github.com/go-ldap/ldap/v3 v3.4.11
6366
github.com/go-logr/logr v1.4.2 // indirect
6467
github.com/go-logr/stdr v1.2.2 // indirect
6568
github.com/go-playground/locales v0.14.1 // indirect

go.sum

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
22
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
3+
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8=
4+
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
35
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
46
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
57
github.com/Microsoft/go-winio v0.4.14 h1:+hMXMk01us9KgxGb7ftKQt2Xpf5hH/yky+TDA+qxleU=
68
github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA=
9+
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI=
10+
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
711
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
812
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
913
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
@@ -90,6 +94,10 @@ github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E
9094
github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0=
9195
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
9296
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
97+
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
98+
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
99+
github.com/go-ldap/ldap/v3 v3.4.11 h1:4k0Yxweg+a3OyBLjdYn5OKglv18JNvfDykSoI8bW0gU=
100+
github.com/go-ldap/ldap/v3 v3.4.11/go.mod h1:bY7t0FLK8OAVpp/vV6sSlpz3EQDGcQwc8pF0ujLgKvM=
93101
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
94102
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
95103
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
@@ -126,8 +134,22 @@ github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzq
126134
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
127135
github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1 h1:VNqngBF40hVlDloBruUehVYC3ArSgIyScOAyMRqBxRg=
128136
github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1/go.mod h1:RBRO7fro65R6tjKzYgLAFo0t1QEXY1Dp+i/bvpRiqiQ=
137+
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
138+
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
129139
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
130140
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
141+
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
142+
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
143+
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
144+
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
145+
github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg=
146+
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
147+
github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o=
148+
github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
149+
github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8=
150+
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
151+
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
152+
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
131153
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
132154
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
133155
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=

internal/auth/auth.go

Lines changed: 91 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"sync"
88
"time"
99
"tinyauth/internal/docker"
10+
"tinyauth/internal/ldap"
1011
"tinyauth/internal/types"
1112
"tinyauth/internal/utils"
1213

@@ -22,9 +23,10 @@ type Auth struct {
2223
LoginAttempts map[string]*types.LoginAttempt
2324
LoginMutex sync.RWMutex
2425
Store *sessions.CookieStore
26+
LDAP *ldap.LDAP
2527
}
2628

27-
func NewAuth(config types.AuthConfig, docker *docker.Docker) *Auth {
29+
func NewAuth(config types.AuthConfig, docker *docker.Docker, ldap *ldap.LDAP) *Auth {
2830
// Create cookie store
2931
store := sessions.NewCookieStore([]byte(config.HMACSecret), []byte(config.EncryptionSecret))
3032

@@ -42,6 +44,7 @@ func NewAuth(config types.AuthConfig, docker *docker.Docker) *Auth {
4244
Docker: docker,
4345
LoginAttempts: make(map[string]*types.LoginAttempt),
4446
Store: store,
47+
LDAP: ldap,
4548
}
4649
}
4750

@@ -68,14 +71,97 @@ func (auth *Auth) GetSession(c *gin.Context) (*sessions.Session, error) {
6871
return session, nil
6972
}
7073

71-
func (auth *Auth) GetUser(username string) *types.User {
74+
func (auth *Auth) SearchUser(username string) types.UserSearch {
7275
// Loop through users and return the user if the username matches
76+
log.Debug().Str("username", username).Msg("Searching for user")
77+
78+
if auth.GetLocalUser(username).Username != "" {
79+
log.Debug().Str("username", username).Msg("Found local user")
80+
81+
// If user found, return a user with the username and type "local"
82+
return types.UserSearch{
83+
Username: username,
84+
Type: "local",
85+
}
86+
}
87+
88+
// If no user found, check LDAP
89+
if auth.LDAP != nil {
90+
log.Debug().Str("username", username).Msg("Checking LDAP for user")
91+
92+
userDN, err := auth.LDAP.Search(username)
93+
if err != nil {
94+
log.Warn().Err(err).Str("username", username).Msg("Failed to find user in LDAP")
95+
return types.UserSearch{}
96+
}
97+
98+
// If user found in LDAP, return a user with the DN as username
99+
return types.UserSearch{
100+
Username: userDN,
101+
Type: "ldap",
102+
}
103+
}
104+
105+
return types.UserSearch{}
106+
}
107+
108+
func (auth *Auth) VerifyUser(search types.UserSearch, password string) bool {
109+
// Authenticate the user based on the type
110+
switch search.Type {
111+
case "local":
112+
// Get local user
113+
user := auth.GetLocalUser(search.Username)
114+
115+
// Check if password is correct
116+
return auth.CheckPassword(user, password)
117+
case "ldap":
118+
// If LDAP is configured, bind to the LDAP server with the user DN and password
119+
if auth.LDAP != nil {
120+
log.Debug().Str("username", search.Username).Msg("Binding to LDAP for user authentication")
121+
122+
// Bind to the LDAP server
123+
err := auth.LDAP.Bind(search.Username, password)
124+
if err != nil {
125+
log.Warn().Err(err).Str("username", search.Username).Msg("Failed to bind to LDAP")
126+
return false
127+
}
128+
129+
// If bind is successful, rebind with the LDAP bind user
130+
err = auth.LDAP.Bind(auth.LDAP.Config.BindDN, auth.LDAP.Config.BindPassword)
131+
if err != nil {
132+
log.Error().Err(err).Msg("Failed to rebind with service account after user authentication")
133+
// Consider closing the connection or creating a new one
134+
return false
135+
}
136+
137+
log.Debug().Str("username", search.Username).Msg("LDAP authentication successful")
138+
139+
// Return true if the bind was successful
140+
return true
141+
}
142+
default:
143+
log.Warn().Str("type", search.Type).Msg("Unknown user type for authentication")
144+
return false
145+
}
146+
147+
// If no user found or authentication failed, return false
148+
log.Warn().Str("username", search.Username).Msg("User authentication failed")
149+
return false
150+
}
151+
152+
func (auth *Auth) GetLocalUser(username string) types.User {
153+
// Loop through users and return the user if the username matches
154+
log.Debug().Str("username", username).Msg("Searching for local user")
155+
73156
for _, user := range auth.Config.Users {
74157
if user.Username == username {
75-
return &user
158+
return user
76159
}
77160
}
78-
return nil
161+
162+
// If no user found, return an empty user
163+
log.Warn().Str("username", username).Msg("Local user not found")
164+
return types.User{}
79165
}
80166

81167
func (auth *Auth) CheckPassword(user types.User, password string) bool {
@@ -275,7 +361,7 @@ func (auth *Auth) GetSessionCookie(c *gin.Context) (types.SessionCookie, error)
275361

276362
func (auth *Auth) UserAuthConfigured() bool {
277363
// If there are users, return true
278-
return len(auth.Config.Users) > 0
364+
return len(auth.Config.Users) > 0 || auth.LDAP != nil
279365
}
280366

281367
func (auth *Auth) ResourceAllowed(c *gin.Context, context types.UserContext, labels types.Labels) bool {

internal/auth/auth_test.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ func TestLoginRateLimiting(t *testing.T) {
1818
// Initialize a new auth service with 3 max retries and 5 seconds timeout
1919
config.LoginMaxRetries = 3
2020
config.LoginTimeout = 5
21-
authService := auth.NewAuth(config, &docker.Docker{})
21+
authService := auth.NewAuth(config, &docker.Docker{}, nil)
2222

2323
// Test identifier
2424
identifier := "test_user"
@@ -62,7 +62,7 @@ func TestLoginRateLimiting(t *testing.T) {
6262
// Reinitialize auth service with a shorter timeout for testing
6363
config.LoginTimeout = 1
6464
config.LoginMaxRetries = 3
65-
authService = auth.NewAuth(config, &docker.Docker{})
65+
authService = auth.NewAuth(config, &docker.Docker{}, nil)
6666

6767
// Add enough failed attempts to lock the account
6868
for i := 0; i < 3; i++ {
@@ -87,7 +87,7 @@ func TestLoginRateLimiting(t *testing.T) {
8787
t.Log("Testing disabled rate limiting")
8888
config.LoginMaxRetries = 0
8989
config.LoginTimeout = 0
90-
authService = auth.NewAuth(config, &docker.Docker{})
90+
authService = auth.NewAuth(config, &docker.Docker{}, nil)
9191

9292
for i := 0; i < 10; i++ {
9393
authService.RecordLoginAttempt(identifier, false)
@@ -103,7 +103,7 @@ func TestConcurrentLoginAttempts(t *testing.T) {
103103
// Initialize a new auth service with 2 max retries and 5 seconds timeout
104104
config.LoginMaxRetries = 2
105105
config.LoginTimeout = 5
106-
authService := auth.NewAuth(config, &docker.Docker{})
106+
authService := auth.NewAuth(config, &docker.Docker{}, nil)
107107

108108
// Test multiple identifiers
109109
identifiers := []string{"user1", "user2", "user3"}

0 commit comments

Comments
 (0)