mirror of
https://github.com/go-gitea/gitea.git
synced 2026-11-09 17:37:20 -06:00
Allows an LDAP auth source to list multiple comma-separated servers in the Host field so logins continue working when one server is unreachable. Entries can be plain hosts using the configured Port and Security Protocol fields or `ldap://`, `ldaps://`, or `ldap+starttls://` URLs with their own port and protocol (as proposed in https://github.com/go-gitea/gitea/pull/31649#issuecomment-2505555133), with TLS protocol consistency enforced so an entry cannot silently downgrade encryption. Hosts are tried in order with a 250 ms head start each so an unresponsive server does not block logins for the full 60 s connect timeout, and only connection failures fail over to the next host so failed binds are not retried across servers. Existing single-host configurations continue to work unchanged without a migration. Docs: https://gitea.com/gitea/docs/pulls/635 Fixes https://github.com/go-gitea/gitea/issues/6898 /claim #6898 --------- Co-authored-by: silverwind <me@silverwind.io>
This commit is contained in:
@@ -49,11 +49,11 @@ func commonLdapCLIFlags() []cli.Flag {
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "host",
|
||||
Usage: "The address where the LDAP server can be reached.",
|
||||
Usage: "Comma-separated LDAP hosts or ldap://, ldaps:// or ldap+starttls:// URLs, tried in order.",
|
||||
},
|
||||
&cli.IntFlag{
|
||||
Name: "port",
|
||||
Usage: "The port to use when connecting to the LDAP server.",
|
||||
Usage: "The port to use when connecting to hosts given without a URL.",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "user-search-base",
|
||||
|
||||
@@ -32,11 +32,16 @@ share the following fields:
|
||||
* A name to assign to the new method of authorization.
|
||||
|
||||
* Host **(required)**
|
||||
* The address where the LDAP server can be reached.
|
||||
* Example: mydomain.com
|
||||
* Comma-separated LDAP servers. Each is a host using the Port and Security
|
||||
Protocol fields, or an `ldap://`, `ldaps://` or `ldap+starttls://` URL
|
||||
with an optional port. `ldap://` requires the Unencrypted Security
|
||||
Protocol, the other URLs require an encrypted one.
|
||||
* The next server is tried when the previous one fails or takes over
|
||||
250 ms to connect. Bind and search failures do not move on to it.
|
||||
* Example: ldap1.mydomain.com, ldaps://ldap2.mydomain.com:636
|
||||
|
||||
* Port **(required)**
|
||||
* The port to use when connecting to the server.
|
||||
* The port to use when connecting to hosts given without a URL.
|
||||
* Example: 636
|
||||
|
||||
* Enable TLS Encryption (optional)
|
||||
|
||||
@@ -29,3 +29,9 @@ var SecurityProtocolNames = map[SecurityProtocol]string{
|
||||
SecurityProtocolLDAPS: "LDAPS",
|
||||
SecurityProtocolStartTLS: "StartTLS",
|
||||
}
|
||||
|
||||
var urlSecurityProtocols = map[string]SecurityProtocol{
|
||||
"ldap": SecurityProtocolUnencrypted,
|
||||
"ldaps": SecurityProtocolLDAPS,
|
||||
"ldap+starttls": SecurityProtocolStartTLS,
|
||||
}
|
||||
|
||||
@@ -6,13 +6,17 @@ package ldap
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dev/modules/container"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"github.com/go-ldap/ldap/v3"
|
||||
)
|
||||
@@ -111,26 +115,85 @@ func (source *Source) findUserDN(l *ldap.Conn, name string) (string, bool) {
|
||||
func dial(source *Source) (*ldap.Conn, error) {
|
||||
log.Trace("Dialing LDAP with security protocol (%v) without verifying: %v", source.SecurityProtocol, source.SkipVerify)
|
||||
|
||||
hosts := util.SplitTrimSpace(source.Host, ",")
|
||||
if len(hosts) == 0 {
|
||||
return nil, errors.New("no LDAP host configured")
|
||||
}
|
||||
|
||||
type dialResult struct {
|
||||
index int
|
||||
conn *ldap.Conn
|
||||
err error
|
||||
}
|
||||
results := make(chan dialResult)
|
||||
done := make(chan struct{})
|
||||
defer close(done)
|
||||
|
||||
var errs []error
|
||||
for started := 0; len(errs) < len(hosts); {
|
||||
var headStart <-chan time.Time
|
||||
if started < len(hosts) {
|
||||
index := started
|
||||
go func() {
|
||||
conn, err := dialHost(source, hosts[index])
|
||||
select {
|
||||
case results <- dialResult{index, conn, err}:
|
||||
case <-done:
|
||||
if conn != nil {
|
||||
conn.Close()
|
||||
}
|
||||
}
|
||||
}()
|
||||
started++
|
||||
headStart = time.After(250 * time.Millisecond)
|
||||
}
|
||||
select {
|
||||
case result := <-results:
|
||||
if result.err == nil {
|
||||
if result.index > 0 {
|
||||
log.Warn("LDAP source %q is using fallback host %q, earlier hosts failed or are slow: %v", source.Name, hosts[result.index], errs)
|
||||
}
|
||||
return result.conn, nil
|
||||
}
|
||||
errs = append(errs, fmt.Errorf("%s: %w", hosts[result.index], result.err))
|
||||
case <-headStart:
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("error during Dial: %w", errors.Join(errs...))
|
||||
}
|
||||
|
||||
func dialHost(source *Source, host string) (*ldap.Conn, error) {
|
||||
protocol, port := source.SecurityProtocol, strconv.Itoa(source.Port)
|
||||
if strings.Contains(host, "://") {
|
||||
hostURL, err := url.Parse(host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
urlProtocol, ok := urlSecurityProtocols[hostURL.Scheme]
|
||||
if !ok || hostURL.Hostname() == "" || hostURL.User != nil || source.UseTLS() != (urlProtocol != SecurityProtocolUnencrypted) {
|
||||
return nil, errors.New("unsupported LDAP URL")
|
||||
}
|
||||
protocol, host, port = urlProtocol, hostURL.Hostname(), hostURL.Port()
|
||||
}
|
||||
|
||||
tlsConfig := &tls.Config{
|
||||
ServerName: source.Host,
|
||||
ServerName: host,
|
||||
InsecureSkipVerify: source.SkipVerify,
|
||||
}
|
||||
|
||||
hostPort := net.JoinHostPort(source.Host, strconv.Itoa(source.Port))
|
||||
if source.SecurityProtocol == SecurityProtocolLDAPS {
|
||||
return ldap.DialURL("ldaps://"+hostPort, ldap.DialWithTLSConfig(tlsConfig))
|
||||
}
|
||||
|
||||
conn, err := ldap.DialURL("ldap://" + hostPort)
|
||||
dialURL := url.URL{Scheme: util.Iif(protocol == SecurityProtocolLDAPS, "ldaps", "ldap"), Host: net.JoinHostPort(host, port)}
|
||||
conn, err := ldap.DialURL(dialURL.String(), ldap.DialWithTLSConfig(tlsConfig))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error during Dial: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if source.SecurityProtocol == SecurityProtocolStartTLS {
|
||||
if protocol == SecurityProtocolStartTLS {
|
||||
conn.SetTimeout(ldap.DefaultTimeout)
|
||||
if err = conn.StartTLS(tlsConfig); err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("error during StartTLS: %w", err)
|
||||
}
|
||||
conn.SetTimeout(0)
|
||||
}
|
||||
|
||||
return conn, nil
|
||||
|
||||
41
services/auth/source/ldap/source_search_test.go
Normal file
41
services/auth/source/ldap/source_search_test.go
Normal file
@@ -0,0 +1,41 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package ldap
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDial(t *testing.T) {
|
||||
silent, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
defer silent.Close()
|
||||
tlsServer := httptest.NewTLSServer(nil)
|
||||
defer tlsServer.Close()
|
||||
|
||||
conn, err := dial(&Source{Host: " , ldap://127.0.0.1:1 , 127.0.0.1 , ", Port: int(netip.MustParseAddrPort(silent.Addr().String()).Port())})
|
||||
require.NoError(t, err)
|
||||
conn.Close()
|
||||
|
||||
start := time.Now()
|
||||
conn, err = dial(&Source{Host: fmt.Sprintf("ldaps://%s, ldaps://%s", silent.Addr(), tlsServer.Listener.Addr()), SecurityProtocol: SecurityProtocolLDAPS, SkipVerify: true})
|
||||
require.NoError(t, err)
|
||||
assert.Less(t, time.Since(start), 10*time.Second)
|
||||
conn.Close()
|
||||
|
||||
_, err = dial(&Source{Host: "ldaps://127.0.0.1:1, ldap://127.0.0.1", SecurityProtocol: SecurityProtocolStartTLS})
|
||||
assert.ErrorContains(t, err, "ldaps://127.0.0.1:1: ")
|
||||
assert.ErrorContains(t, err, "ldap://127.0.0.1: unsupported LDAP URL")
|
||||
|
||||
_, err = dial(&Source{Host: " , "})
|
||||
assert.EqualError(t, err, "no LDAP host configured")
|
||||
}
|
||||
@@ -42,7 +42,7 @@
|
||||
</div>
|
||||
<div class="required field">
|
||||
<label for="host">{{ctx.Locale.Tr "admin.auths.host"}}</label>
|
||||
<input id="host" name="host" value="{{$cfg.Host}}" placeholder="mydomain.com" required>
|
||||
<input id="host" name="host" value="{{$cfg.Host}}" placeholder="mydomain.com, backup.mydomain.com" required>
|
||||
</div>
|
||||
<div class="required field">
|
||||
<label for="port">{{ctx.Locale.Tr "admin.auths.port"}}</label>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
</div>
|
||||
<div class="required field">
|
||||
<label for="host">{{ctx.Locale.Tr "admin.auths.host"}}</label>
|
||||
<input id="host" name="host" value="{{.host}}" placeholder="mydomain.com">
|
||||
<input id="host" name="host" value="{{.host}}" placeholder="mydomain.com, backup.mydomain.com">
|
||||
</div>
|
||||
<div class="required field">
|
||||
<label for="port">{{ctx.Locale.Tr "admin.auths.port"}}</label>
|
||||
|
||||
Reference in New Issue
Block a user