├── .github └── workflows │ └── docker-image.yml ├── Dockerfile ├── LICENSE ├── README.md ├── build ├── config.sample.yaml ├── data ├── storage.go └── storage_test.go ├── go.mod ├── go.sum ├── group └── service.go ├── k8s.yaml ├── ldap ├── connect.go ├── jail.go ├── login.go ├── pool.go └── search.go ├── main ├── config.go ├── main.go ├── parser.go └── server.go ├── rule └── service.go ├── test-server └── user └── service.go /.github/workflows/docker-image.yml: -------------------------------------------------------------------------------- 1 | name: Docker Image CI 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | 11 | build: 12 | 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Build the Docker image 18 | run: docker build --force-rm --no-cache . --file Dockerfile --tag tpimenta/nginx-ldap-auth:$(date +%s) 19 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.13-alpine AS gobuild 2 | 3 | COPY . /build/nginx-ldap-auth 4 | 5 | ENV CGO_ENABLED=0 6 | 7 | RUN cd /build/nginx-ldap-auth && \ 8 | apk add --no-cache git && \ 9 | go build -a -x -ldflags='-s -w -extldflags -static' -v -o /go/bin/nginx-ldap-auth ./main 10 | 11 | FROM scratch 12 | 13 | MAINTAINER Tiago A. Pimenta 14 | 15 | COPY --from=gobuild /go/bin/nginx-ldap-auth /usr/local/bin/nginx-ldap-auth 16 | 17 | WORKDIR /tmp 18 | 19 | VOLUME /etc/nginx-ldap-auth 20 | 21 | EXPOSE 5555 22 | 23 | USER 65534:65534 24 | 25 | CMD [ \ 26 | "/usr/local/bin/nginx-ldap-auth", \ 27 | "--config", \ 28 | "/etc/nginx-ldap-auth/config.yaml" \ 29 | ] 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018 Tiago A. Pimenta 2 | 3 | This software is provided 'as-is', without any express or implied 4 | warranty. In no event will the authors be held liable for any damages 5 | arising from the use of this software. 6 | 7 | Permission is granted to anyone to use this software for any purpose, 8 | including commercial applications, and to alter it and redistribute it 9 | freely, subject to the following restrictions: 10 | 11 | 1. The origin of this software must not be misrepresented; you must not 12 | claim that you wrote the original software. If you use this software 13 | in a product, an acknowledgment in the product documentation would be 14 | appreciated but is not required. 15 | 2. Altered source versions must be plainly marked as such, and must not be 16 | misrepresented as being the original software. 17 | 3. This notice may not be removed or altered from any source distribution. 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Nginx LDAP Auth 2 | 3 | Use this in order to provide a ingress authentication over LDAP for Kubernetes, change the Secret inside `config.sample.yaml` to match your LDAP server and run: 4 | 5 | kubectl create secret generic nginx-ldap-auth --from-file=config.yaml=config.sample.yaml 6 | 7 | kubectl apply -f k8s.yaml 8 | 9 | Configure your ingress with annotation `nginx.ingress.kubernetes.io/auth-url: http://nginx-ldap-auth.ingress-nginx.svc.cluster.local:5555` as described on [nginx documentation](https://kubernetes.github.io/ingress-nginx/examples/auth/external-auth/). 10 | 11 | ## Configuration 12 | 13 | The actual version choose a random server, in future version it is intended to have a pool of them, that is why it is a list, not a single one, but you can fill only one if you wish. 14 | 15 | The prefix tell the program which protocol to use, if `ldaps://` it will try LDAP over SSL, if `ldap://` it will try plain LDAP with STARTTLS, case no prefix is given it will try to guess based on port, 636 for SSL and 389 for plain. 16 | 17 | If the `user.requiredGroups` list is omited or empty all LDAP users will be allowed regardless the group, if not empty all groups will be required, the next version will have more flexible configuration. 18 | 19 | If you are not sure what `filter`, `bindDN` or `baseDN` to use, here is a tip: 20 | 21 | ldapsearch -H ${servers[*]} -D ${auth.bindDN} -w ${auth.bindPW} -b ${user.baseDN|group.baseDN} ${user.filter|group.filter} 22 | 23 | Replace the values between `${...}` to the ones on `config.yaml`, when you succeed you can fill the final configuration. 24 | 25 | Timeouts are configurable, but it is recommended not to use values less than some seconds, it was planned to prevent several identical requests to LDAP servers. 26 | -------------------------------------------------------------------------------- /build: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | base='docker.io/tpimenta/nginx-ldap-auth' 6 | version='v1.0.7' 7 | image="$base:$version" 8 | 9 | atexit() { 10 | docker images -q -f dangling=true | xargs -r docker rmi 11 | } 12 | 13 | trap atexit INT TERM EXIT 14 | 15 | docker build \ 16 | --force-rm \ 17 | --no-cache \ 18 | --tag "$image" \ 19 | "$(dirname "$0")" 20 | 21 | docker push "$image" 22 | docker tag "$image" "$base:latest" 23 | docker push "$base:latest" 24 | -------------------------------------------------------------------------------- /config.sample.yaml: -------------------------------------------------------------------------------- 1 | web: 0.0.0.0:5555 2 | path: / 3 | servers: 4 | - ldaps://ldap1.example.com:636 5 | - ldaps://ldap2.example.com:636 6 | - ldaps://ldap3.example.com:636 7 | auth: 8 | bindDN: uid=seviceaccount,cn=users,dc=example,dc=com 9 | bindPW: password 10 | user: 11 | baseDN: ou=users,dc=example,dc=com 12 | filter: "(cn={0})" 13 | requiredGroups: 14 | - appAdmin 15 | group: 16 | baseDN: ou=groups,dc=example,dc=com 17 | groupAttr: cn 18 | filter: "(member={0})" 19 | timeout: 20 | success: 24h 21 | wrong: 5m 22 | -------------------------------------------------------------------------------- /data/storage.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | "sort" 5 | "sync" 6 | "time" 7 | ) 8 | 9 | type passtimer struct { 10 | password string 11 | timer *time.Timer 12 | } 13 | 14 | type userpass struct { 15 | correct *passtimer 16 | wrong []passtimer 17 | } 18 | 19 | type Storage struct { 20 | passwords map[string]*userpass 21 | lock sync.RWMutex 22 | success time.Duration 23 | wrong time.Duration 24 | } 25 | 26 | func NewStorage(success, wrong time.Duration) *Storage { 27 | return &Storage{ 28 | passwords: map[string]*userpass{}, 29 | lock: sync.RWMutex{}, 30 | success: success, 31 | wrong: wrong, 32 | } 33 | } 34 | 35 | func (p *Storage) Get(username, password string) (bool, bool) { 36 | p.lock.RLock() 37 | defer p.lock.RUnlock() 38 | 39 | data, found := p.passwords[username] 40 | if !found { 41 | return false, false 42 | } 43 | 44 | if data.correct != nil && (*data.correct).password == password { 45 | return true, true 46 | } 47 | 48 | _, found = containsWrongPassword(data, password) 49 | 50 | return false, found 51 | } 52 | 53 | func (p *Storage) Put(username, password string, ok bool) { 54 | p.lock.Lock() 55 | defer p.lock.Unlock() 56 | 57 | data, found := p.passwords[username] 58 | if !found { 59 | data = &userpass{} 60 | p.passwords[username] = data 61 | } 62 | 63 | timeout := p.wrong 64 | if ok { 65 | timeout = p.success 66 | } 67 | 68 | pass := passtimer{ 69 | password: password, 70 | timer: time.AfterFunc(timeout, func() { 71 | p.remove(username, password, ok) 72 | }), 73 | } 74 | 75 | if ok { 76 | if data.correct != nil { 77 | data.correct.timer.Stop() 78 | } 79 | data.correct = &pass 80 | } else { 81 | pos, found := containsWrongPassword(data, password) 82 | if found { 83 | data.wrong[pos].timer.Stop() 84 | } else { 85 | data.wrong = append(data.wrong, passtimer{}) 86 | copy(data.wrong[pos+1:], data.wrong[pos:]) 87 | } 88 | data.wrong[pos] = pass 89 | } 90 | } 91 | 92 | func (p *Storage) remove(username, password string, ok bool) { 93 | p.lock.Lock() 94 | defer p.lock.Unlock() 95 | 96 | data, found := p.passwords[username] 97 | if !found { 98 | return 99 | } 100 | 101 | if ok { 102 | if data.correct != nil { 103 | data.correct.timer.Stop() 104 | data.correct = nil 105 | } 106 | } else { 107 | pos, found := containsWrongPassword(data, password) 108 | if found { 109 | data.wrong[pos].timer.Stop() 110 | data.wrong = data.wrong[:pos+copy(data.wrong[pos:], data.wrong[pos+1:])] 111 | } 112 | } 113 | 114 | if data.correct == nil && len(data.wrong) == 0 { 115 | delete(p.passwords, username) 116 | } 117 | } 118 | 119 | func containsWrongPassword(data *userpass, password string) (int, bool) { 120 | size := len(data.wrong) 121 | if size == 0 { 122 | return 0, false 123 | } 124 | 125 | pos := sort.Search(size, func(i int) bool { 126 | return data.wrong[i].password >= password 127 | }) 128 | 129 | return pos, pos < size && 130 | data.wrong[pos].password == password 131 | } 132 | -------------------------------------------------------------------------------- /data/storage_test.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "testing" 7 | "time" 8 | ) 9 | 10 | const ( 11 | username1 = "Alice" 12 | username2 = "James" 13 | password1 = "master" 14 | password2 = "shadow" 15 | password3 = "qwerty" 16 | success = time.Second / 2 17 | wrong = time.Second / 5 18 | ) 19 | 20 | func printPassMap(t *testing.T, storage *Storage, prefix string) { 21 | buffer := bytes.Buffer{} 22 | first := true 23 | for k, v := range storage.passwords { 24 | if first { 25 | first = false 26 | } else { 27 | buffer.WriteByte(',') 28 | } 29 | correct := "" 30 | if v.correct != nil { 31 | correct = v.correct.password 32 | } 33 | fmt.Fprintf(&buffer, "%s:{correct:%s,wrong:%+v}", k, correct, v.wrong) 34 | } 35 | t.Logf("%s passwords: %s\n", prefix, buffer.String()) 36 | } 37 | 38 | func testCache(t *testing.T, storage *Storage, id int, username, password string, eok, efound bool) { 39 | ok, found := storage.Get(username, password) 40 | if ok != eok || found != efound { 41 | t.Errorf("Test %d expected (%v %v) given (%v %v)\n", id, eok, efound, ok, found) 42 | } 43 | } 44 | 45 | func TestPasswordTimeout(t *testing.T) { 46 | storage := NewStorage(success, wrong) 47 | 48 | testCache(t, storage, 0, username1, password1, false, false) 49 | testCache(t, storage, 1, username1, password2, false, false) 50 | testCache(t, storage, 2, username1, password3, false, false) 51 | testCache(t, storage, 3, username2, password1, false, false) 52 | testCache(t, storage, 4, username2, password2, false, false) 53 | testCache(t, storage, 5, username2, password3, false, false) 54 | 55 | storage.Put(username1, password1, true) 56 | storage.Put(username1, password3, false) 57 | printPassMap(t, storage, "add") 58 | 59 | testCache(t, storage, 6, username1, password1, true, true) 60 | testCache(t, storage, 7, username1, password2, false, false) 61 | testCache(t, storage, 8, username1, password3, false, true) 62 | testCache(t, storage, 9, username2, password1, false, false) 63 | testCache(t, storage, 10, username2, password2, false, false) 64 | testCache(t, storage, 11, username2, password3, false, false) 65 | 66 | time.Sleep(wrong + wrong/2) 67 | printPassMap(t, storage, "timed") 68 | 69 | testCache(t, storage, 12, username1, password1, true, true) 70 | testCache(t, storage, 13, username1, password2, false, false) 71 | testCache(t, storage, 14, username1, password3, false, false) 72 | testCache(t, storage, 15, username2, password1, false, false) 73 | testCache(t, storage, 16, username2, password2, false, false) 74 | testCache(t, storage, 17, username2, password3, false, false) 75 | 76 | time.Sleep(success - wrong) 77 | printPassMap(t, storage, "expired") 78 | 79 | testCache(t, storage, 18, username1, password1, false, false) 80 | testCache(t, storage, 19, username1, password2, false, false) 81 | testCache(t, storage, 20, username1, password3, false, false) 82 | testCache(t, storage, 21, username2, password1, false, false) 83 | testCache(t, storage, 22, username2, password2, false, false) 84 | testCache(t, storage, 23, username2, password3, false, false) 85 | } 86 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/tiagoapimenta/nginx-ldap-auth 2 | 3 | go 1.13 4 | 5 | require ( 6 | github.com/go-ldap/ldap/v3 v3.1.3 7 | gopkg.in/yaml.v2 v2.2.7 8 | ) 9 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/go-asn1-ber/asn1-ber v1.3.1 h1:gvPdv/Hr++TRFCl0UbPFHC54P9N9jgsRPnmnr419Uck= 2 | github.com/go-asn1-ber/asn1-ber v1.3.1/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= 3 | github.com/go-ldap/ldap/v3 v3.1.3 h1:RIgdpHXJpsUqUK5WXwKyVsESrGFqo5BRWPk3RR4/ogQ= 4 | github.com/go-ldap/ldap/v3 v3.1.3/go.mod h1:3rbOH3jRS2u6jg2rJnKAMLE/xQyCKIveG2Sa/Cohzb8= 5 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 6 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 7 | gopkg.in/yaml.v2 v2.2.7 h1:VUgggvou5XRW9mHwD/yXxIYSMtY0zoKQf/v226p2nyo= 8 | gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 9 | -------------------------------------------------------------------------------- /group/service.go: -------------------------------------------------------------------------------- 1 | package group 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/tiagoapimenta/nginx-ldap-auth/ldap" 7 | 8 | gldap "github.com/go-ldap/ldap/v3" 9 | ) 10 | 11 | type Service struct { 12 | pool *ldap.Pool 13 | base string 14 | filter string 15 | attr string 16 | } 17 | 18 | func NewService(pool *ldap.Pool, base, filter, attr string) *Service { 19 | return &Service{ 20 | pool: pool, 21 | base: base, 22 | filter: filter, 23 | attr: attr, 24 | } 25 | } 26 | 27 | func (p *Service) Find(id string) ([]string, error) { 28 | id = gldap.EscapeFilter(id) 29 | 30 | ok, _, groups, err := p.pool.Search( 31 | p.base, 32 | strings.Replace(p.filter, "{0}", id, -1), 33 | p.attr, 34 | ) 35 | 36 | if !ok && err != nil { 37 | return nil, err 38 | } else if err != nil { 39 | return []string{}, nil 40 | } 41 | 42 | return groups, nil 43 | } 44 | -------------------------------------------------------------------------------- /k8s.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ServiceAccount 3 | metadata: 4 | name: nginx-ldap-auth 5 | namespace: ingress-nginx 6 | --- 7 | apiVersion: rbac.authorization.k8s.io/v1beta1 8 | kind: Role 9 | metadata: 10 | name: nginx-ldap-auth 11 | namespace: ingress-nginx 12 | rules: 13 | - apiGroups: 14 | - "" 15 | resources: 16 | - configmaps 17 | - secrets 18 | resourceNames: 19 | - "nginx-ldap-auth" 20 | verbs: 21 | - get 22 | --- 23 | apiVersion: rbac.authorization.k8s.io/v1beta1 24 | kind: RoleBinding 25 | metadata: 26 | name: nginx-ldap-auth 27 | namespace: ingress-nginx 28 | roleRef: 29 | apiGroup: rbac.authorization.k8s.io 30 | kind: Role 31 | name: nginx-ldap-auth 32 | subjects: 33 | - kind: ServiceAccount 34 | name: nginx-ldap-auth 35 | --- 36 | kind: Service 37 | apiVersion: v1 38 | metadata: 39 | name: nginx-ldap-auth 40 | namespace: ingress-nginx 41 | spec: 42 | type: ClusterIP 43 | ports: 44 | - name: nginx-ldap-auth 45 | port: 5555 46 | protocol: TCP 47 | targetPort: 5555 48 | selector: 49 | app: nginx-ldap-auth 50 | --- 51 | kind: Deployment 52 | apiVersion: apps/v1 53 | metadata: 54 | name: nginx-ldap-auth 55 | namespace: ingress-nginx 56 | labels: 57 | app: nginx-ldap-auth 58 | spec: 59 | replicas: 1 60 | selector: 61 | matchLabels: 62 | app: nginx-ldap-auth 63 | template: 64 | metadata: 65 | labels: 66 | app: nginx-ldap-auth 67 | spec: 68 | serviceAccountName: nginx-ldap-auth 69 | containers: 70 | - image: docker.io/tpimenta/nginx-ldap-auth:v1.0.7 71 | name: nginx-ldap-auth 72 | command: 73 | - "/usr/local/bin/nginx-ldap-auth" 74 | - "--config" 75 | - "/etc/nginx-ldap-auth/config.yaml" 76 | ports: 77 | - name: http 78 | containerPort: 5555 79 | volumeMounts: 80 | - name: config 81 | mountPath: /etc/nginx-ldap-auth 82 | resources: 83 | limits: 84 | cpu: 50m 85 | memory: 20Mi 86 | requests: 87 | cpu: 10m 88 | memory: 5Mi 89 | volumes: 90 | - name: config 91 | secret: 92 | secretName: nginx-ldap-auth 93 | items: 94 | - key: config.yaml 95 | path: config.yaml 96 | -------------------------------------------------------------------------------- /ldap/connect.go: -------------------------------------------------------------------------------- 1 | package ldap 2 | 3 | import ( 4 | "crypto/tls" 5 | "errors" 6 | "fmt" 7 | "log" 8 | 9 | ldap "github.com/go-ldap/ldap/v3" 10 | ) 11 | 12 | func (p *Pool) Connect() error { 13 | if p.url == "" { 14 | return errors.New("No LDAP server available!") 15 | } 16 | 17 | if p.port == 0 { 18 | return fmt.Errorf("Unable to determine schema or port for \"%s\"", p.url) 19 | } 20 | 21 | if p.conn != nil { 22 | p.conn.Close() 23 | } 24 | 25 | address := fmt.Sprintf("%s:%d", p.url, p.port) 26 | if p.ssl { 27 | conn, err := ldap.DialTLS("tcp", address, &tls.Config{InsecureSkipVerify: true}) 28 | if err != nil { 29 | return err 30 | } 31 | p.conn = conn 32 | } else { 33 | conn, err := ldap.Dial("tcp", address) 34 | if err != nil { 35 | return err 36 | } 37 | err = conn.StartTLS(&tls.Config{InsecureSkipVerify: true}) 38 | if err != nil { 39 | log.Printf("It was not possble to start TLS, falling back to plain: %v.\n", err) 40 | conn.Close() 41 | conn, err = ldap.Dial("tcp", address) 42 | if err != nil { 43 | return err 44 | } 45 | } 46 | p.conn = conn 47 | } 48 | 49 | p.admin = false 50 | 51 | return p.auth() 52 | } 53 | -------------------------------------------------------------------------------- /ldap/jail.go: -------------------------------------------------------------------------------- 1 | package ldap 2 | 3 | import ( 4 | "log" 5 | 6 | ldap "github.com/go-ldap/ldap/v3" 7 | ) 8 | 9 | func (p *Pool) networkJail(f func() error) (bool, error) { 10 | err := f() 11 | if err != nil && ldap.IsErrorWithCode(err, ldap.ErrorNetwork) { 12 | log.Printf("Network problem, trying to reconnect once: %v.\n", err) 13 | err = p.Connect() 14 | if err != nil { 15 | return false, err 16 | } 17 | err = f() 18 | if err != nil && ldap.IsErrorWithCode(err, ldap.ErrorNetwork) { 19 | return false, err 20 | } 21 | } 22 | return true, err 23 | } 24 | -------------------------------------------------------------------------------- /ldap/login.go: -------------------------------------------------------------------------------- 1 | package ldap 2 | 3 | import "errors" 4 | 5 | func (p *Pool) Validate(username, password string) (bool, error) { 6 | p.lock.Lock() 7 | defer p.lock.Unlock() 8 | 9 | err := p.auth() 10 | if err != nil { 11 | return false, err 12 | } 13 | 14 | p.admin = false 15 | var ok bool 16 | ok, err = p.networkJail(func() error { 17 | return p.conn.Bind(username, password) 18 | }) 19 | if !ok { 20 | return false, err 21 | } 22 | if err != nil { 23 | return true, err 24 | } 25 | 26 | err = p.auth() 27 | if err != nil { 28 | return false, err 29 | } 30 | 31 | if len(password) == 0 { 32 | return true, errors.New("Password field cannot be empty") 33 | } 34 | 35 | return true, nil 36 | } 37 | 38 | func (p *Pool) auth() error { 39 | if p.admin || p.username == "" && p.password == "" { 40 | return nil 41 | } 42 | 43 | _, err := p.networkJail(func() error { 44 | return p.conn.Bind(p.username, p.password) 45 | }) 46 | if err == nil { 47 | p.admin = true 48 | } 49 | return err 50 | } 51 | -------------------------------------------------------------------------------- /ldap/pool.go: -------------------------------------------------------------------------------- 1 | package ldap 2 | 3 | import ( 4 | "log" 5 | "math/rand" 6 | "regexp" 7 | "strconv" 8 | "strings" 9 | "sync" 10 | "time" 11 | 12 | ldap "github.com/go-ldap/ldap/v3" 13 | ) 14 | 15 | type Pool struct { 16 | url string 17 | port int 18 | ssl bool 19 | username string 20 | password string 21 | conn *ldap.Conn 22 | admin bool 23 | lock sync.Mutex 24 | } 25 | 26 | func NewPool(servers []string, username, password string) *Pool { 27 | url := "" 28 | port := 0 29 | schema := "auto" 30 | 31 | size := len(servers) 32 | if size != 0 { 33 | r := rand.New(rand.NewSource(time.Now().Unix())) 34 | server := servers[r.Intn(size)] 35 | 36 | url = server 37 | if strings.HasPrefix(url, "ldaps:") { 38 | url = strings.TrimPrefix(strings.TrimPrefix(url, "ldaps:"), "//") 39 | schema = "ldaps" 40 | port = 636 41 | } else if strings.HasPrefix(url, "ldap:") { 42 | url = strings.TrimPrefix(strings.TrimPrefix(url, "ldap:"), "//") 43 | schema = "ldap" 44 | port = 389 45 | } 46 | 47 | portExp := regexp.MustCompile(`:[0-9]+$`) 48 | if portExp.MatchString(url) { 49 | str := portExp.FindString(url) 50 | 51 | number, err := strconv.Atoi(str[1:]) 52 | if err == nil { 53 | port = number 54 | url = strings.TrimSuffix(url, str) 55 | } else { 56 | log.Printf("Error on parse port of \"%s\": %v\n", server, err) 57 | } 58 | } 59 | 60 | if schema == "auto" { 61 | if port == 636 { 62 | schema = "ldaps" 63 | } else if port == 389 { 64 | schema = "ldap" 65 | } else { 66 | port = 0 67 | } 68 | } 69 | } 70 | 71 | return &Pool{ 72 | url: url, 73 | port: port, 74 | ssl: schema == "ldaps", 75 | username: username, 76 | password: password, 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /ldap/search.go: -------------------------------------------------------------------------------- 1 | package ldap 2 | 3 | import ( 4 | "fmt" 5 | "sort" 6 | 7 | ldap "github.com/go-ldap/ldap/v3" 8 | ) 9 | 10 | func (p *Pool) Search(base, filter string, attr string) (bool, string, []string, error) { 11 | p.lock.Lock() 12 | defer p.lock.Unlock() 13 | 14 | err := p.auth() 15 | if err != nil { 16 | return false, "", nil, err 17 | } 18 | 19 | var list []string = nil 20 | if attr != "" { 21 | list = []string{attr} 22 | } 23 | 24 | var res *ldap.SearchResult 25 | _, err = p.networkJail(func() error { 26 | res, err = p.conn.Search(ldap.NewSearchRequest( 27 | base, 28 | ldap.ScopeWholeSubtree, 29 | ldap.NeverDerefAliases, 30 | 0, 31 | 0, 32 | false, 33 | filter, 34 | list, 35 | nil, 36 | )) 37 | return err 38 | }) 39 | 40 | if err != nil { 41 | return false, "", nil, err 42 | } 43 | 44 | if res == nil || len(res.Entries) == 0 { 45 | return true, "", nil, fmt.Errorf("No results for %s filter %s", base, filter) 46 | } 47 | 48 | if attr == "" && len(res.Entries) > 1 { 49 | return true, "", nil, fmt.Errorf("Too many results for %s filter %s", base, filter) 50 | } 51 | 52 | var result []string = nil 53 | if attr != "" { 54 | result = []string{} 55 | for _, entry := range res.Entries { 56 | result = append(result, entry.GetAttributeValue(attr)) 57 | } 58 | sort.Strings(result) 59 | } 60 | 61 | return true, res.Entries[0].DN, result, nil 62 | } 63 | -------------------------------------------------------------------------------- /main/config.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "time" 4 | 5 | type AuthConfig struct { 6 | BindDN string `yaml:"bindDN"` 7 | BindPW string `yaml:"bindPW"` 8 | } 9 | 10 | type UserConfig struct { 11 | BaseDN string `yaml:"baseDN"` 12 | Filter string `yaml:"filter"` 13 | RequiredGroups []string `yaml:"requiredGroups"` 14 | } 15 | 16 | type GroupConfig struct { 17 | BaseDN string `yaml:"baseDN"` 18 | GroupAttr string `yaml:"groupAttr"` 19 | Filter string `yaml:"filter"` 20 | } 21 | 22 | type TimeoutConfig struct { 23 | Success time.Duration `yaml:"success"` 24 | Wrong time.Duration `yaml:"wrong"` 25 | } 26 | 27 | type Config struct { 28 | Web string `yaml:"web"` 29 | Path string `yaml:"path"` 30 | Message string `yaml:"message"` 31 | Servers []string `yaml:"servers"` 32 | Auth AuthConfig `yaml:"auth"` 33 | User UserConfig `yaml:"user"` 34 | Group GroupConfig `yaml:"group"` 35 | Timeout TimeoutConfig `yaml:"timeout"` 36 | } 37 | -------------------------------------------------------------------------------- /main/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | 7 | "github.com/tiagoapimenta/nginx-ldap-auth/data" 8 | "github.com/tiagoapimenta/nginx-ldap-auth/group" 9 | "github.com/tiagoapimenta/nginx-ldap-auth/ldap" 10 | "github.com/tiagoapimenta/nginx-ldap-auth/rule" 11 | "github.com/tiagoapimenta/nginx-ldap-auth/user" 12 | ) 13 | 14 | func main() { 15 | file, config, err := parseConfig() 16 | if err != nil { 17 | log.Fatalln(err.Error()) 18 | } 19 | 20 | fmt.Printf("Loaded config \"%s\".\n", file) 21 | 22 | pool := ldap.NewPool(config.Servers, config.Auth.BindDN, config.Auth.BindPW) 23 | 24 | err = pool.Connect() 25 | if err != nil { 26 | log.Fatalf("Error on connect to LDAP: %v\n", err) 27 | } 28 | 29 | storage := data.NewStorage(config.Timeout.Success, config.Timeout.Wrong) 30 | 31 | userService := user.NewService(pool, config.User.BaseDN, config.User.Filter) 32 | 33 | groupService := group.NewService(pool, config.Group.BaseDN, config.Group.Filter, config.Group.GroupAttr) 34 | 35 | ruleService := rule.NewService(storage, userService, groupService, config.User.RequiredGroups) 36 | 37 | fmt.Printf("Serving...\n") 38 | err = startServer(ruleService, config.Web, config.Path, config.Message) 39 | if err != nil { 40 | log.Fatalf("Error on start server: %v\n", err) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /main/parser.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "io/ioutil" 7 | "time" 8 | 9 | yaml "gopkg.in/yaml.v2" 10 | ) 11 | 12 | func parseConfig() (string, *Config, error) { 13 | file := flag.String("config", "/etc/nginx-ldap-auth/config.yaml", "Configuration file") 14 | 15 | flag.Parse() 16 | 17 | data, err := ioutil.ReadFile(*file) 18 | if err != nil { 19 | return "", nil, fmt.Errorf("Error on read file \"%s\": %v", *file, err) 20 | } 21 | 22 | config := Config{ 23 | Web: "0.0.0.0:5555", 24 | Path: "/", 25 | Message: "LDAP Login", 26 | User: UserConfig{ 27 | Filter: "(cn={0})", 28 | }, 29 | Group: GroupConfig{ 30 | Filter: "(member={0})", 31 | GroupAttr: "cn", 32 | }, 33 | Timeout: TimeoutConfig{ 34 | Success: 24 * time.Hour, 35 | Wrong: 5 * time.Minute, 36 | }, 37 | } 38 | 39 | err = yaml.Unmarshal(data, &config) 40 | if err != nil { 41 | return "", nil, fmt.Errorf("Error on parse config: %v", err) 42 | } 43 | 44 | return *file, &config, nil 45 | } 46 | -------------------------------------------------------------------------------- /main/server.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/base64" 5 | "fmt" 6 | "log" 7 | "net/http" 8 | "strings" 9 | 10 | "github.com/tiagoapimenta/nginx-ldap-auth/rule" 11 | ) 12 | 13 | func startServer(service *rule.Service, server, path, message string) error { 14 | realm := fmt.Sprintf("Basic realm=\"%s\"", message) 15 | 16 | http.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { 17 | header := r.Header.Get("Authorization") 18 | 19 | if header != "" { 20 | auth := strings.SplitN(header, " ", 2) 21 | 22 | if len(auth) == 2 && auth[0] == "Basic" { 23 | decoded, err := base64.StdEncoding.DecodeString(auth[1]) 24 | if err == nil { 25 | secret := strings.SplitN(string(decoded), ":", 2) 26 | 27 | if len(secret) == 2 && service.Validate(secret[0], secret[1]) { 28 | w.WriteHeader(http.StatusOK) 29 | return 30 | } 31 | } else { 32 | log.Printf("Error decode basic auth: %v\n", err) 33 | } 34 | } 35 | } 36 | 37 | w.Header().Set("WWW-Authenticate", realm) 38 | w.WriteHeader(http.StatusUnauthorized) 39 | }) 40 | 41 | return http.ListenAndServe(server, nil) 42 | } 43 | -------------------------------------------------------------------------------- /rule/service.go: -------------------------------------------------------------------------------- 1 | package rule 2 | 3 | import ( 4 | "log" 5 | "sort" 6 | 7 | "github.com/tiagoapimenta/nginx-ldap-auth/data" 8 | "github.com/tiagoapimenta/nginx-ldap-auth/group" 9 | "github.com/tiagoapimenta/nginx-ldap-auth/user" 10 | ) 11 | 12 | type Service struct { 13 | storage *data.Storage 14 | user *user.Service 15 | group *group.Service 16 | required []string 17 | } 18 | 19 | func NewService(storage *data.Storage, userService *user.Service, groupService *group.Service, required []string) *Service { 20 | return &Service{ 21 | storage: storage, 22 | user: userService, 23 | group: groupService, 24 | required: required, 25 | } 26 | } 27 | 28 | func (p *Service) Validate(username, password string) bool { 29 | ok, found := p.storage.Get(username, password) 30 | if found { 31 | return ok 32 | } 33 | 34 | ok, err := p.validate(username, password) 35 | if err != nil { 36 | log.Printf("Could not validate user %s: %v\n", username, err) 37 | return false 38 | } 39 | 40 | p.storage.Put(username, password, ok) 41 | return ok 42 | } 43 | 44 | func (p *Service) validate(username, password string) (bool, error) { 45 | ok, id, err := p.user.Find(username) 46 | if !ok && err != nil { 47 | return false, err 48 | } else if err != nil { 49 | return false, nil 50 | } 51 | 52 | ok, err = p.user.Login(id, password) 53 | if !ok && err != nil { 54 | return false, err 55 | } 56 | 57 | if !ok || err != nil || p.required == nil || len(p.required) == 0 { 58 | return err == nil, nil 59 | } 60 | 61 | groups, err := p.group.Find(id) 62 | if err != nil { 63 | return false, err 64 | } 65 | 66 | for _, group := range p.required { 67 | pos := sort.SearchStrings(groups, group) 68 | if pos >= len(groups) || groups[pos] != group { 69 | return false, nil 70 | } 71 | } 72 | 73 | return true, nil 74 | } 75 | -------------------------------------------------------------------------------- /test-server: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | for name in ldap-test-server ldap-test-client; do 6 | if docker ps -a --format '{{.Names}}' | egrep -q "^${name}\$"; then 7 | docker rm -f "$name" || : 8 | fi 9 | done 10 | 11 | docker run \ 12 | -p 389:389 \ 13 | -p 636:636 \ 14 | --name ldap-test-server \ 15 | -d \ 16 | osixia/openldap:1.2.2 17 | 18 | # docker exec ldap-test-server ldapsearch -x -H ldap://localhost -b dc=example,dc=org -D "cn=admin,dc=example,dc=org" -w admin 19 | 20 | cat > /tmp/config.yaml <