├── .gitattributes ├── docs ├── _config.yml ├── docker-auth-1.0.3.tgz ├── docker-auth-1.1.0.tgz ├── docker-auth-1.1.1.tgz ├── docker-auth-1.2.0.tgz ├── docker-auth-1.3.0.tgz ├── docker-auth-1.4.0.tgz ├── docker-auth-1.5.0.tgz ├── docker-auth-1.14.0.tgz ├── README.md ├── casbin_backend.md ├── auth-methods.md ├── Labels.md ├── Backend_MongoDB.md └── index.yaml ├── .gitignore ├── auth_server ├── .gitignore ├── README.md ├── authn │ ├── data │ │ ├── oidc_auth.tmpl │ │ ├── oidc_auth_result.tmpl │ │ ├── gitlab_auth.tmpl │ │ ├── gitlab_auth_result.tmpl │ │ ├── github_auth_result.tmpl │ │ ├── google_auth.tmpl │ │ └── github_auth.tmpl │ ├── authn.go │ ├── xorm_sqlite_authn.go │ ├── static_auth.go │ ├── plugin_authn.go │ ├── xorm_authn.go │ ├── ext_auth.go │ ├── tokendb_gcs.go │ ├── mongo_auth.go │ ├── tokendb_redis.go │ ├── tokendb_level.go │ ├── ldap_auth.go │ ├── gitlab_auth.go │ └── oidc_auth.go ├── authz │ ├── set.go │ ├── acl_xorm_sqlite.go │ ├── plugin_authz.go │ ├── ext_authz.go │ ├── casbin_authz.go │ ├── acl_xorm.go │ ├── acl_mongo.go │ ├── casbin_authz_test.go │ ├── acl_test.go │ └── acl.go ├── Dockerfile ├── Makefile ├── api │ ├── authn.go │ └── authz.go ├── gen_version.go ├── go.mod ├── mgo_session │ └── mgo_session.go └── main.go ├── examples ├── casbin_authz_policy.csv ├── ext_auth.sh ├── casbin_authz_model.conf ├── non_tls.yml ├── simple.yml └── ldap_auth.yml ├── chart └── docker-auth │ ├── templates │ ├── secret.yaml │ ├── tests │ │ └── test-connection.yaml │ ├── service.yaml │ ├── configmap.yaml │ ├── _helpers.tpl │ ├── ingress.yaml │ ├── NOTES.txt │ └── deployment.yaml │ ├── .helmignore │ ├── Chart.yaml │ ├── Makefile │ ├── values.yaml │ └── README.md ├── .github └── workflows │ ├── go_test.yml │ ├── codeql-analysis.yml │ └── docker.yml ├── README.md └── LICENSE /.gitattributes: -------------------------------------------------------------------------------- 1 | bindata.go -diff 2 | -------------------------------------------------------------------------------- /docs/_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-slate -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | chart/docker-auth/Chart.lock 3 | -------------------------------------------------------------------------------- /auth_server/.gitignore: -------------------------------------------------------------------------------- 1 | ca-certificates.crt 2 | auth_server 3 | vendor/*/ 4 | version.* 5 | -------------------------------------------------------------------------------- /docs/docker-auth-1.0.3.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cesanta/docker_auth/HEAD/docs/docker-auth-1.0.3.tgz -------------------------------------------------------------------------------- /docs/docker-auth-1.1.0.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cesanta/docker_auth/HEAD/docs/docker-auth-1.1.0.tgz -------------------------------------------------------------------------------- /docs/docker-auth-1.1.1.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cesanta/docker_auth/HEAD/docs/docker-auth-1.1.1.tgz -------------------------------------------------------------------------------- /docs/docker-auth-1.2.0.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cesanta/docker_auth/HEAD/docs/docker-auth-1.2.0.tgz -------------------------------------------------------------------------------- /docs/docker-auth-1.3.0.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cesanta/docker_auth/HEAD/docs/docker-auth-1.3.0.tgz -------------------------------------------------------------------------------- /docs/docker-auth-1.4.0.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cesanta/docker_auth/HEAD/docs/docker-auth-1.4.0.tgz -------------------------------------------------------------------------------- /docs/docker-auth-1.5.0.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cesanta/docker_auth/HEAD/docs/docker-auth-1.5.0.tgz -------------------------------------------------------------------------------- /docs/docker-auth-1.14.0.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cesanta/docker_auth/HEAD/docs/docker-auth-1.14.0.tgz -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Helm repo for Docker Auth 2 | 3 | Documentation available here: 4 | -------------------------------------------------------------------------------- /auth_server/README.md: -------------------------------------------------------------------------------- 1 | ### Building local image 2 | 3 | ``` 4 | mkdir -p /var/tmp/go/src/github.com/cesanta 5 | cd /var/tmp/go/src/github.com/cesanta 6 | git clone https://github.com/cesanta/docker_auth.git 7 | cd docker_auth/auth_server 8 | make docker-build 9 | ``` 10 | -------------------------------------------------------------------------------- /examples/casbin_authz_policy.csv: -------------------------------------------------------------------------------- 1 | p, alice, book, book1, bookstore1, 1.2.3.4, read, "{""a"":[""b""]}" 2 | p, alice, book, book1, bookstore1, 1.2.3.4, write, "{""a"":[""b""]}" 3 | p, role1, book, book2, bookstore1, 192.168.1.0/24, read, "{""a"":[""b"",""c""]}" 4 | 5 | g, bob, role1 -------------------------------------------------------------------------------- /chart/docker-auth/templates/secret.yaml: -------------------------------------------------------------------------------- 1 | {{- if not .Values.secret.secretName }} 2 | --- 3 | apiVersion: v1 4 | kind: Secret 5 | metadata: 6 | name: {{ include "docker-auth.name" . }} 7 | type: Opaque 8 | data: 9 | server.pem: {{ .Values.secret.data.server.certificate | quote }} 10 | server.key: {{ .Values.secret.data.server.key | quote }} 11 | {{- end }} 12 | -------------------------------------------------------------------------------- /examples/ext_auth.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # Example external authenticator program for use with `ext_auth`. 4 | # 5 | 6 | read u p 7 | 8 | if [ "$u" == "user" -a "$p" == "pass" ]; then 9 | exit 0 10 | fi 11 | 12 | if [ "$u" == "bofh" -a "$p" == "LART" ]; then 13 | echo '{"labels": {"level": ["max"], "groups": ["VIP", "ATeam"]}}' 14 | exit 0 15 | fi 16 | 17 | exit 1 18 | -------------------------------------------------------------------------------- /chart/docker-auth/.helmignore: -------------------------------------------------------------------------------- 1 | # Patterns to ignore when building packages. 2 | # This supports shell glob matching, relative path matching, and 3 | # negation (prefixed with !). Only one pattern per line. 4 | .DS_Store 5 | # Common VCS dirs 6 | .git/ 7 | .gitignore 8 | .bzr/ 9 | .bzrignore 10 | .hg/ 11 | .hgignore 12 | .svn/ 13 | # Common backup files 14 | *.swp 15 | *.bak 16 | *.tmp 17 | *~ 18 | # Various IDEs 19 | .project 20 | .idea/ 21 | *.tmproj 22 | .vscode/ 23 | Makefile 24 | -------------------------------------------------------------------------------- /auth_server/authn/data/oidc_auth.tmpl: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Docker Registry Authentication 7 | 8 | 9 | 10 |
11 |

12 | 13 | Login with OIDC Provider 14 | 15 |

16 |
17 | 18 | 19 | -------------------------------------------------------------------------------- /docs/casbin_backend.md: -------------------------------------------------------------------------------- 1 | # Casbin Backend 2 | 3 | [Casbin](https://github.com/casbin/casbin) is a powerful and efficient open-source access control library written by Golang. It provides support for enforcing authorization based on various access control models. 4 | 5 | ## Usage 6 | 7 | add casbin section in yml configuration file 8 | 9 | ```yaml 10 | casbin_authz: 11 | model_path: "path/to/model" 12 | policy_path: "path/to/policy" 13 | ``` 14 | 15 | more info see: https://github.com/casbin/casbin -------------------------------------------------------------------------------- /examples/casbin_authz_model.conf: -------------------------------------------------------------------------------- 1 | [request_definition] 2 | r = account, type, name, service, ip, action, labels 3 | 4 | [policy_definition] 5 | p = account, type, name, service, ip, action, labels 6 | 7 | [role_definition] 8 | g = _, _ 9 | 10 | [policy_effect] 11 | e = some(where (p.eft == allow)) 12 | 13 | [matchers] 14 | m = r.account == "admin" || (g(r.account, p.account) && r.type == p.type && r.name == p.name && r.service == p.service && ipMatch(r.ip, p.ip) && r.action == p.action && labelMatch(r.labels, p.labels)) -------------------------------------------------------------------------------- /auth_server/authz/set.go: -------------------------------------------------------------------------------- 1 | package authz 2 | 3 | import ( 4 | "sort" 5 | 6 | mapset "github.com/deckarep/golang-set" 7 | ) 8 | 9 | func makeSet(ss []string) mapset.Set { 10 | set := mapset.NewSet() 11 | for _, s := range ss { 12 | set.Add(s) 13 | } 14 | return set 15 | } 16 | 17 | func StringSetIntersection(a, b []string) []string { 18 | as := makeSet(a) 19 | bs := makeSet(b) 20 | d := []string{} 21 | for s := range as.Intersect(bs).Iter() { 22 | d = append(d, s.(string)) 23 | } 24 | sort.Strings(d) 25 | return d 26 | } 27 | -------------------------------------------------------------------------------- /auth_server/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.24-alpine3.22 AS build 2 | 3 | ARG VERSION 4 | ENV VERSION="${VERSION}" 5 | ARG BUILD_ID 6 | ENV BUILD_ID="${BUILD_ID}" 7 | ARG CGO_EXTRA_CFLAGS 8 | 9 | RUN apk add -U --no-cache ca-certificates make git gcc musl-dev binutils-gold 10 | 11 | COPY . /build 12 | WORKDIR /build 13 | RUN make build 14 | 15 | FROM alpine:3.22 16 | COPY --from=build /build/auth_server /docker_auth/ 17 | COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ 18 | ENTRYPOINT ["/docker_auth/auth_server"] 19 | CMD ["/config/auth_config.yml"] 20 | EXPOSE 5001 21 | -------------------------------------------------------------------------------- /chart/docker-auth/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v2 2 | appVersion: "1.14.0" 3 | description: Docker Registry V2 authentication server 4 | name: docker-auth 5 | version: 1.14.1 6 | kubeVersion: ">=1.25" 7 | keywords: 8 | - docker 9 | - registry 10 | - docker-auth 11 | - docker-registry 12 | - token 13 | home: https://github.com/cesanta/docker_auth 14 | sources: 15 | - https://github.com/cesanta/docker_auth 16 | maintainers: 17 | - name: duyanghao 18 | email: 1294057873@qq.com 19 | - name: pfisterer 20 | email: github@farberg.de 21 | - name: techknowlogick 22 | email: hello@techknowlogick.com 23 | engine: gotpl 24 | -------------------------------------------------------------------------------- /.github/workflows/go_test.yml: -------------------------------------------------------------------------------- 1 | on: [push, pull_request] 2 | name: Test 3 | jobs: 4 | test: 5 | strategy: 6 | matrix: 7 | go-version: [1.23.x,1.24.x] 8 | os: [ubuntu-latest] 9 | runs-on: ${{ matrix.os }} 10 | steps: 11 | - name: Install Go 12 | uses: actions/setup-go@v5 13 | with: 14 | go-version: ${{ matrix.go-version }} 15 | - name: Checkout code 16 | uses: actions/checkout@v4 17 | - name: Test 18 | run: | 19 | cd auth_server 20 | go test ./... 21 | - name: Build 22 | run: | 23 | cd auth_server 24 | make 25 | -------------------------------------------------------------------------------- /chart/docker-auth/templates/tests/test-connection.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Pod 3 | metadata: 4 | name: "{{ include "docker-auth.fullname" . }}-test-connection" 5 | labels: 6 | app.kubernetes.io/name: {{ include "docker-auth.name" . }} 7 | helm.sh/chart: {{ include "docker-auth.chart" . }} 8 | app.kubernetes.io/instance: {{ .Release.Name }} 9 | app.kubernetes.io/managed-by: {{ .Release.Service }} 10 | annotations: 11 | "helm.sh/hook": test-success 12 | spec: 13 | containers: 14 | - name: wget 15 | image: busybox 16 | command: ['wget'] 17 | args: ['{{ include "docker-auth.fullname" . }}:{{ .Values.service.port }}'] 18 | restartPolicy: Never 19 | -------------------------------------------------------------------------------- /chart/docker-auth/templates/service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: {{ include "docker-auth.fullname" . }} 5 | labels: 6 | app.kubernetes.io/name: {{ include "docker-auth.name" . }} 7 | helm.sh/chart: {{ include "docker-auth.chart" . }} 8 | app.kubernetes.io/instance: {{ .Release.Name }} 9 | app.kubernetes.io/managed-by: {{ .Release.Service }} 10 | spec: 11 | type: {{ .Values.service.type }} 12 | ports: 13 | - port: {{ .Values.service.port }} 14 | targetPort: {{ .Values.service.targetPort }} 15 | protocol: TCP 16 | name: http 17 | selector: 18 | app.kubernetes.io/name: {{ include "docker-auth.name" . }} 19 | app.kubernetes.io/instance: {{ .Release.Name }} 20 | -------------------------------------------------------------------------------- /auth_server/authn/authn.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Cesanta Software Ltd. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | https://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package authn 18 | 19 | import "embed" 20 | 21 | //go:embed data/* 22 | var static embed.FS 23 | -------------------------------------------------------------------------------- /auth_server/authz/acl_xorm_sqlite.go: -------------------------------------------------------------------------------- 1 | //+build sqlite 2 | 3 | /* 4 | Copyright 2020 Cesanta Software Ltd. 5 | 6 | Licensed under the Apache License, Version 2.0 (the "License"); 7 | you may not use this file except in compliance with the License. 8 | You may obtain a copy of the License at 9 | 10 | https://www.apache.org/licenses/LICENSE-2.0 11 | 12 | Unless required by applicable law or agreed to in writing, software 13 | distributed under the License is distributed on an "AS IS" BASIS, 14 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | See the License for the specific language governing permissions and 16 | limitations under the License. 17 | */ 18 | 19 | package authz 20 | 21 | import ( 22 | _ "github.com/mattn/go-sqlite3" 23 | ) 24 | 25 | func init() { 26 | EnableSQLite3 = true 27 | } 28 | -------------------------------------------------------------------------------- /auth_server/authn/xorm_sqlite_authn.go: -------------------------------------------------------------------------------- 1 | //+build sqlite 2 | 3 | /* 4 | Copyright 2020 Cesanta Software Ltd. 5 | 6 | Licensed under the Apache License, Version 2.0 (the "License"); 7 | you may not use this file except in compliance with the License. 8 | You may obtain a copy of the License at 9 | 10 | https://www.apache.org/licenses/LICENSE-2.0 11 | 12 | Unless required by applicable law or agreed to in writing, software 13 | distributed under the License is distributed on an "AS IS" BASIS, 14 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | See the License for the specific language governing permissions and 16 | limitations under the License. 17 | */ 18 | 19 | package authn 20 | 21 | import ( 22 | _ "github.com/mattn/go-sqlite3" 23 | ) 24 | 25 | func init() { 26 | EnableSQLite3 = true 27 | } 28 | -------------------------------------------------------------------------------- /auth_server/authn/data/oidc_auth_result.tmpl: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Docker Registry Authentication 7 | 8 | 9 | 10 |

11 | You are successfully authenticated for the Docker Registry. 12 | Log into the registry using one of these commands: 13 |

14 |
15 |
$ docker login -u {{.Username}} -p {{.Password}} {{if .RegistryUrl}}{{.RegistryUrl}}{{else}}docker.example.com{{end}}
16 |
$ podman login -u {{.Username}} -p {{.Password}} {{if .RegistryUrl}}{{.RegistryUrl}}{{else}}docker.example.com{{end}}
17 |
$ nerdctl login -u {{.Username}} -p {{.Password}} {{if .RegistryUrl}}{{.RegistryUrl}}{{else}}docker.example.com{{end}}
18 | 19 | 20 | -------------------------------------------------------------------------------- /docs/auth-methods.md: -------------------------------------------------------------------------------- 1 | ## Github 2 | 3 | First you need to setup a [Github OAuth Application](https://github.com/settings/applications). 4 | 5 | - The callback url needs to be `$fqdn:5001/github_auth` 6 | - `$fqdn` is the domain where docker_auth is accessed 7 | - `5001` or what port is specified in the `server` block 8 | 9 | Once you have setup a Github OAuth application you need to add a `github` block to the docker_auth config file: 10 | 11 | ```yaml 12 | github_auth: 13 | organization: "my-org-name" 14 | client_id: "..." 15 | client_secret: "..." # or client_secret_file 16 | level_token_db: 17 | path: /data/tokens.db 18 | # Optional token hash cost for bcrypt hashing 19 | # token_hash_cost: 5 20 | ``` 21 | 22 | Then specify what teams can do via acls 23 | 24 | ```yaml 25 | acl: 26 | - match: {team: "infrastructure"} 27 | actions: ["pull", "push"] 28 | comment: "Infrastructure team members can push and all images" 29 | ``` 30 | -------------------------------------------------------------------------------- /auth_server/Makefile: -------------------------------------------------------------------------------- 1 | MAKEFLAGS += --warn-undefined-variables 2 | IMAGE ?= cesanta/docker_auth 3 | VERSION ?= $(shell go run ./gen_version.go | awk '{print $$1}') 4 | BUILD_ID ?= $(shell go run ./gen_version.go | awk '{print $$2}') 5 | 6 | .PHONY: % 7 | 8 | all: build 9 | 10 | build: 11 | go build -v -ldflags="-extldflags '-static' -X 'main.Version=${VERSION}' -X 'main.BuildID=${BUILD_ID}'" 12 | 13 | auth_server: 14 | @echo 15 | @echo Use build or build-release to produce the auth_server binary 16 | @echo 17 | @exit 1 18 | 19 | docker-build: 20 | docker build --build-arg VERSION="${VERSION}" --build-arg BUILD_ID="${BUILD_ID}" -t $(IMAGE):latest . 21 | docker tag $(IMAGE):latest $(IMAGE):$(VERSION) 22 | 23 | docker-tag-%: 24 | docker tag $(IMAGE):latest $(IMAGE):$* 25 | 26 | docker-push: 27 | docker push $(IMAGE):latest 28 | docker push $(IMAGE):$(VERSION) 29 | 30 | docker-push-%: docker-tag-% 31 | docker push $(IMAGE):$* 32 | 33 | clean: 34 | rm -rf auth_server vendor/*/* 35 | -------------------------------------------------------------------------------- /chart/docker-auth/Makefile: -------------------------------------------------------------------------------- 1 | CHART_NAME := docker-auth 2 | CHART_VERSION := $(shell grep '^version:' Chart.yaml | cut -d' ' -f2) 3 | PACKAGE_NAME := $(CHART_NAME)-$(CHART_VERSION).tgz 4 | 5 | # Repository settings 6 | REPO_URL := https://cesanta.github.io/docker_auth/ 7 | DOCS_DIR := ../../docs 8 | 9 | .PHONY: lint 10 | lint: 11 | helm lint . 12 | 13 | .PHONY: test 14 | test: 15 | helm template test-release . --dry-run > /dev/null 16 | 17 | .PHONY: validate 18 | validate: lint test ## Run all validation checks 19 | @echo "All validations passed" 20 | 21 | .PHONY: package 22 | package: validate ## Package the helm chart 23 | helm package . 24 | 25 | .PHONY: update-repo 26 | update-repo: package 27 | mv $(PACKAGE_NAME) $(DOCS_DIR)/ 28 | helm repo index $(DOCS_DIR)/ --url $(REPO_URL) 29 | @echo "Repository updated" 30 | @echo "" 31 | @echo "Please review changes, then commit and push the changes to GitHub." 32 | 33 | .PHONY: debug 34 | debug: 35 | helm template debug-$(CHART_NAME) . --debug 36 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | schedule: 9 | - cron: '32 3 * * 5' 10 | 11 | jobs: 12 | analyze: 13 | name: Analyze 14 | runs-on: ubuntu-latest 15 | permissions: 16 | actions: read 17 | contents: read 18 | security-events: write 19 | 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | language: [ 'go' ] 24 | steps: 25 | - name: Checkout repository 26 | uses: actions/checkout@v4 27 | 28 | # Initializes the CodeQL tools for scanning. 29 | - name: Initialize CodeQL 30 | uses: github/codeql-action/init@v3 31 | with: 32 | languages: ${{ matrix.language }} 33 | 34 | - name: Autobuild 35 | uses: github/codeql-action/autobuild@v3 36 | 37 | #- run: | 38 | # make bootstrap 39 | # make release 40 | 41 | - name: Perform CodeQL Analysis 42 | uses: github/codeql-action/analyze@v3 43 | -------------------------------------------------------------------------------- /chart/docker-auth/templates/configmap.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ConfigMap 3 | metadata: 4 | name: {{ include "docker-auth.name" . }} 5 | data: 6 | auth_config.yml: | 7 | server: 8 | addr: ":{{ .Values.service.targetPort }}" 9 | token: 10 | issuer: "{{ .Values.configmap.data.token.issuer }}" # Must match issuer in the Registry config. 11 | expiration: {{ .Values.configmap.data.token.expiration }} 12 | {{- if .Values.secret.secretName }} 13 | certificate: "/config/certs/{{ default "tls.crt" .Values.secret.certificateFileName }}" 14 | key: "/config/certs/{{ default "tls.key" .Values.secret.keyFileName }}" 15 | {{- else }} 16 | certificate: "/config/certs/server.pem" 17 | key: "/config/certs/server.key" 18 | {{- end }} 19 | {{- if .Values.configmap.data.token.disableLegacyKeyId }} 20 | disable_legacy_key_id: {{ .Values.configmap.data.token.disableLegacyKeyId }} 21 | {{- end }} 22 | users: 23 | {{ .Values.configmap.data.users | toYaml | nindent 6 }} 24 | acl: 25 | {{ .Values.configmap.data.acl | toYaml | nindent 6 }} 26 | -------------------------------------------------------------------------------- /examples/non_tls.yml: -------------------------------------------------------------------------------- 1 | # A non-tls example. See reference.yml for explanation of all options. 2 | # 3 | # auth: 4 | # token: 5 | # realm: "http://127.0.0.1:5001/auth" 6 | # service: "Docker registry" 7 | # issuer: "Acme auth server" 8 | # rootcertbundle: "/path/to/server.pem" 9 | 10 | server: 11 | addr: ":5001" 12 | 13 | token: 14 | issuer: "Acme auth server" # Must match issuer in the Registry config. 15 | expiration: 900 16 | certificate: "/path/to/server.pem" 17 | key: "/path/to/server.key" 18 | 19 | users: 20 | # Password is specified as a BCrypt hash. Use `htpasswd -nB USERNAME` to generate. 21 | "admin": 22 | password: "$2y$05$LO.vzwpWC5LZGqThvEfznu8qhb5SGqvBSWY1J3yZ4AxtMRZ3kN5jC" # badmin 23 | "test": 24 | password: "$2y$05$WuwBasGDAgr.QCbGIjKJaep4dhxeai9gNZdmBnQXqpKly57oNutya" # 123 25 | 26 | acl: 27 | - match: {account: "admin"} 28 | actions: ["*"] 29 | comment: "Admin has full access to everything." 30 | - match: {account: "user"} 31 | actions: ["pull"] 32 | comment: "User \"user\" can pull stuff." 33 | # Access is denied by default. 34 | -------------------------------------------------------------------------------- /chart/docker-auth/templates/_helpers.tpl: -------------------------------------------------------------------------------- 1 | {{/* vim: set filetype=mustache: */}} 2 | {{/* 3 | Expand the name of the chart. 4 | */}} 5 | {{- define "docker-auth.name" -}} 6 | {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} 7 | {{- end -}} 8 | 9 | {{/* 10 | Create a default fully qualified app name. 11 | We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). 12 | If release name contains chart name it will be used as a full name. 13 | */}} 14 | {{- define "docker-auth.fullname" -}} 15 | {{- if .Values.fullnameOverride -}} 16 | {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} 17 | {{- else -}} 18 | {{- $name := default .Chart.Name .Values.nameOverride -}} 19 | {{- if contains $name .Release.Name -}} 20 | {{- .Release.Name | trunc 63 | trimSuffix "-" -}} 21 | {{- else -}} 22 | {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} 23 | {{- end -}} 24 | {{- end -}} 25 | {{- end -}} 26 | 27 | {{/* 28 | Create chart name and version as used by the chart label. 29 | */}} 30 | {{- define "docker-auth.chart" -}} 31 | {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}} 32 | {{- end -}} 33 | -------------------------------------------------------------------------------- /examples/simple.yml: -------------------------------------------------------------------------------- 1 | # A simple example. See reference.yml for explanation of all options. 2 | # 3 | # auth: 4 | # token: 5 | # realm: "https://127.0.0.1:5001/auth" 6 | # service: "Docker registry" 7 | # issuer: "Acme auth server" 8 | # rootcertbundle: "/path/to/server.pem" 9 | 10 | server: 11 | addr: ":5001" 12 | certificate: "/path/to/server.pem" 13 | key: "/path/to/server.key" 14 | 15 | token: 16 | issuer: "Acme auth server" # Must match issuer in the Registry config. 17 | expiration: 900 18 | # Uncomment the following line if you are using registry v3, leave it commented if you are using registry v2 19 | # disable_legacy_key_id: true 20 | 21 | users: 22 | # Password is specified as a BCrypt hash. Use `htpasswd -nB USERNAME` to generate. 23 | "admin": 24 | password: "$2y$05$LO.vzwpWC5LZGqThvEfznu8qhb5SGqvBSWY1J3yZ4AxtMRZ3kN5jC" # badmin 25 | "test": 26 | password: "$2y$05$WuwBasGDAgr.QCbGIjKJaep4dhxeai9gNZdmBnQXqpKly57oNutya" # 123 27 | 28 | acl: 29 | - match: {account: "admin"} 30 | actions: ["*"] 31 | comment: "Admin has full access to everything." 32 | - match: {account: "test"} 33 | actions: ["pull"] 34 | comment: "User \"test\" can pull stuff." 35 | # Access is denied by default. 36 | -------------------------------------------------------------------------------- /auth_server/authn/data/gitlab_auth.tmpl: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Docker Registry Authentication 7 | 28 | 29 | 30 | 31 |
32 |

33 | 34 |

35 |

36 | 37 | Login 38 | 39 |

40 |

41 | Revoke access 42 |

43 |
44 | 45 | 46 | -------------------------------------------------------------------------------- /examples/ldap_auth.yml: -------------------------------------------------------------------------------- 1 | # LDAP server authentication example. 2 | # See reference.yml for additional options. 3 | 4 | server: 5 | addr: :5001 6 | certificate: /path/to/server.pem 7 | key: /path/to/server.key 8 | token: 9 | issuer: Acme auth server 10 | expiration: 900 11 | ldap_auth: 12 | # Addr is the hostname:port or ip:port 13 | addr: ldap.example.com:636 14 | # Setup tls connection method to be 15 | # "" or "none": the communication won't be encrypted 16 | # "always": setup LDAP over SSL/TLS 17 | # "starttls": sets StartTLS as the encryption method 18 | tls: always 19 | # set to true to allow insecure tls 20 | insecure_tls_skip_verify: false 21 | # set this to specify the ca certificate path 22 | ca_certificate: 23 | # In case bind DN and password is required for querying user information, 24 | # specify them here. Plain text password is read from the file. 25 | bind_dn: 26 | bind_password_file: 27 | # If the auth request credentials shall be used for the initial LDAP bind, 28 | # set this to true and refer to ${account} in the bind_dn field. 29 | # The bind_password_file setting is ignored in this case. 30 | initial_bind_as_user: true 31 | bind_dn: "cn=${account},cn=users,dc=example,dc=com" 32 | # User query settings. ${account} is expanded from auth request 33 | base: o=example.com 34 | filter: (&(uid=${account})(objectClass=person)) 35 | acl: 36 | # This will allow authenticated users to pull/push 37 | - match: 38 | account: /.+/ 39 | actions: ['*'] 40 | -------------------------------------------------------------------------------- /chart/docker-auth/templates/ingress.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.ingress.enabled -}} 2 | {{- $serviceName := include "docker-auth.fullname" . -}} 3 | {{- $servicePort := .Values.service.port -}} 4 | apiVersion: networking.k8s.io/v1 5 | kind: Ingress 6 | metadata: 7 | name: {{ include "docker-auth.fullname" . }} 8 | namespace: {{ .Release.Namespace }} 9 | labels: 10 | app.kubernetes.io/name: {{ include "docker-auth.name" . }} 11 | helm.sh/chart: {{ include "docker-auth.chart" . }} 12 | app.kubernetes.io/instance: {{ .Release.Name }} 13 | app.kubernetes.io/managed-by: {{ .Release.Service }} 14 | {{- with .Values.ingress.labels }} 15 | {{- toYaml . | nindent 4 }} 16 | {{- end }} 17 | {{- with .Values.ingress.annotations }} 18 | annotations: 19 | {{- toYaml . | nindent 4 }} 20 | {{- end }} 21 | spec: 22 | {{- if .Values.ingress.className }} 23 | ingressClassName: {{ .Values.ingress.className }} 24 | {{- end }} 25 | {{- if .Values.ingress.tls }} 26 | tls: 27 | {{- range .Values.ingress.tls }} 28 | - hosts: 29 | {{- range .hosts }} 30 | - {{ . | quote }} 31 | {{- end }} 32 | secretName: {{ .secretName }} 33 | {{- end }} 34 | {{- end }} 35 | rules: 36 | {{- range .Values.ingress.hosts }} 37 | - host: {{ .host | quote }} 38 | http: 39 | paths: 40 | {{- range .paths }} 41 | - path: {{ .path }} 42 | pathType: {{ .pathType | default "Prefix" }} 43 | backend: 44 | service: 45 | name: {{ $serviceName }} 46 | port: 47 | number: {{ $servicePort }} 48 | {{- end }} 49 | {{- end }} 50 | {{- end }} 51 | -------------------------------------------------------------------------------- /auth_server/authn/data/gitlab_auth_result.tmpl: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Docker Registry Authentication 7 | 42 | 43 | 44 |

45 | You are successfully authenticated to the Docker Registry. 46 | Log into the registry using one of these commands: 47 |

48 |
49 |
$ docker login -u {{.Username}} -p {{.Password}} {{if .RegistryUrl}}{{.RegistryUrl}}{{else}}docker.example.com{{end}}
50 |
$ podman login -u {{.Username}} -p {{.Password}} {{if .RegistryUrl}}{{.RegistryUrl}}{{else}}docker.example.com{{end}}
51 |
$ nerdctl login -u {{.Username}} -p {{.Password}} {{if .RegistryUrl}}{{.RegistryUrl}}{{else}}docker.example.com{{end}}
52 | 53 | 54 | -------------------------------------------------------------------------------- /auth_server/authn/data/github_auth_result.tmpl: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Docker Registry Authentication 7 | 42 | 43 | 44 |

45 | You are successfully authenticated for the Docker Registry{{if .Organization}} with the @{{.Organization}} Github organization{{end}}. 46 | Log into the registry using one of these commands: 47 |

48 |
49 |
$ docker login -u {{.Username}} -p {{.Password}} {{if .RegistryUrl}}{{.RegistryUrl}}{{else}}docker.example.com{{end}}
50 |
$ podman login -u {{.Username}} -p {{.Password}} {{if .RegistryUrl}}{{.RegistryUrl}}{{else}}docker.example.com{{end}}
51 |
$ nerdctl login -u {{.Username}} -p {{.Password}} {{if .RegistryUrl}}{{.RegistryUrl}}{{else}}docker.example.com{{end}}
52 | 53 | 54 | -------------------------------------------------------------------------------- /auth_server/api/authn.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2019 Cesanta Software Ltd. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | https://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package api 18 | 19 | import "errors" 20 | 21 | type Labels map[string][]string 22 | 23 | // Authentication plugin interface. 24 | type Authenticator interface { 25 | // Given a user name and a password (plain text), responds with the result or an error. 26 | // Error should only be reported if request could not be serviced, not if it should be denied. 27 | // A special NoMatch error is returned if the authorizer could not reach a decision, 28 | // e.g. none of the rules matched. 29 | // Another special WrongPass error is returned if the authorizer failed to authenticate. 30 | // Implementations must be goroutine-safe. 31 | Authenticate(user string, password PasswordString) (bool, Labels, error) 32 | 33 | // Finalize resources in preparation for shutdown. 34 | // When this call is made there are guaranteed to be no Authenticate requests in flight 35 | // and there will be no more calls made to this instance. 36 | Stop() 37 | 38 | // Human-readable name of the authenticator. 39 | Name() string 40 | } 41 | 42 | var NoMatch = errors.New("did not match any rule") 43 | var WrongPass = errors.New("wrong password for user") 44 | 45 | type PasswordString string 46 | 47 | func (ps PasswordString) String() string { 48 | if len(ps) == 0 { 49 | return "" 50 | } 51 | return "***" 52 | } 53 | -------------------------------------------------------------------------------- /auth_server/authn/static_auth.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2015 Cesanta Software Ltd. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | https://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package authn 18 | 19 | import ( 20 | "encoding/json" 21 | "golang.org/x/crypto/bcrypt" 22 | 23 | "github.com/cesanta/docker_auth/auth_server/api" 24 | ) 25 | 26 | type Requirements struct { 27 | Password *api.PasswordString `yaml:"password,omitempty" json:"password,omitempty"` 28 | Labels api.Labels `yaml:"labels,omitempty" json:"labels,omitempty"` 29 | } 30 | 31 | type staticUsersAuth struct { 32 | users map[string]*Requirements 33 | } 34 | 35 | func (r Requirements) String() string { 36 | p := r.Password 37 | if p != nil { 38 | pm := api.PasswordString("***") 39 | r.Password = &pm 40 | } 41 | b, _ := json.Marshal(r) 42 | r.Password = p 43 | return string(b) 44 | } 45 | 46 | func NewStaticUserAuth(users map[string]*Requirements) *staticUsersAuth { 47 | return &staticUsersAuth{users: users} 48 | } 49 | 50 | func (sua *staticUsersAuth) Authenticate(user string, password api.PasswordString) (bool, api.Labels, error) { 51 | reqs := sua.users[user] 52 | if reqs == nil { 53 | return false, nil, api.NoMatch 54 | } 55 | if reqs.Password != nil { 56 | if bcrypt.CompareHashAndPassword([]byte(*reqs.Password), []byte(password)) != nil { 57 | return false, nil, nil 58 | } 59 | } 60 | return true, reqs.Labels, nil 61 | } 62 | 63 | func (sua *staticUsersAuth) Stop() { 64 | } 65 | 66 | func (sua *staticUsersAuth) Name() string { 67 | return "static" 68 | } 69 | -------------------------------------------------------------------------------- /chart/docker-auth/templates/NOTES.txt: -------------------------------------------------------------------------------- 1 | 1. Get the application URL by running these commands: 2 | {{- if .Values.ingress.enabled }} 3 | {{- range .Values.ingress.hosts }} 4 | {{- range .paths }} 5 | http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $.host }}{{ .path }} 6 | {{- end }} 7 | {{- end }} 8 | {{- else if contains "NodePort" .Values.service.type }} 9 | export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "docker-auth.fullname" . }}) 10 | export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}") 11 | echo http://$NODE_IP:$NODE_PORT 12 | {{- else if contains "LoadBalancer" .Values.service.type }} 13 | NOTE: It may take a few minutes for the LoadBalancer IP to be available. 14 | You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "docker-auth.fullname" . }}' 15 | export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "docker-auth.fullname" . }} -o jsonpath='{.status.loadBalancer.ingress[0].ip}') 16 | echo http://$SERVICE_IP:{{ .Values.service.port }} 17 | {{- else if contains "ClusterIP" .Values.service.type }} 18 | export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "docker-auth.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}") 19 | echo "Visit http://127.0.0.1:8080 to use your application" 20 | kubectl port-forward $POD_NAME 8080:80 21 | {{- end }} 22 | 23 | {{- if not .Values.registry.enabled }} 24 | 2. Configure your docker registry: 25 | auth: 26 | token: 27 | autoredirect: false 28 | {{- if .Values.ingress.enabled }} 29 | {{- range .Values.ingress.hosts }} 30 | {{- range .paths }} 31 | realm: http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $.host }}{{ .path }} 32 | {{- end }} 33 | {{- end }} 34 | {{- end }} 35 | service: token-service 36 | issuer: {{ .Values.configmap.data.token.issuer }} 37 | rootcertbundle: /config/certs/{{ .Values.secret.certificateFileName }} 38 | {{- end }} 39 | -------------------------------------------------------------------------------- /auth_server/api/authz.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2019 Cesanta Software Ltd. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | https://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package api 18 | 19 | import ( 20 | "fmt" 21 | "net" 22 | "strings" 23 | ) 24 | 25 | // Authorizer interface performs authorization of the request. 26 | // It is invoked after authentication so it can be assumed that the requestor has 27 | // presented satisfactory credentials for Account. 28 | // Principally, it answers the question: is this Account allowed to perform these Actions 29 | // on this Type.Name subject in the give Service? 30 | type Authorizer interface { 31 | // Authorize performs authorization given the request information. 32 | // It returns a set of authorized actions (of the set requested), which can be empty/nil. 33 | // Error should only be reported if request could not be serviced, not if it should be denied. 34 | // A special NoMatch error is returned if the authorizer could not reach a decision, 35 | // e.g. none of the rules matched. 36 | // Implementations must be goroutine-safe. 37 | Authorize(ai *AuthRequestInfo) ([]string, error) 38 | 39 | // Finalize resources in preparation for shutdown. 40 | // When this call is made there are guaranteed to be no Authenticate requests in flight 41 | // and there will be no more calls made to this instance. 42 | Stop() 43 | 44 | // Human-readable name of the authenticator. 45 | Name() string 46 | } 47 | 48 | type AuthRequestInfo struct { 49 | Account string 50 | Type string 51 | Name string 52 | Service string 53 | IP net.IP 54 | Actions []string 55 | Labels Labels 56 | } 57 | 58 | func (ai AuthRequestInfo) String() string { 59 | return fmt.Sprintf("{%s %s %s %s}", ai.Account, strings.Join(ai.Actions, ","), ai.Type, ai.Name) 60 | } 61 | -------------------------------------------------------------------------------- /auth_server/authz/plugin_authz.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2019 Cesanta Software Ltd. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | https://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package authz 18 | 19 | import ( 20 | "fmt" 21 | "plugin" 22 | 23 | "github.com/cesanta/glog" 24 | 25 | "github.com/cesanta/docker_auth/auth_server/api" 26 | ) 27 | 28 | type PluginAuthzConfig struct { 29 | PluginPath string `yaml:"plugin_path"` 30 | } 31 | 32 | func lookupAuthzSymbol(cfg *PluginAuthzConfig) (api.Authorizer, error) { 33 | // load module 34 | plug, err := plugin.Open(cfg.PluginPath) 35 | if err != nil { 36 | return nil, fmt.Errorf("error while loading authz plugin: %v", err) 37 | } 38 | 39 | // look up for Authz 40 | symAuthen, err := plug.Lookup("Authz") 41 | if err != nil { 42 | return nil, fmt.Errorf("error while loading authz exporting the variable: %v", err) 43 | } 44 | 45 | // assert that loaded symbol is of a desired type 46 | var authz api.Authorizer 47 | authz, ok := symAuthen.(api.Authorizer) 48 | if !ok { 49 | return nil, fmt.Errorf("unexpected type from module symbol. Unable to cast Authz module") 50 | } 51 | return authz, nil 52 | } 53 | 54 | func (c *PluginAuthzConfig) Validate() error { 55 | _, err := lookupAuthzSymbol(c) 56 | return err 57 | } 58 | 59 | type PluginAuthz struct { 60 | Authz api.Authorizer 61 | } 62 | 63 | func (c *PluginAuthz) Stop() { 64 | } 65 | 66 | func (c *PluginAuthz) Name() string { 67 | return "plugin authz" 68 | } 69 | 70 | func NewPluginAuthzAuthorizer(cfg *PluginAuthzConfig) (*PluginAuthz, error) { 71 | glog.Infof("Plugin authorization: %s", cfg) 72 | authz, err := lookupAuthzSymbol(cfg) 73 | if err != nil { 74 | return nil, err 75 | } 76 | return &PluginAuthz{Authz: authz}, nil 77 | } 78 | 79 | func (c *PluginAuthz) Authorize(ai *api.AuthRequestInfo) ([]string, error) { 80 | // use the plugin 81 | return c.Authz.Authorize(ai) 82 | } 83 | -------------------------------------------------------------------------------- /auth_server/authn/plugin_authn.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2019 Cesanta Software Ltd. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | https://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package authn 18 | 19 | import ( 20 | "fmt" 21 | "plugin" 22 | 23 | "github.com/cesanta/glog" 24 | 25 | "github.com/cesanta/docker_auth/auth_server/api" 26 | ) 27 | 28 | type PluginAuthnConfig struct { 29 | PluginPath string `yaml:"plugin_path"` 30 | } 31 | 32 | func lookupAuthnSymbol(cfg *PluginAuthnConfig) (api.Authenticator, error) { 33 | // load module 34 | plug, err := plugin.Open(cfg.PluginPath) 35 | if err != nil { 36 | return nil, fmt.Errorf("error while loading authn plugin: %v", err) 37 | } 38 | 39 | // look up for Authn 40 | symAuthen, err := plug.Lookup("Authn") 41 | if err != nil { 42 | return nil, fmt.Errorf("error while loading authn exporting the variable: %v", err) 43 | } 44 | 45 | // assert that loaded symbol is of a desired type 46 | var authn api.Authenticator 47 | authn, ok := symAuthen.(api.Authenticator) 48 | if !ok { 49 | return nil, fmt.Errorf("unexpected type from module symbol. Unable to cast Authn module") 50 | } 51 | return authn, nil 52 | } 53 | 54 | func (c *PluginAuthnConfig) Validate() error { 55 | _, err := lookupAuthnSymbol(c) 56 | return err 57 | } 58 | 59 | type PluginAuthn struct { 60 | cfg *PluginAuthnConfig 61 | Authn api.Authenticator 62 | } 63 | 64 | func (c *PluginAuthn) Authenticate(user string, password api.PasswordString) (bool, api.Labels, error) { 65 | // use the plugin 66 | return c.Authn.Authenticate(user, password) 67 | } 68 | 69 | func (c *PluginAuthn) Stop() { 70 | } 71 | 72 | func (c *PluginAuthn) Name() string { 73 | return "plugin auth" 74 | } 75 | 76 | func NewPluginAuthn(cfg *PluginAuthnConfig) (*PluginAuthn, error) { 77 | glog.Infof("Plugin authenticator: %s", cfg) 78 | authn, err := lookupAuthnSymbol(cfg) 79 | if err != nil { 80 | return nil, err 81 | } 82 | return &PluginAuthn{Authn: authn}, nil 83 | } 84 | -------------------------------------------------------------------------------- /chart/docker-auth/values.yaml: -------------------------------------------------------------------------------- 1 | # Default values for docker-auth. 2 | # This is a YAML-formatted file. 3 | # Declare variables to be passed into your templates. 4 | 5 | replicaCount: 1 6 | 7 | image: 8 | repository: cesanta/docker_auth 9 | tag: 1.14.0 10 | pullPolicy: IfNotPresent 11 | 12 | registry: 13 | enabled: false 14 | 15 | logging: 16 | level: 2 17 | 18 | secret: 19 | data: 20 | server: 21 | certificate: "" 22 | key: "" 23 | configmap: 24 | data: 25 | token: 26 | issuer: "Acme auth server" 27 | expiration: 900 28 | disableLegacyKeyId: false 29 | users: 30 | "admin": 31 | password: "$2y$05$LO.vzwpWC5LZGqThvEfznu8qhb5SGqvBSWY1J3yZ4AxtMRZ3kN5jC" # password: badmin 32 | "test": 33 | password: "$2y$05$WuwBasGDAgr.QCbGIjKJaep4dhxeai9gNZdmBnQXqpKly57oNutya" # password: 123 34 | acl: 35 | - match: {account: "admin"} 36 | actions: ["*"] 37 | comment: "Admin has full access to everything." 38 | - match: {account: "test"} 39 | actions: ["pull"] 40 | comment: "User \"test\" can pull stuff." 41 | 42 | nameOverride: "" 43 | fullnameOverride: "" 44 | 45 | service: 46 | type: ClusterIP 47 | port: 5001 48 | targetPort: 5001 49 | 50 | ingress: 51 | enabled: true 52 | className: "" 53 | annotations: {} 54 | # kubernetes.io/ingress.class: nginx 55 | # kubernetes.io/tls-acme: "true" 56 | # nginx.ingress.kubernetes.io/force-ssl-redirect: "true" 57 | labels: {} 58 | hosts: 59 | - host: docker-auth.test.com 60 | paths: 61 | - path: / 62 | pathType: Prefix 63 | tls: [] 64 | # - secretName: chart-example-tls 65 | # hosts: 66 | # - chart-example.local 67 | 68 | resources: {} 69 | # We usually recommend not to specify default resources and to leave this as a conscious 70 | # choice for the user. This also increases chances charts run on environments with little 71 | # resources, such as Minikube. If you do want to specify resources, uncomment the following 72 | # lines, adjust them as necessary, and remove the curly braces after 'resources:'. 73 | # limits: 74 | # cpu: 100m 75 | # memory: 128Mi 76 | # requests: 77 | # cpu: 100m 78 | # memory: 128Mi 79 | 80 | nodeSelector: {} 81 | 82 | tolerations: [] 83 | 84 | affinity: {} 85 | 86 | # podAnnotations to use for the deployment. Optional 87 | podAnnotations: {} 88 | 89 | # SecurityContext at container level to use for the deployment. Optional 90 | containerSecurityContext: {} 91 | 92 | # SecurityContext at pod level to use for the deployment. Optional 93 | podSecurityContext: {} 94 | 95 | -------------------------------------------------------------------------------- /auth_server/gen_version.go: -------------------------------------------------------------------------------- 1 | //+build ignore 2 | 3 | /* 4 | Copyright 2021 Cesanta Software Ltd. 5 | 6 | Licensed under the Apache License, Version 2.0 (the "License"); 7 | you may not use this file except in compliance with the License. 8 | You may obtain a copy of the License at 9 | 10 | https://www.apache.org/licenses/LICENSE-2.0 11 | 12 | Unless required by applicable law or agreed to in writing, software 13 | distributed under the License is distributed on an "AS IS" BASIS, 14 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | See the License for the specific language governing permissions and 16 | limitations under the License. 17 | */ 18 | 19 | package main 20 | 21 | import ( 22 | "fmt" 23 | "log" 24 | "os" 25 | "strings" 26 | "time" 27 | 28 | "github.com/cooldrip/cstrftime" // strftime implemented with cgo 29 | "github.com/go-git/go-git/v5" 30 | "github.com/go-git/go-git/v5/plumbing" 31 | ) 32 | 33 | func main() { 34 | dir, err := os.Getwd() 35 | if err != nil { 36 | log.Fatal(err) 37 | } 38 | r, err := git.PlainOpenWithOptions(dir, &git.PlainOpenOptions{DetectDotGit: true}) 39 | if err != nil { 40 | log.Fatal(err) 41 | } 42 | 43 | t := time.Now() 44 | ts := cstrftime.Format("%Y%m%d-%H%M%S", t) 45 | 46 | head, err := r.Head() 47 | if err != nil { 48 | log.Fatal(err) 49 | } 50 | 51 | short := fmt.Sprintf("%s", head.Hash())[:8] 52 | 53 | w, err := r.Worktree() 54 | if err != nil { 55 | log.Fatal(err) 56 | } 57 | status, err := w.Status() 58 | if err != nil { 59 | log.Fatal(err) 60 | } 61 | 62 | is_dirty := "" 63 | if len(status) > 0 { 64 | is_dirty = "+" 65 | } 66 | 67 | branch_or_tag := head.Name().Short() 68 | if branch_or_tag == "HEAD" { 69 | branch_or_tag = "?" 70 | } 71 | 72 | tags, _ := r.Tags() 73 | tags.ForEach(func(ref *plumbing.Reference) error { 74 | if ref.Type() != plumbing.HashReference { 75 | return nil 76 | } 77 | 78 | if strings.HasPrefix(ref.String(), short) { 79 | tag := ref.String() 80 | branch_or_tag = trimRef(strings.Split(tag, " ")[1]) 81 | } 82 | return nil 83 | }) 84 | 85 | buildId := fmt.Sprintf("%s/%s@%s%s", ts, branch_or_tag, short, is_dirty) 86 | 87 | version := cstrftime.Format("%Y%m%d%H", t) 88 | if is_dirty != "" || branch_or_tag == "?" { 89 | version = branch_or_tag 90 | } 91 | 92 | fmt.Printf("%s\t%s\n", version, buildId) 93 | } 94 | 95 | func trimRef(ref string) string { 96 | ref = strings.TrimPrefix(ref, "refs/heads/") 97 | ref = strings.TrimPrefix(ref, "refs/tags/") 98 | return ref 99 | } 100 | -------------------------------------------------------------------------------- /auth_server/authn/xorm_authn.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Cesanta Software Ltd. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | https://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package authn 18 | 19 | import ( 20 | "fmt" 21 | 22 | "github.com/cesanta/docker_auth/auth_server/api" 23 | "golang.org/x/crypto/bcrypt" 24 | 25 | _ "github.com/go-sql-driver/mysql" 26 | _ "github.com/lib/pq" 27 | "xorm.io/xorm" 28 | ) 29 | 30 | var ( 31 | EnableSQLite3 = false 32 | ) 33 | 34 | type XormAuthnConfig struct { 35 | DatabaseType string `yaml:"database_type,omitempty"` 36 | ConnString string `yaml:"conn_string,omitempty"` 37 | } 38 | 39 | type XormAuthn struct { 40 | config *XormAuthnConfig 41 | engine *xorm.Engine 42 | } 43 | 44 | type XormUser struct { 45 | Id int64 `xorm:"pk autoincr"` 46 | Username string `xorm:"VARCHAR(128) NOT NULL"` 47 | PasswordHash string `xorm:"VARCHAR(128) NOT NULL"` 48 | Labels api.Labels `xorm:"JSON"` 49 | } 50 | 51 | func NewXormAuth(c *XormAuthnConfig) (*XormAuthn, error) { 52 | e, err := xorm.NewEngine(c.DatabaseType, c.ConnString) 53 | if err != nil { 54 | return nil, err 55 | } 56 | 57 | if err := e.Sync2(new(XormUser)); err != nil { 58 | return nil, fmt.Errorf("Sync2: %v", err) 59 | } 60 | return &XormAuthn{ 61 | config: c, 62 | engine: e, 63 | }, nil 64 | } 65 | 66 | func (xa *XormAuthn) Authenticate(user string, password api.PasswordString) (bool, api.Labels, error) { 67 | if user == "" || password == "" { 68 | return false, nil, api.NoMatch 69 | } 70 | var xuser XormUser 71 | has, err := xa.engine.Where("username = ?", user).Desc("id").Get(&xuser) 72 | if err != nil { 73 | return false, nil, err 74 | } 75 | if !has { 76 | return false, nil, api.NoMatch 77 | } 78 | if bcrypt.CompareHashAndPassword([]byte(xuser.PasswordHash), []byte(password)) != nil { 79 | return false, nil, nil 80 | } 81 | return true, xuser.Labels, nil 82 | } 83 | 84 | func (xa *XormAuthn) Name() string { 85 | return "XORM.io Authn" 86 | } 87 | 88 | func (xa *XormAuthn) Stop() { 89 | if xa.engine != nil { 90 | xa.engine.Close() 91 | } 92 | } 93 | func (xa *XormAuthnConfig) Validate(configKey string) error { 94 | // TODO: Validate auth 95 | return nil 96 | } 97 | -------------------------------------------------------------------------------- /auth_server/authz/ext_authz.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2016 Cesanta Software Ltd. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | https://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package authz 18 | 19 | import ( 20 | "encoding/json" 21 | "fmt" 22 | "os/exec" 23 | "strings" 24 | "syscall" 25 | 26 | "github.com/cesanta/glog" 27 | 28 | "github.com/cesanta/docker_auth/auth_server/api" 29 | ) 30 | 31 | type ExtAuthzConfig struct { 32 | Command string `yaml:"command"` 33 | Args []string `yaml:"args"` 34 | } 35 | 36 | type ExtAuthzStatus int 37 | 38 | const ( 39 | ExtAuthzAllowed ExtAuthzStatus = 0 40 | ExtAuthzDenied ExtAuthzStatus = 1 41 | ExtAuthzError ExtAuthzStatus = 2 42 | ) 43 | 44 | func (c *ExtAuthzConfig) Validate() error { 45 | if c.Command == "" { 46 | return fmt.Errorf("command is not set") 47 | } 48 | if _, err := exec.LookPath(c.Command); err != nil { 49 | return fmt.Errorf("invalid command %q: %s", c.Command, err) 50 | } 51 | return nil 52 | } 53 | 54 | type ExtAuthz struct { 55 | cfg *ExtAuthzConfig 56 | } 57 | 58 | func NewExtAuthzAuthorizer(cfg *ExtAuthzConfig) *ExtAuthz { 59 | glog.Infof("External authorization: %s %s", cfg.Command, strings.Join(cfg.Args, " ")) 60 | return &ExtAuthz{cfg: cfg} 61 | } 62 | 63 | func (ea *ExtAuthz) Authorize(ai *api.AuthRequestInfo) ([]string, error) { 64 | aiMarshal, err := json.Marshal(ai) 65 | if err != nil { 66 | return nil, fmt.Errorf("Unable to json.Marshal AuthRequestInfo: %s", err) 67 | } 68 | 69 | cmd := exec.Command(ea.cfg.Command, ea.cfg.Args...) 70 | cmd.Stdin = strings.NewReader(fmt.Sprintf("%s", aiMarshal)) 71 | output, err := cmd.Output() 72 | 73 | es := 0 74 | et := "" 75 | if err == nil { 76 | } else if ee, ok := err.(*exec.ExitError); ok { 77 | es = ee.Sys().(syscall.WaitStatus).ExitStatus() 78 | et = string(ee.Stderr) 79 | } else { 80 | es = int(ExtAuthzError) 81 | et = fmt.Sprintf("cmd run error: %s", err) 82 | } 83 | glog.V(2).Infof("%s %s -> %d %s", cmd.Path, cmd.Args, es, output) 84 | 85 | switch ExtAuthzStatus(es) { 86 | case ExtAuthzAllowed: 87 | return ai.Actions, nil 88 | case ExtAuthzDenied: 89 | return []string{}, nil 90 | default: 91 | glog.Errorf("Ext command error: %d %s", es, et) 92 | } 93 | return nil, fmt.Errorf("bad return code from command: %d", es) 94 | } 95 | 96 | func (sua *ExtAuthz) Stop() { 97 | } 98 | 99 | func (sua *ExtAuthz) Name() string { 100 | return "external authz" 101 | } 102 | -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: docker-nightly 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | tags: 8 | - '*.*.*' 9 | pull_request: 10 | 11 | jobs: 12 | 13 | docker: 14 | name: Docker 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - name: Install Go 19 | uses: actions/setup-go@v5 20 | with: 21 | go-version: "1.24.x" 22 | 23 | - name: Checkout code 24 | uses: actions/checkout@v4 25 | 26 | - name: Get Build Data 27 | id: info 28 | run: | 29 | echo ::set-output name=created::$(date -u +'%Y-%m-%dT%H:%M:%SZ') 30 | export TEMP=$(cd auth_server && go run gen_version.go) 31 | echo ::set-output name=version::$(echo -n $TEMP | awk '{print $1}') 32 | echo ::set-output name=build_id::$(echo -n $TEMP | awk '{print $2}') 33 | 34 | - name: Docker meta 35 | id: docker_meta 36 | uses: crazy-max/ghaction-docker-meta@v5 37 | with: 38 | images: cesanta/docker_auth 39 | tag-edge: true 40 | tag-semver: | 41 | {{version}} 42 | {{major}} 43 | {{major}}.{{minor}} 44 | 45 | - name: Set up QEMU 46 | uses: docker/setup-qemu-action@v3 47 | with: 48 | platforms: all 49 | 50 | - name: Set up Docker Buildx 51 | id: buildx 52 | uses: docker/setup-buildx-action@v3 53 | with: 54 | install: true 55 | version: latest 56 | # TODO: Remove driver-opts once fix is released docker/buildx#386 57 | driver-opts: image=moby/buildkit:master 58 | 59 | - name: Login to DockerHub 60 | uses: docker/login-action@v3 61 | with: 62 | username: ${{ secrets.DOCKER_USERNAME }} 63 | password: ${{ secrets.DOCKER_PASSWORD }} 64 | if: github.event_name == 'push' 65 | 66 | - name: Build and Push 67 | uses: docker/build-push-action@v6 68 | with: 69 | context: auth_server 70 | file: auth_server/Dockerfile 71 | platforms: linux/amd64,linux/arm64,linux/arm/v7 72 | push: ${{ github.event_name == 'push' }} 73 | tags: ${{ steps.docker_meta.outputs.tags }} 74 | build-args: | 75 | VERSION=${{ steps.info.outputs.version }} 76 | BUILD_ID=${{ steps.info.outputs.build_id }} 77 | labels: | 78 | org.opencontainers.image.title=${{ github.event.repository.name }} 79 | org.opencontainers.image.description=${{ github.event.repository.description }} 80 | org.opencontainers.image.url=${{ github.event.repository.html_url }} 81 | org.opencontainers.image.source=${{ github.event.repository.clone_url }} 82 | org.opencontainers.image.version=${{ steps.imagetag.outputs.value }} 83 | org.opencontainers.image.created=${{ steps.info.outputs.created }} 84 | org.opencontainers.image.revision=${{ github.sha }} 85 | org.opencontainers.image.licenses=${{ github.event.repository.license.spdx_id }} 86 | -------------------------------------------------------------------------------- /auth_server/authn/data/google_auth.tmpl: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 34 | 35 | 36 | 37 | 61 | 62 | 88 |
89 | 90 | 91 | -------------------------------------------------------------------------------- /auth_server/authn/ext_auth.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2016 Cesanta Software Ltd. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | https://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package authn 18 | 19 | import ( 20 | "encoding/json" 21 | "fmt" 22 | "os/exec" 23 | "strings" 24 | "syscall" 25 | 26 | "github.com/cesanta/glog" 27 | 28 | "github.com/cesanta/docker_auth/auth_server/api" 29 | ) 30 | 31 | type ExtAuthConfig struct { 32 | Command string `yaml:"command"` 33 | Args []string `yaml:"args"` 34 | } 35 | 36 | type ExtAuthStatus int 37 | 38 | const ( 39 | ExtAuthAllowed ExtAuthStatus = 0 40 | ExtAuthDenied ExtAuthStatus = 1 41 | ExtAuthNoMatch ExtAuthStatus = 2 42 | ExtAuthError ExtAuthStatus = 3 43 | ) 44 | 45 | type ExtAuthResponse struct { 46 | Labels api.Labels `json:"labels,omitempty"` 47 | } 48 | 49 | func (c *ExtAuthConfig) Validate() error { 50 | if c.Command == "" { 51 | return fmt.Errorf("command is not set") 52 | } 53 | if _, err := exec.LookPath(c.Command); err != nil { 54 | return fmt.Errorf("invalid command %q: %s", c.Command, err) 55 | } 56 | return nil 57 | } 58 | 59 | type extAuth struct { 60 | cfg *ExtAuthConfig 61 | } 62 | 63 | func NewExtAuth(cfg *ExtAuthConfig) *extAuth { 64 | glog.Infof("External authenticator: %s %s", cfg.Command, strings.Join(cfg.Args, " ")) 65 | return &extAuth{cfg: cfg} 66 | } 67 | 68 | func (ea *extAuth) Authenticate(user string, password api.PasswordString) (bool, api.Labels, error) { 69 | cmd := exec.Command(ea.cfg.Command, ea.cfg.Args...) 70 | cmd.Stdin = strings.NewReader(fmt.Sprintf("%s %s", user, string(password))) 71 | output, err := cmd.Output() 72 | es := 0 73 | et := "" 74 | if err == nil { 75 | } else if ee, ok := err.(*exec.ExitError); ok { 76 | es = ee.Sys().(syscall.WaitStatus).ExitStatus() 77 | et = string(ee.Stderr) 78 | } else { 79 | es = int(ExtAuthError) 80 | et = fmt.Sprintf("cmd run error: %s", err) 81 | } 82 | glog.V(2).Infof("%s %s -> %d %s", cmd.Path, cmd.Args, es, output) 83 | switch ExtAuthStatus(es) { 84 | case ExtAuthAllowed: 85 | var resp ExtAuthResponse 86 | if len(output) > 0 { 87 | if err = json.Unmarshal(output, &resp); err != nil { 88 | return false, nil, err 89 | } 90 | } 91 | return true, resp.Labels, nil 92 | case ExtAuthDenied: 93 | return false, nil, nil 94 | case ExtAuthNoMatch: 95 | return false, nil, api.NoMatch 96 | default: 97 | glog.Errorf("Ext command error: %d %s", es, et) 98 | } 99 | return false, nil, fmt.Errorf("bad return code from command: %d", es) 100 | } 101 | 102 | func (sua *extAuth) Stop() { 103 | } 104 | 105 | func (sua *extAuth) Name() string { 106 | return "external" 107 | } 108 | -------------------------------------------------------------------------------- /auth_server/authn/data/github_auth.tmpl: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Docker Registry Authentication 7 | 60 | 61 | 62 | 63 |
64 |

65 | 66 | 67 | Login{{if .Organization}} to @{{.Organization}}{{end}} with GitHub 68 | 69 |

70 |

71 | Revoke access 72 |

73 |
74 | 75 | 76 | -------------------------------------------------------------------------------- /chart/docker-auth/templates/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: {{ include "docker-auth.fullname" . }} 5 | labels: 6 | app.kubernetes.io/name: {{ include "docker-auth.name" . }} 7 | helm.sh/chart: {{ include "docker-auth.chart" . }} 8 | app.kubernetes.io/instance: {{ .Release.Name }} 9 | app.kubernetes.io/managed-by: {{ .Release.Service }} 10 | spec: 11 | replicas: {{ .Values.replicaCount }} 12 | selector: 13 | matchLabels: 14 | app.kubernetes.io/name: {{ include "docker-auth.name" . }} 15 | app.kubernetes.io/instance: {{ .Release.Name }} 16 | template: 17 | metadata: 18 | labels: 19 | app.kubernetes.io/name: {{ include "docker-auth.name" . }} 20 | app.kubernetes.io/instance: {{ .Release.Name }} 21 | annotations: 22 | checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }} 23 | {{- if .Values.podAnnotations }} 24 | {{- range $key, $value := .Values.podAnnotations }} 25 | {{ $key }}: {{ $value | quote }} 26 | {{- end }} 27 | {{- end }} 28 | spec: 29 | {{- if .Values.podSecurityContext }} 30 | {{- with .Values.podSecurityContext }} 31 | securityContext: 32 | {{- toYaml . | nindent 8 }} 33 | {{- end }} 34 | {{- end }} 35 | containers: 36 | - name: {{ .Chart.Name }} 37 | image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" 38 | {{- if .Values.containerSecurityContext }} 39 | {{- with .Values.containerSecurityContext }} 40 | securityContext: 41 | {{- toYaml . | nindent 12 }} 42 | {{- end }} 43 | {{- end }} 44 | args: ["--v={{ .Values.logging.level }}", "-logtostderr", "/config/auth_config.yml"] 45 | volumeMounts: 46 | - name: {{ include "docker-auth.name" . }}-config 47 | mountPath: /config 48 | - name: {{ include "docker-auth.name" . }}-secret 49 | mountPath: /config/certs 50 | imagePullPolicy: {{ .Values.image.pullPolicy }} 51 | ports: 52 | - name: {{ include "docker-auth.name" . }} 53 | containerPort: {{ .Values.service.targetPort }} 54 | protocol: TCP 55 | livenessProbe: 56 | httpGet: 57 | path: / 58 | port: {{ .Values.service.targetPort }} 59 | readinessProbe: 60 | httpGet: 61 | path: / 62 | port: {{ .Values.service.targetPort }} 63 | resources: 64 | {{- toYaml .Values.resources | nindent 12 }} 65 | volumes: 66 | - name: {{ include "docker-auth.name" . }}-config 67 | configMap: 68 | name: {{ include "docker-auth.name" . }} 69 | - name: {{ include "docker-auth.name" . }}-secret 70 | secret: 71 | {{- if .Values.secret.secretName }} 72 | secretName: {{ .Values.secret.secretName }} 73 | {{- else }} 74 | secretName: {{ include "docker-auth.name" . }} 75 | {{- end }} 76 | {{- with .Values.nodeSelector }} 77 | nodeSelector: 78 | {{- toYaml . | nindent 8 }} 79 | {{- end }} 80 | {{- with .Values.affinity }} 81 | affinity: 82 | {{- toYaml . | nindent 8 }} 83 | {{- end }} 84 | {{- with .Values.tolerations }} 85 | tolerations: 86 | {{- toYaml . | nindent 8 }} 87 | {{- end }} 88 | -------------------------------------------------------------------------------- /auth_server/authz/casbin_authz.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 The casbin Authors. All Rights Reserved. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package authz 16 | 17 | import ( 18 | "encoding/json" 19 | "fmt" 20 | 21 | "github.com/casbin/casbin/v2" 22 | "github.com/cesanta/docker_auth/auth_server/api" 23 | ) 24 | 25 | type CasbinAuthzConfig struct { 26 | ModelFilePath string `yaml:"model_path"` 27 | PolicyFilePath string `yaml:"policy_path"` 28 | } 29 | 30 | // labelMatch determines whether lbl1 matches lbl2. 31 | func labelMatch(lbl1 api.Labels, lbl2 api.Labels) bool { 32 | for label := range lbl2 { 33 | lbl1Values := lbl1[label] 34 | lbl2Values := lbl2[label] 35 | 36 | for _, val2 := range lbl2Values { 37 | matched := false 38 | for _, val1 := range lbl1Values { 39 | if val1 == val2 { 40 | matched = true 41 | break 42 | } 43 | } 44 | 45 | if !matched { 46 | return false 47 | } 48 | } 49 | } 50 | return true 51 | } 52 | 53 | // labelMatchFunc is the wrapper for labelMatch. 54 | func labelMatchFunc(args ...interface{}) (interface{}, error) { 55 | fmt.Println(args[0].(string)) 56 | lbl1 := stringToLabels(args[0].(string)) 57 | fmt.Println(labelsToString(lbl1)) 58 | lbl2 := stringToLabels(args[1].(string)) 59 | fmt.Println(lbl2) 60 | 61 | return (bool)(labelMatch(lbl1, lbl2)), nil 62 | } 63 | 64 | func labelsToString(labels api.Labels) string { 65 | labelsStr, err := json.Marshal(labels) 66 | if err != nil { 67 | return "" 68 | } 69 | 70 | return string(labelsStr) 71 | } 72 | 73 | func stringToLabels(str string) api.Labels { 74 | labels := api.Labels{} 75 | err := json.Unmarshal([]byte(str), &labels) 76 | if err != nil { 77 | return nil 78 | } 79 | 80 | return labels 81 | } 82 | 83 | type casbinAuthorizer struct { 84 | enforcer *casbin.Enforcer 85 | acl ACL 86 | } 87 | 88 | // NewCasbinAuthorizer creates a new casbin authorizer. 89 | func NewCasbinAuthorizer(enforcer *casbin.Enforcer) (api.Authorizer, error) { 90 | enforcer.AddFunction("labelMatch", labelMatchFunc) 91 | return &casbinAuthorizer{enforcer: enforcer}, nil 92 | } 93 | 94 | // Authorize determines whether to allow the actions. 95 | func (a *casbinAuthorizer) Authorize(ai *api.AuthRequestInfo) ([]string, error) { 96 | actions := []string{} 97 | 98 | for _, action := range ai.Actions { 99 | if ok, _ := a.enforcer.Enforce(ai.Account, ai.Type, ai.Name, ai.Service, ai.IP.String(), action, labelsToString(ai.Labels)); ok { 100 | actions = append(actions, action) 101 | } 102 | } 103 | return actions, nil 104 | 105 | // return nil, NoMatch 106 | } 107 | 108 | // Stop stops the middleware. 109 | func (a *casbinAuthorizer) Stop() { 110 | // Nothing to do. 111 | } 112 | 113 | // Name returns the name of the middleware. 114 | func (a *casbinAuthorizer) Name() string { 115 | return "Casbin Authorizer" 116 | } 117 | -------------------------------------------------------------------------------- /auth_server/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/cesanta/docker_auth/auth_server 2 | 3 | go 1.23.0 4 | 5 | require ( 6 | cloud.google.com/go/storage v1.29.0 7 | github.com/casbin/casbin/v2 v2.55.1 8 | github.com/cesanta/glog v0.0.0-20150527111657-22eb27a0ae19 9 | github.com/coreos/go-oidc/v3 v3.9.0 10 | github.com/dchest/uniuri v0.0.0-20220929095258-3027df40b6ce 11 | github.com/deckarep/golang-set v1.8.0 12 | github.com/docker/distribution v2.8.2-beta.1+incompatible 13 | github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7 14 | github.com/go-ldap/ldap v3.0.3+incompatible 15 | github.com/go-redis/redis v6.15.9+incompatible 16 | github.com/go-sql-driver/mysql v1.6.0 17 | github.com/lib/pq v1.10.7 18 | github.com/mattn/go-sqlite3 v2.0.3+incompatible 19 | github.com/syndtr/goleveldb v1.0.0 20 | go.mongodb.org/mongo-driver v1.10.2 21 | golang.org/x/crypto v0.36.0 22 | golang.org/x/net v0.38.0 23 | golang.org/x/oauth2 v0.13.0 24 | google.golang.org/api v0.126.0 25 | gopkg.in/fsnotify.v1 v1.4.7 26 | gopkg.in/mgo.v2 v2.0.0-20190816093944-a6b53ec6cb22 27 | gopkg.in/yaml.v2 v2.4.0 28 | xorm.io/xorm v1.3.2 29 | ) 30 | 31 | require ( 32 | cloud.google.com/go v0.110.2 // indirect 33 | cloud.google.com/go/compute v1.20.1 // indirect 34 | cloud.google.com/go/compute/metadata v0.2.3 // indirect 35 | cloud.google.com/go/iam v0.13.0 // indirect 36 | github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible // indirect 37 | github.com/go-jose/go-jose/v3 v3.0.4 // indirect 38 | github.com/goccy/go-json v0.9.11 // indirect 39 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect 40 | github.com/golang/mock v1.6.0 // indirect 41 | github.com/golang/protobuf v1.5.4 // indirect 42 | github.com/golang/snappy v0.0.4 // indirect 43 | github.com/google/go-cmp v0.6.0 // indirect 44 | github.com/google/s2a-go v0.1.4 // indirect 45 | github.com/google/uuid v1.3.0 // indirect 46 | github.com/googleapis/enterprise-certificate-proxy v0.2.3 // indirect 47 | github.com/googleapis/gax-go/v2 v2.11.0 // indirect 48 | github.com/gorilla/mux v1.8.0 // indirect 49 | github.com/json-iterator/go v1.1.12 // indirect 50 | github.com/klauspost/compress v1.15.11 // indirect 51 | github.com/kr/pretty v0.3.0 // indirect 52 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 53 | github.com/modern-go/reflect2 v1.0.2 // indirect 54 | github.com/montanaflynn/stats v0.6.6 // indirect 55 | github.com/pkg/errors v0.9.1 // indirect 56 | github.com/rogpeppe/go-internal v1.9.0 // indirect 57 | github.com/sirupsen/logrus v1.9.0 // indirect 58 | github.com/xdg-go/pbkdf2 v1.0.0 // indirect 59 | github.com/xdg-go/scram v1.1.1 // indirect 60 | github.com/xdg-go/stringprep v1.0.3 // indirect 61 | github.com/youmark/pkcs8 v0.0.0-20201027041543-1326539a0a0a // indirect 62 | go.opencensus.io v0.24.0 // indirect 63 | golang.org/x/sync v0.12.0 // indirect 64 | golang.org/x/sys v0.31.0 // indirect 65 | golang.org/x/text v0.23.0 // indirect 66 | golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect 67 | google.golang.org/appengine v1.6.8 // indirect 68 | google.golang.org/genproto v0.0.0-20230530153820-e85fd2cbaebc // indirect 69 | google.golang.org/genproto/googleapis/api v0.0.0-20230530153820-e85fd2cbaebc // indirect 70 | google.golang.org/genproto/googleapis/rpc v0.0.0-20230530153820-e85fd2cbaebc // indirect 71 | google.golang.org/grpc v1.56.3 // indirect 72 | google.golang.org/protobuf v1.33.0 // indirect 73 | gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d // indirect 74 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect 75 | lukechampine.com/uint128 v1.2.0 // indirect 76 | modernc.org/cc/v3 v3.36.3 // indirect 77 | modernc.org/ccgo/v3 v3.16.9 // indirect 78 | modernc.org/libc v1.17.1 // indirect 79 | modernc.org/opt v0.1.3 // indirect 80 | modernc.org/sqlite v1.18.1 // indirect 81 | modernc.org/strutil v1.1.3 // indirect 82 | xorm.io/builder v0.3.12 // indirect 83 | ) 84 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Docker Registry 2 authentication server 2 | ========================================= 3 | 4 | The original Docker Registry server (v1) did not provide any support for authentication or authorization. 5 | Access control had to be performed externally, typically by deploying Nginx in the reverse proxy mode with Basic or other type of authentication. 6 | While performing simple user authentication is pretty straightforward, performing more fine-grained access control was cumbersome. 7 | 8 | Docker Registry 2.0 introduced a new, token-based authentication and authorization protocol, but the server to generate them was not released. 9 | Thus, most guides found on the internet still describe a set up with a reverse proxy performing access control. 10 | 11 | This server fills the gap and implements the protocol described [here](https://github.com/docker/distribution/blob/main/docs/spec/auth/token.md). 12 | 13 | Supported authentication methods: 14 | * Static list of users 15 | * Google Sign-In (incl. Google for Work / GApps for domain) (documented [here](https://github.com/cesanta/docker_auth/blob/main/examples/reference.yml)) 16 | * [Github Sign-In](docs/auth-methods.md#github) 17 | * Gitlab Sign-In 18 | * LDAP bind ([demo](https://github.com/kwk/docker-registry-setup)) 19 | * MongoDB user collection 20 | * MySQL/MariaDB, PostgreSQL, SQLite database table 21 | * [External program](https://github.com/cesanta/docker_auth/blob/main/examples/ext_auth.sh) 22 | 23 | Supported authorization methods: 24 | * Static ACL 25 | * MongoDB-backed ACL 26 | * MySQL/MariaDB, PostgreSQL, SQLite backed ACL 27 | * External program 28 | 29 | ## Installation and Examples 30 | 31 | ### Using Helm/Kubernetes 32 | 33 | A helm chart is available in the folder [chart/docker-auth](chart/docker-auth). 34 | 35 | ### Docker 36 | 37 | A public Docker image is available on Docker Hub: [cesanta/docker_auth](https://hub.docker.com/r/cesanta/docker_auth/). 38 | 39 | Tags available: 40 | - `:edge` - bleeding edge, usually works but breaking config changes are possible. You probably do not want to use this in production. 41 | - `:latest` - latest tagged release, will line up with `:1` tag 42 | - `:1` - the `1.x` version, will have fixes, no breaking config changes. Previously known as `:stable`. 43 | - `:1.x` - specific release, see [here](https://github.com/cesanta/docker_auth/releases) for the list of current releases. 44 | 45 | The binary takes a single argument - path to the config file. 46 | If no arguments are given, the Dockerfile defaults to `/config/auth_config.yml`. 47 | 48 | Example command line: 49 | 50 | ```{r, engine='bash', count_lines} 51 | $ docker run \ 52 | --rm -it --name docker_auth -p 5001:5001 \ 53 | -v /path/to/config_dir:/config:ro \ 54 | -v /var/log/docker_auth:/logs \ 55 | cesanta/docker_auth:1 /config/auth_config.yml 56 | ``` 57 | 58 | See the [example config files](https://github.com/cesanta/docker_auth/tree/main/examples/) to get an idea of what is possible. 59 | 60 | ## Troubleshooting 61 | 62 | Run with increased verbosity: 63 | ```{r, engine='bash', count_lines} 64 | docker run ... cesanta/docker_auth:1 --v=2 --alsologtostderr /config/auth_config.yml 65 | ``` 66 | 67 | ## Contributing 68 | 69 | Bug reports, feature requests and pull requests (for small fixes) are welcome. 70 | If you require larger changes, please file an issue. 71 | We cannot guarantee response but will do our best to address them. 72 | 73 | ## Licensing 74 | 75 | Copyright 2015 [Cesanta Software Ltd](http://www.cesanta.com). 76 | 77 | Licensed under the Apache License, Version 2.0 (the "License"); 78 | you may not use this software except in compliance with the License. 79 | You may obtain a copy of the License at 80 | 81 | https://www.apache.org/licenses/LICENSE-2.0 82 | 83 | Unless required by applicable law or agreed to in writing, software 84 | distributed under the License is distributed on an "AS IS" BASIS, 85 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 86 | See the License for the specific language governing permissions and 87 | limitations under the License. 88 | -------------------------------------------------------------------------------- /docs/Labels.md: -------------------------------------------------------------------------------- 1 | # Labels 2 | 3 | Labels can be used to reduce the number ACLS needed in large, complex installations. 4 | 5 | Labels are only supported for certain auth backends. As of right now labels are only supported when using Static Authentication or Mongo Authentication. 6 | 7 | ## Label Placeholders 8 | 9 | Label placeholders are available for any label that is assigned to a user. 10 | 11 | For example, given a user: 12 | 13 | ```json 14 | { 15 | "username" : "busy-guy", 16 | "password" : "$2y$05$B.x046DV3bvuwFgn0I42F.W/SbRU5fUoCbCGtjFl7S33aCUHNBxbq", 17 | "labels" : { 18 | "group" : [ 19 | "web", 20 | "webdev" 21 | ], 22 | "project" : [ 23 | "website", 24 | "api" 25 | ], 26 | "tier" : [ 27 | "frontend", 28 | "backend" 29 | ] 30 | } 31 | } 32 | ``` 33 | 34 | The following placeholders could be used in any match field: 35 | 36 | * `${labels:group}` 37 | * `${labels:project}` 38 | * `${labels:tier}` 39 | 40 | Example acl with label matching: 41 | 42 | ```json 43 | { 44 | "match": { "name": "${labels:project}/*" }, 45 | "actions": [ "push", "pull" ], 46 | "comment": "Users can push to any project they are assigned to" 47 | } 48 | ``` 49 | 50 | Single label matching is efficient and will be tested in the order 51 | they are listed in the user record. 52 | 53 | 54 | ## Using Multiple Labels when matching 55 | 56 | It's possible to use multiple labels in a single match. When multiple labels are 57 | used in a single match all possible combinations of the labels are tested 58 | in [no particular order](https://blog.golang.org/go-maps-in-action#TOC_7.). 59 | 60 | Example acl with multiple label matching: 61 | 62 | ```json 63 | { 64 | "match": { "name": "${labels:project}/${labels:group}-${labels:tier}" }, 65 | "actions": [ "push", "pull" ], 66 | "comment": "Contrived multiple label match rule" 67 | } 68 | ``` 69 | 70 | When paired with the user given above would result in 8 possible combinations 71 | that would need to be tested. 72 | 73 | * `${labels:project} : website`, `${labels:group} : dev`, `${labels:tier} : frontend` 74 | * `${labels:project} : website`, `${labels:group} : dev`, `${labels:tier} : backend` 75 | * `${labels:project} : website`, `${labels:group} : webdev`, `${labels:tier} : frontend` 76 | * `${labels:project} : website`, `${labels:group} : webdev`, `${labels:tier} : backend` 77 | * `${labels:project} : api`, `${labels:group} : dev`, `${labels:tier} : frontend` 78 | * `${labels:project} : api`, `${labels:group} : dev`, `${labels:tier} : backend` 79 | * `${labels:project} : api`, `${labels:group} : webdev`, `${labels:tier} : frontend` 80 | * `${labels:project} : api`, `${labels:group} : webdev`, `${labels:tier} : backend` 81 | 82 | This grows rapidly as more placeholders and labels are added. So it's best 83 | to limit multiple label matching when possible. 84 | 85 | ## Using Labels for User Based Access 86 | 87 | If you want to use minimal ACLs then you can create some very basic acls and rely on user-side labels for access control. 88 | 89 | Example acls: 90 | 91 | ```yaml 92 | - match: {name: "${labels:full-access}"} 93 | actions: ["*"] 94 | - match: {name: "${labels:read-only-access}"} 95 | actions: ["pull"] 96 | ``` 97 | 98 | Given the acl above you could use labels to grant access by simply updating a user's record 99 | 100 | Example User with full-access to `test/*` and read-only access to `prod/*` 101 | 102 | ```json 103 | { 104 | "username" : "test-user", 105 | "labels" : { 106 | "full-access" : [ 107 | "test/*" 108 | ], 109 | "read-only-access" : [ 110 | "prod/*" 111 | ] 112 | } 113 | } 114 | 115 | ``` 116 | 117 | If you wanted to grant more access to test-user in the future you would simply add to the `full-access` or `read-only-access` labels list. This works best when paired with a dynamic authentication method that returns labels. As of v1.3 that includes mongo and ext_auth 118 | -------------------------------------------------------------------------------- /auth_server/authz/acl_xorm.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Cesanta Software Ltd. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | https://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package authz 18 | 19 | import ( 20 | "fmt" 21 | "io" 22 | "sync" 23 | "time" 24 | 25 | "github.com/cesanta/docker_auth/auth_server/api" 26 | "github.com/cesanta/glog" 27 | 28 | _ "github.com/go-sql-driver/mysql" 29 | _ "github.com/lib/pq" 30 | "xorm.io/xorm" 31 | ) 32 | 33 | var ( 34 | EnableSQLite3 = false 35 | ) 36 | 37 | type XormAuthzConfig struct { 38 | DatabaseType string `yaml:"database_type,omitempty"` 39 | ConnString string `yaml:"conn_string,omitempty"` 40 | CacheTTL time.Duration `yaml:"cache_ttl,omitempty"` 41 | } 42 | 43 | type XormACL []XormACLEntry 44 | 45 | type XormACLEntry struct { 46 | ACLEntry `xorm:"'acl_entry' JSON"` 47 | Seq int64 48 | } 49 | 50 | func (x XormACLEntry) TableName() string { 51 | return "xorm_acl_entry" 52 | } 53 | 54 | type aclXormAuthz struct { 55 | lastCacheUpdate time.Time 56 | lock sync.RWMutex 57 | config *XormAuthzConfig 58 | staticAuthorizer api.Authorizer 59 | engine *xorm.Engine 60 | updateTicker *time.Ticker 61 | } 62 | 63 | func NewACLXormAuthz(c *XormAuthzConfig) (api.Authorizer, error) { 64 | e, err := xorm.NewEngine(c.DatabaseType, c.ConnString) 65 | if err != nil { 66 | return nil, err 67 | } 68 | 69 | if err := e.Sync2(new(XormACLEntry)); err != nil { 70 | return nil, fmt.Errorf("Sync2: %v", err) 71 | } 72 | authorizer := &aclXormAuthz{ 73 | config: c, 74 | engine: e, 75 | updateTicker: time.NewTicker(c.CacheTTL), 76 | } 77 | 78 | // Initially fetch the ACL from XORM 79 | if err := authorizer.updateACLCache(); err != nil { 80 | return nil, err 81 | } 82 | 83 | go authorizer.continuouslyUpdateACLCache() 84 | 85 | return authorizer, nil 86 | } 87 | 88 | func (xa *aclXormAuthz) Authorize(ai *api.AuthRequestInfo) ([]string, error) { 89 | xa.lock.RLock() 90 | defer xa.lock.RUnlock() 91 | 92 | // Test if authorizer has been initialized 93 | if xa.staticAuthorizer == nil { 94 | return nil, fmt.Errorf("XORM.io authorizer is not ready") 95 | } 96 | 97 | return xa.staticAuthorizer.Authorize(ai) 98 | } 99 | 100 | func (xa *aclXormAuthz) Stop() { 101 | if xa.engine != nil { 102 | xa.engine.Close() 103 | } 104 | } 105 | func (xa *XormAuthzConfig) Validate(configKey string) error { 106 | // TODO: Validate authz 107 | return nil 108 | } 109 | 110 | func (xa *aclXormAuthz) Name() string { 111 | return "XORM.io Authz" 112 | } 113 | 114 | func (xa *aclXormAuthz) continuouslyUpdateACLCache() { 115 | var tick time.Time 116 | for ; true; tick = <-xa.updateTicker.C { 117 | aclAge := time.Now().Sub(xa.lastCacheUpdate) 118 | glog.V(2).Infof("Updating ACL at %s (ACL age: %s. CacheTTL: %s)", tick, aclAge, xa.config.CacheTTL) 119 | 120 | for true { 121 | err := xa.updateACLCache() 122 | if err == nil { 123 | break 124 | } else if err == io.EOF { 125 | glog.Warningf("EOF error received from Xorm. Retrying connection") 126 | time.Sleep(time.Second) 127 | continue 128 | } else { 129 | glog.Errorf("Failed to update ACL. ERROR: %s", err) 130 | glog.Warningf("Using stale ACL (Age: %s, TTL: %s)", aclAge, xa.config.CacheTTL) 131 | break 132 | } 133 | } 134 | } 135 | } 136 | 137 | func (xa *aclXormAuthz) updateACLCache() error { 138 | // Get ACL from Xorm.io database connection 139 | var newACL []XormACLEntry 140 | 141 | err := xa.engine.OrderBy("seq").Find(&newACL) 142 | if err != nil { 143 | return err 144 | } 145 | var retACL ACL 146 | for _, e := range newACL { 147 | retACL = append(retACL, e.ACLEntry) 148 | } 149 | 150 | newStaticAuthorizer, err := NewACLAuthorizer(retACL) 151 | if err != nil { 152 | return err 153 | } 154 | 155 | xa.lock.Lock() 156 | xa.lastCacheUpdate = time.Now() 157 | xa.staticAuthorizer = newStaticAuthorizer 158 | xa.lock.Unlock() 159 | 160 | glog.V(2).Infof("Got new ACL from XORM: %s", retACL) 161 | glog.V(1).Infof("Installed new ACL from XORM (%d entries)", len(retACL)) 162 | return nil 163 | 164 | } 165 | -------------------------------------------------------------------------------- /auth_server/authn/tokendb_gcs.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2017 Cesanta Software Ltd. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | https://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package authn 18 | 19 | import ( 20 | "encoding/json" 21 | "fmt" 22 | "time" 23 | 24 | "cloud.google.com/go/storage" 25 | "github.com/cesanta/glog" 26 | "github.com/dchest/uniuri" 27 | "golang.org/x/crypto/bcrypt" 28 | "golang.org/x/net/context" 29 | "google.golang.org/api/option" 30 | 31 | "github.com/cesanta/docker_auth/auth_server/api" 32 | ) 33 | 34 | type GCSStoreConfig struct { 35 | Bucket string `yaml:"bucket,omitempty"` 36 | ClientSecretFile string `yaml:"client_secret_file,omitempty"` 37 | TokenHashCost int `yaml:"token_hash_cost,omitempty"` 38 | } 39 | 40 | // NewGCSTokenDB return a new TokenDB structure which uses Google Cloud Storage as backend. The 41 | // created DB uses file-per-user strategy and stores credentials independently for each user. 42 | // 43 | // Note: it's not recomanded bucket to be shared with other apps or services 44 | func NewGCSTokenDB(options *GCSStoreConfig) (TokenDB, error) { 45 | gcs, err := storage.NewClient(context.Background(), option.WithServiceAccountFile(options.ClientSecretFile)) 46 | tokenHashCost := options.TokenHashCost 47 | if tokenHashCost <= 0 { 48 | tokenHashCost = bcrypt.DefaultCost 49 | } 50 | return &gcsTokenDB{gcs, options.Bucket, tokenHashCost}, err 51 | } 52 | 53 | type gcsTokenDB struct { 54 | gcs *storage.Client 55 | bucket string 56 | tokenHashCost int 57 | } 58 | 59 | // GetValue gets token value associated with the provided user. Each user 60 | // in the bucket is having it's own file for tokens and it's recomanded bucket 61 | // to not be shared with other apps 62 | func (db *gcsTokenDB) GetValue(user string) (*TokenDBValue, error) { 63 | rd, err := db.gcs.Bucket(db.bucket).Object(user).NewReader(context.Background()) 64 | if err == storage.ErrObjectNotExist { 65 | return nil, nil 66 | } 67 | if err != nil { 68 | return nil, fmt.Errorf("could not retrieved token for user '%s' due: %v", user, err) 69 | } 70 | defer rd.Close() 71 | 72 | var dbv TokenDBValue 73 | if err := json.NewDecoder(rd).Decode(&dbv); err != nil { 74 | glog.Errorf("bad DB value for %q: %v", user, err) 75 | return nil, fmt.Errorf("could not read token for user '%s' due: %v", user, err) 76 | } 77 | 78 | return &dbv, nil 79 | } 80 | 81 | // StoreToken stores token in the GCS file in a JSON format. Note that separate file is 82 | // used for each user 83 | func (db *gcsTokenDB) StoreToken(user string, v *TokenDBValue, updatePassword bool) (dp string, err error) { 84 | if updatePassword { 85 | dp = uniuri.New() 86 | dph, _ := bcrypt.GenerateFromPassword([]byte(dp), db.tokenHashCost) 87 | v.DockerPassword = string(dph) 88 | } 89 | 90 | wr := db.gcs.Bucket(db.bucket).Object(user).NewWriter(context.Background()) 91 | 92 | if err := json.NewEncoder(wr).Encode(v); err != nil { 93 | glog.Errorf("failed to set token data for %s: %s", user, err) 94 | return "", fmt.Errorf("failed to set token data for %s due: %v", user, err) 95 | } 96 | 97 | err = wr.Close() 98 | return 99 | } 100 | 101 | // ValidateToken verifies whether the provided token passed as password field 102 | // is still valid, e.g available and not expired 103 | func (db *gcsTokenDB) ValidateToken(user string, password api.PasswordString) error { 104 | dbv, err := db.GetValue(user) 105 | if err != nil { 106 | return err 107 | } 108 | if dbv == nil { 109 | return api.NoMatch 110 | } 111 | 112 | if bcrypt.CompareHashAndPassword([]byte(dbv.DockerPassword), []byte(password)) != nil { 113 | return api.WrongPass 114 | } 115 | if time.Now().After(dbv.ValidUntil) { 116 | return ExpiredToken 117 | } 118 | 119 | return nil 120 | } 121 | 122 | // DeleteToken deletes the GCS file that is associated with the provided user. 123 | func (db *gcsTokenDB) DeleteToken(user string) error { 124 | ctx := context.Background() 125 | err := db.gcs.Bucket(db.bucket).Object(user).Delete(ctx) 126 | if err == storage.ErrObjectNotExist { 127 | return nil 128 | } 129 | return err 130 | } 131 | 132 | // Close is a nop operation for this db 133 | func (db *gcsTokenDB) Close() error { 134 | return nil 135 | } 136 | -------------------------------------------------------------------------------- /auth_server/authn/mongo_auth.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2015 Cesanta Software Ltd. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | https://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package authn 18 | 19 | import ( 20 | "context" 21 | "errors" 22 | "fmt" 23 | "io" 24 | "time" 25 | 26 | "github.com/cesanta/glog" 27 | "go.mongodb.org/mongo-driver/bson" 28 | "go.mongodb.org/mongo-driver/mongo" 29 | "go.mongodb.org/mongo-driver/mongo/options" 30 | "golang.org/x/crypto/bcrypt" 31 | 32 | "github.com/cesanta/docker_auth/auth_server/api" 33 | "github.com/cesanta/docker_auth/auth_server/mgo_session" 34 | ) 35 | 36 | type MongoAuthConfig struct { 37 | MongoConfig *mgo_session.Config `yaml:"dial_info,omitempty"` 38 | Collection string `yaml:"collection,omitempty"` 39 | } 40 | 41 | type MongoAuth struct { 42 | config *MongoAuthConfig 43 | session *mongo.Client 44 | Collection string `yaml:"collection,omitempty"` 45 | } 46 | 47 | type authUserEntry struct { 48 | Username *string `yaml:"username,omitempty" json:"username,omitempty"` 49 | Password *string `yaml:"password,omitempty" json:"password,omitempty"` 50 | Labels api.Labels `yaml:"labels,omitempty" json:"labels,omitempty"` 51 | } 52 | 53 | func NewMongoAuth(c *MongoAuthConfig) (*MongoAuth, error) { 54 | // Attempt to create new mongo session. 55 | session, err := mgo_session.New(c.MongoConfig) 56 | if err != nil { 57 | return nil, err 58 | } 59 | // determine collection 60 | collection := session.Database(c.MongoConfig.DialInfo.Database).Collection(c.Collection) 61 | 62 | // Create username index obj 63 | index := mongo.IndexModel{ 64 | Keys: bson.M{"username": 1}, 65 | Options: options.Index().SetUnique(true), 66 | } 67 | 68 | // Enforce a username index. 69 | // mongodb will do no operation if index still exists. 70 | // see: https://pkg.go.dev/go.mongodb.org/mongo-driver/mongo#Collection.Indexes 71 | _, erri := collection.Indexes().CreateOne(context.TODO(), index) 72 | if erri != nil { 73 | fmt.Println(erri.Error()) 74 | return nil, erri 75 | } 76 | 77 | return &MongoAuth{ 78 | config: c, 79 | session: session, 80 | }, nil 81 | } 82 | 83 | func (mauth *MongoAuth) Authenticate(account string, password api.PasswordString) (bool, api.Labels, error) { 84 | for true { 85 | result, labels, err := mauth.authenticate(account, password) 86 | if err == io.EOF { 87 | glog.Warningf("EOF error received from Mongo. Retrying connection") 88 | time.Sleep(time.Second) 89 | continue 90 | } 91 | return result, labels, err 92 | } 93 | 94 | return false, nil, errors.New("Unable to communicate with Mongo.") 95 | } 96 | 97 | func (mauth *MongoAuth) authenticate(account string, password api.PasswordString) (bool, api.Labels, error) { 98 | 99 | // Get Users from MongoDB 100 | glog.V(2).Infof("Checking user %s against Mongo Users. DB: %s, collection:%s", 101 | account, mauth.config.MongoConfig.DialInfo.Database, mauth.config.Collection) 102 | var dbUserRecord authUserEntry 103 | collection := mauth.session.Database(mauth.config.MongoConfig.DialInfo.Database).Collection(mauth.config.Collection) 104 | 105 | 106 | filter := bson.D{{"username", account}} 107 | err := collection.FindOne(context.TODO(), filter).Decode(&dbUserRecord) 108 | 109 | // If we connect and get no results we return a NoMatch so auth can fall-through 110 | if err == mongo.ErrNoDocuments { 111 | return false, nil, api.NoMatch 112 | } else if err != nil { 113 | return false, nil, err 114 | } 115 | 116 | // Validate db password against passed password 117 | if dbUserRecord.Password != nil { 118 | if bcrypt.CompareHashAndPassword([]byte(*dbUserRecord.Password), []byte(password)) != nil { 119 | return false, nil, nil 120 | } 121 | } 122 | 123 | // Auth success 124 | return true, dbUserRecord.Labels, nil 125 | } 126 | 127 | // Validate ensures that any custom config options 128 | // in a Config are set correctly. 129 | func (c *MongoAuthConfig) Validate(configKey string) error { 130 | //First validate the mongo config. 131 | if err := c.MongoConfig.Validate(configKey); err != nil { 132 | return err 133 | } 134 | 135 | // Now check additional config fields. 136 | if c.Collection == "" { 137 | return fmt.Errorf("%s.collection is required", configKey) 138 | } 139 | 140 | return nil 141 | } 142 | 143 | func (ma *MongoAuth) Stop() { 144 | 145 | } 146 | 147 | func (ga *MongoAuth) Name() string { 148 | return "MongoDB" 149 | } 150 | -------------------------------------------------------------------------------- /docs/Backend_MongoDB.md: -------------------------------------------------------------------------------- 1 | # MongoDB Backends 2 | 3 | You may want to manage your ACLs and Users from an external application and therefore 4 | need them to be stored outside of your auth_server's configuration file. 5 | 6 | For this purpose, there's a [MongoDB](https://www.mongodb.org/) backend 7 | which can query ACL and Auth from a MongoDB database. 8 | 9 | 10 | ## Auth backend in MongoDB 11 | 12 | Auth entries in mongo are single dictionary containing a username and password entry. 13 | The password entry must contain a BCrypt hash. The labels entry is optional. 14 | 15 | ```json 16 | { 17 | "username" : "admin", 18 | "password" : "$2y$05$B.x046DV3bvuwFgn0I42F.W/SbRU5fUoCbCGtjFl7S33aCUHNBxbq", 19 | "labels" : { 20 | "group" : [ 21 | "dev" 22 | ], 23 | "project": [ 24 | "website", 25 | "api" 26 | ] 27 | } 28 | } 29 | ``` 30 | 31 | ## ACL backend in MongoDB 32 | 33 | A typical ACL entry from the static YAML configuration file looks something like 34 | this: 35 | 36 | ```yaml 37 | - match: {account: "/.+/", name: "${account}/*"} 38 | actions: ["push", "pull"] 39 | comment: "All logged in users can push all images that are in a namespace beginning with their name" 40 | ``` 41 | 42 | Notice the use of a regular expression (`/.+/`), a placeholder (`${account}`), 43 | and in particular the `actions` array. 44 | 45 | The ACL entry as is it is stored inside the static YAML file can be mapped to 46 | MongoDB quite easily. Below you can find a list of ACL entries that are ready to 47 | be imported into MongoDB. Those ACL entries reflect what's specified in the 48 | `example/reference.yml` file under the `acl` section (aka static ACL). 49 | 50 | The added field of seq is used to provide a reliable order which MongoDB does not 51 | guarantee by default, i.e. [Natural Sorting](https://docs.mongodb.org/manual/reference/method/cursor.sort/#return-natural-order). 52 | 53 | ``seq`` is a required field in all MongoDB ACL documents. Any documents without this key will be excluded. seq uniqeness is also enforced. 54 | 55 | - match: {labels: {"group": "/trainee|dev/"}} 56 | actions: ["push", "pull"] 57 | comment: "Users assigned to group 'trainee' and 'dev' is able to push and pull" 58 | 59 | **reference_acl.json** 60 | ```json 61 | {"seq": 10, "match" : {"account" : "admin"}, "actions" : ["*"], "comment" : "Admin has full access to everything."} 62 | {"seq": 11, "match" : {"labels": {"group": "admin"}}, "actions" : ["*"], "comment" : "Admin group members have full access to everything"} 63 | {"seq": 20, "match" : {"account" : "test", "name" : "test-*"}, "actions" : ["*"], "comment" : "User \"test\" has full access to test-* images but nothing else. (1)"} 64 | {"seq": 30, "match" : {"account" : "test"}, "actions" : [], "comment" : "User \"test\" has full access to test-* images but nothing else. (2)"} 65 | {"seq": 40, "match" : {"account" : "/.+/", "name" : "${account}/*"}, "actions" : ["*"], "comment" : "All logged in users can push all images that are in a namespace beginning with their name"} 66 | {"seq": 50, "match" : {"name" : "${labels:group}-shared/*"}, "actions" : ["push", "pull"], "comment" : "Users can pull and push to the shared namespace of any group they are in"} 67 | {"seq": 60, "match" : {"name" : "${labels:project}/*"}, "actions" : ["push", "pull"], "comment" : "Users can pull and push to to namespaces matching projects they are assigned to"} 68 | {"seq": 70, "match" : {"account" : "/.+/"}, "actions" : ["pull"], "comment" : "All logged in users can pull all images."} 69 | {"seq": 80, "match" : {"account" : "", "name" : "hello-world"}, "actions" : ["pull"], "comment" : "Anonymous users can pull \"hello-world\"."} 70 | ``` 71 | 72 | **Note** that each document entry must span exactly one line or otherwise the 73 | `mongoimport` tool (see below) will not accept it. 74 | 75 | ### Import reference ACLs into MongoDB 76 | 77 | To import the above specified ACL entries from the reference file, simply 78 | execute the following commands. 79 | 80 | #### Ensure MongoDB is running 81 | 82 | If you don't have a MongoDB server running, consider to start it within it's own 83 | docker container: 84 | 85 | `docker run --name mongo-acl -d mongo` 86 | 87 | Then wait until the MongoDB server is ready to accept connections. You can find 88 | this out by running `docker logs -f mongo-acl`. Once you see the message 89 | `waiting for connections on port 27017`, you can proceed with the instructions 90 | below. 91 | 92 | #### Get mongoimport tool 93 | 94 | On Ubuntu this is a matter of `sudo apt-get install mongodb-clients`. 95 | 96 | #### Import ACLs 97 | 98 | ```bash 99 | MONGO_IP=$(docker inspect --format '{{ .NetworkSettings.IPAddress }}' mongo-acl) 100 | mongoimport --host $MONGO_IP --db docker_auth --collection acl < reference_acl.json 101 | ``` 102 | 103 | This should print a message like this if everything was successful: 104 | 105 | ``` 106 | connected to: 172.17.0.4 107 | Wed Nov 4 13:34:15.816 imported 6 objects 108 | ``` 109 | -------------------------------------------------------------------------------- /auth_server/authn/tokendb_redis.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2017 Cesanta Software Ltd. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | https://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package authn 18 | 19 | import ( 20 | "encoding/json" 21 | "fmt" 22 | "time" 23 | 24 | "golang.org/x/crypto/bcrypt" 25 | 26 | "github.com/cesanta/docker_auth/auth_server/api" 27 | "github.com/cesanta/glog" 28 | "github.com/dchest/uniuri" 29 | "github.com/go-redis/redis" 30 | ) 31 | 32 | type RedisStoreConfig struct { 33 | ClientOptions *redis.Options `yaml:"redis_options,omitempty"` 34 | ClusterOptions *redis.ClusterOptions `yaml:"redis_cluster_options,omitempty"` 35 | TokenHashCost int `yaml:"token_hash_cost,omitempty"` 36 | } 37 | 38 | type RedisClient interface { 39 | Get(key string) *redis.StringCmd 40 | Set(key string, value interface{}, expiration time.Duration) *redis.StatusCmd 41 | Del(keys ...string) *redis.IntCmd 42 | } 43 | 44 | // NewRedisTokenDB returns a new TokenDB structure which uses Redis as the storage backend. 45 | // 46 | func NewRedisTokenDB(options *RedisStoreConfig) (TokenDB, error) { 47 | var client RedisClient 48 | if options.ClusterOptions != nil { 49 | if options.ClientOptions != nil { 50 | glog.Infof("Both redis_token_db.configs and redis_token_db.cluster_configs have been set. Only the latter will be used") 51 | } 52 | client = redis.NewClusterClient(options.ClusterOptions) 53 | } else { 54 | client = redis.NewClient(options.ClientOptions) 55 | } 56 | tokenHashCost := options.TokenHashCost 57 | if tokenHashCost <= 0 { 58 | tokenHashCost = bcrypt.DefaultCost 59 | } 60 | 61 | return &redisTokenDB{client,tokenHashCost}, nil 62 | } 63 | 64 | type redisTokenDB struct { 65 | client RedisClient 66 | tokenHashCost int 67 | } 68 | 69 | func (db *redisTokenDB) String() string { 70 | return fmt.Sprintf("%v", db.client) 71 | } 72 | 73 | func (db *redisTokenDB) GetValue(user string) (*TokenDBValue, error) { 74 | // Short-circuit calling Redis when the user is anonymous 75 | if user == "" { 76 | return nil, nil 77 | } 78 | 79 | key := string(getDBKey(user)) 80 | 81 | result, err := db.client.Get(key).Result() 82 | if err == redis.Nil { 83 | glog.V(2).Infof("Key <%s> doesn't exist\n", key) 84 | return nil, nil 85 | } else if err != nil { 86 | glog.Errorf("Error getting Redis key <%s>: %s\n", key, err) 87 | return nil, fmt.Errorf("Error getting key <%s>: %s", key, err) 88 | } 89 | 90 | var dbv TokenDBValue 91 | 92 | err = json.Unmarshal([]byte(result), &dbv) 93 | if err != nil { 94 | glog.Errorf("Error parsing value for user <%q> (%q): %s", user, string(result), err) 95 | return nil, fmt.Errorf("Error parsing value: %v", err) 96 | } 97 | glog.V(2).Infof("Redis: GET %s : %v\n", key, result) 98 | return &dbv, nil 99 | } 100 | 101 | func (db *redisTokenDB) StoreToken(user string, v *TokenDBValue, updatePassword bool) (dp string, err error) { 102 | if updatePassword { 103 | dp = uniuri.New() 104 | dph, _ := bcrypt.GenerateFromPassword([]byte(dp), db.tokenHashCost) 105 | v.DockerPassword = string(dph) 106 | } 107 | 108 | data, err := json.Marshal(v) 109 | if err != nil { 110 | return "", err 111 | } 112 | 113 | key := string(getDBKey(user)) 114 | 115 | err = db.client.Set(key, data, 0).Err() 116 | if err != nil { 117 | glog.Errorf("Failed to store token data for user <%s>: %s\n", user, err) 118 | return "", fmt.Errorf("Failed to store token data for user <%s>: %s", user, err) 119 | } 120 | 121 | glog.V(2).Infof("Server tokens for <%s>: %x\n", user, string(data)) 122 | return 123 | } 124 | 125 | func (db *redisTokenDB) ValidateToken(user string, password api.PasswordString) error { 126 | dbv, err := db.GetValue(user) 127 | 128 | if err != nil { 129 | return err 130 | } 131 | 132 | if dbv == nil { 133 | return api.NoMatch 134 | } 135 | 136 | if bcrypt.CompareHashAndPassword([]byte(dbv.DockerPassword), []byte(password)) != nil { 137 | return api.WrongPass 138 | } 139 | 140 | if time.Now().After(dbv.ValidUntil) { 141 | return ExpiredToken 142 | } 143 | 144 | return nil 145 | } 146 | 147 | func (db *redisTokenDB) DeleteToken(user string) error { 148 | glog.Infof("Deleting token for user <%s>\n", user) 149 | 150 | key := string(getDBKey(user)) 151 | err := db.client.Del(key).Err() 152 | if err != nil { 153 | return fmt.Errorf("Failed to delete token for user <%s>: %s", user, err) 154 | } 155 | return nil 156 | } 157 | 158 | func (db *redisTokenDB) Close() error { 159 | return nil 160 | } 161 | -------------------------------------------------------------------------------- /auth_server/authn/tokendb_level.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2015 Cesanta Software Ltd. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | https://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package authn 18 | 19 | import ( 20 | "encoding/json" 21 | "errors" 22 | "fmt" 23 | "time" 24 | 25 | "github.com/cesanta/glog" 26 | "github.com/dchest/uniuri" 27 | "github.com/syndtr/goleveldb/leveldb" 28 | "golang.org/x/crypto/bcrypt" 29 | 30 | "github.com/cesanta/docker_auth/auth_server/api" 31 | ) 32 | 33 | const ( 34 | tokenDBPrefix = "t:" // Keys in the database are t:email@example.com 35 | ) 36 | 37 | var ExpiredToken = errors.New("expired token") 38 | 39 | type LevelDBStoreConfig struct { 40 | Path string `yaml:"path,omitempty"` 41 | TokenHashCost int `yaml:"token_hash_cost,omitempty"` 42 | } 43 | 44 | // TokenDB stores tokens using LevelDB 45 | type TokenDB interface { 46 | // GetValue takes a username returns the corresponding token 47 | GetValue(string) (*TokenDBValue, error) 48 | 49 | // StoreToken takes a username and token, stores them in the DB 50 | // and returns a password and error 51 | StoreToken(string, *TokenDBValue, bool) (string, error) 52 | 53 | // ValidateTOken takes a username and password 54 | // and returns an error 55 | ValidateToken(string, api.PasswordString) error 56 | 57 | // DeleteToken takes a username 58 | // and deletes the corresponding token from the DB 59 | DeleteToken(string) error 60 | 61 | // Composed from leveldb.DB 62 | Close() error 63 | } 64 | 65 | // TokenDB stores tokens using LevelDB 66 | type TokenDBImpl struct { 67 | *leveldb.DB 68 | } 69 | 70 | // TokenDBValue is stored in the database, JSON-serialized. 71 | type TokenDBValue struct { 72 | TokenType string `json:"token_type,omitempty"` // Usually "Bearer" 73 | AccessToken string `json:"access_token,omitempty"` 74 | RefreshToken string `json:"refresh_token,omitempty"` 75 | ValidUntil time.Time `json:"valid_until,omitempty"` 76 | // DockerPassword is the temporary password we use to authenticate Docker users. 77 | // Generated at the time of token creation, stored here as a BCrypt hash. 78 | DockerPassword string `json:"docker_password,omitempty"` 79 | Labels api.Labels `json:"labels,omitempty"` 80 | } 81 | 82 | // NewTokenDB returns a new TokenDB structure 83 | func NewTokenDB(options *LevelDBStoreConfig) (TokenDB, error) { 84 | db, err := leveldb.OpenFile(options.Path, nil) 85 | tokenHashCost := options.TokenHashCost 86 | if tokenHashCost <= 0 { 87 | tokenHashCost = bcrypt.DefaultCost 88 | } 89 | return &TokenDBImpl{ 90 | DB: db, 91 | }, err 92 | } 93 | 94 | func (db *TokenDBImpl) GetValue(user string) (*TokenDBValue, error) { 95 | valueStr, err := db.Get(getDBKey(user), nil) 96 | switch { 97 | case err == leveldb.ErrNotFound: 98 | return nil, nil 99 | case err != nil: 100 | glog.Errorf("error accessing token db: %s", err) 101 | return nil, fmt.Errorf("error accessing token db: %s", err) 102 | } 103 | var dbv TokenDBValue 104 | err = json.Unmarshal(valueStr, &dbv) 105 | if err != nil { 106 | glog.Errorf("bad DB value for %q (%q): %s", user, string(valueStr), err) 107 | return nil, fmt.Errorf("bad DB value due: %v", err) 108 | } 109 | return &dbv, nil 110 | } 111 | 112 | func (db *TokenDBImpl) StoreToken(user string, v *TokenDBValue, updatePassword bool) (dp string, err error) { 113 | if updatePassword { 114 | dp = uniuri.New() 115 | dph, _ := bcrypt.GenerateFromPassword([]byte(dp), bcrypt.DefaultCost) 116 | v.DockerPassword = string(dph) 117 | } 118 | 119 | data, err := json.Marshal(v) 120 | if err != nil { 121 | return "", err 122 | } 123 | err = db.Put(getDBKey(user), data, nil) 124 | if err != nil { 125 | glog.Errorf("failed to set token data for %s: %s", user, err) 126 | } 127 | glog.V(2).Infof("Server tokens for %s: %s", user, string(data)) 128 | return 129 | } 130 | 131 | func (db *TokenDBImpl) ValidateToken(user string, password api.PasswordString) error { 132 | dbv, err := db.GetValue(user) 133 | if err != nil { 134 | return err 135 | } 136 | if dbv == nil { 137 | return api.NoMatch 138 | } 139 | if bcrypt.CompareHashAndPassword([]byte(dbv.DockerPassword), []byte(password)) != nil { 140 | return api.WrongPass 141 | } 142 | if time.Now().After(dbv.ValidUntil) { 143 | return ExpiredToken 144 | } 145 | return nil 146 | } 147 | 148 | func (db *TokenDBImpl) DeleteToken(user string) error { 149 | glog.V(1).Infof("deleting token for %s", user) 150 | if err := db.Delete(getDBKey(user), nil); err != nil { 151 | return fmt.Errorf("failed to delete %s: %s", user, err) 152 | } 153 | return nil 154 | } 155 | 156 | func getDBKey(user string) []byte { 157 | return []byte(fmt.Sprintf("%s%s", tokenDBPrefix, user)) 158 | } 159 | -------------------------------------------------------------------------------- /auth_server/mgo_session/mgo_session.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2015 Cesanta Software Ltmc. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | https://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or impliemc. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package mgo_session 18 | 19 | import ( 20 | "context" 21 | "fmt" 22 | "io/ioutil" 23 | "net" 24 | "net/url" 25 | "strings" 26 | "time" 27 | 28 | "github.com/cesanta/glog" 29 | 30 | "go.mongodb.org/mongo-driver/mongo" 31 | "go.mongodb.org/mongo-driver/mongo/options" 32 | ) 33 | 34 | type ServerAddr struct { 35 | // contains filtered or unexported fields 36 | } 37 | 38 | type DialInfo struct { 39 | // Addrs holds the addresses for the seed servers. 40 | Addrs []string 41 | 42 | // Direct informs whether to establish connections only with the 43 | // specified seed servers, or to obtain information for the whole 44 | // cluster and establish connections with further servers too. 45 | Direct bool 46 | 47 | // Timeout is the amount of time to wait for a server to respond when 48 | // first connecting and on follow up operations in the session. If 49 | // timeout is zero, the call may block forever waiting for a connection 50 | // to be established. 51 | Timeout time.Duration 52 | 53 | // FailFast will cause connection and query attempts to fail faster when 54 | // the server is unavailable, instead of retrying until the configured 55 | // timeout period. Note that an unavailable server may silently drop 56 | // packets instead of rejecting them, in which case it's impossible to 57 | // distinguish it from a slow server, so the timeout stays relevant. 58 | FailFast bool 59 | 60 | // Database is the default database name used when the Session.DB method 61 | // is called with an empty name, and is also used during the intial 62 | // authenticatoin if Source is unset. 63 | Database string 64 | 65 | // Source is the database used to establish credentials and privileges 66 | // with a MongoDB server. Defaults to the value of Database, if that is 67 | // set, or "admin" otherwise. 68 | Source string 69 | 70 | // Service defines the service name to use when authenticating with the GSSAPI 71 | // mechanism. Defaults to "mongodb". 72 | Service string 73 | 74 | // Mechanism defines the protocol for credential negotiation. 75 | // Defaults to "MONGODB-CR". 76 | Mechanism string 77 | 78 | // Username and Password inform the credentials for the initial authentication 79 | // done on the database defined by the Source field. See Session.Login. 80 | Username string 81 | Password string 82 | 83 | // DialServer optionally specifies the dial function for establishing 84 | // connections with the MongoDB servers. 85 | DialServer func(addr *ServerAddr) (net.Conn, error) 86 | 87 | // WARNING: This field is obsolete. See DialServer above. 88 | Dial func(addr net.Addr) (net.Conn, error) 89 | } 90 | 91 | // Config stores how to connect to the MongoDB server and an optional password file 92 | type Config struct { 93 | DialInfo DialInfo `yaml:",inline"` 94 | 95 | PasswordFile string `yaml:"password_file,omitempty"` 96 | EnableTLS bool `yaml:"enable_tls,omitempty"` 97 | } 98 | 99 | // Validate ensures the most common fields inside the mgo.DialInfo portion of 100 | // a Config are set correctly as well as other fields inside the 101 | // Config itself. 102 | func (c *Config) Validate(configKey string) error { 103 | if len(c.DialInfo.Addrs) == 0 { 104 | return fmt.Errorf("At least one element in %s.dial_info.addrs is required", configKey) 105 | } 106 | if c.DialInfo.Timeout == 0 { 107 | c.DialInfo.Timeout = 10 * time.Second 108 | } 109 | if c.DialInfo.Database == "" { 110 | return fmt.Errorf("%s.dial_info.database is required", configKey) 111 | } 112 | return nil 113 | } 114 | 115 | var retClient *mongo.Client = nil 116 | 117 | func New(c *Config) (*mongo.Client, error) { 118 | 119 | if nil == retClient { 120 | // Attempt to create a MongoDB session which we can re-use when handling 121 | // multiple requests. We can optionally read in the password from a file or directly from the config. 122 | 123 | // Read in the password (if any) 124 | if c.PasswordFile != "" { 125 | passBuf, err := ioutil.ReadFile(c.PasswordFile) 126 | if err != nil { 127 | return nil, fmt.Errorf(`Failed to read password file "%s": %s`, c.PasswordFile, err) 128 | } 129 | c.DialInfo.Password = strings.TrimSpace(string(passBuf)) 130 | } 131 | 132 | glog.V(2).Infof("Creating MongoDB session (operation timeout %s)", c.DialInfo.Timeout) 133 | 134 | session, err := DialWithInfo(&c.DialInfo, c.EnableTLS) 135 | retClient = session 136 | if err != nil { 137 | return nil, err 138 | } 139 | } 140 | 141 | return retClient, nil 142 | } 143 | 144 | func DialWithInfo(info *DialInfo, enableTLS bool) (*mongo.Client, error) { 145 | 146 | sslActivationString := "ssl=false" 147 | if enableTLS { 148 | sslActivationString = "ssl=true" 149 | } 150 | 151 | // Connect 152 | username := url.QueryEscape(info.Username) 153 | password := url.QueryEscape(info.Password) 154 | uri := "mongodb://" + username + ":" + password + "@" + info.Addrs[0] + "/?authSource=admin&" + sslActivationString 155 | 156 | ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) 157 | defer cancel() 158 | client, err := mongo.Connect(ctx, options.Client().ApplyURI(uri)) 159 | if err != nil { 160 | panic(err) 161 | } else { 162 | fmt.Println("Successfully connected!") 163 | } 164 | return client, err 165 | } 166 | -------------------------------------------------------------------------------- /docs/index.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | entries: 3 | docker-auth: 4 | - apiVersion: v2 5 | appVersion: 1.14.0 6 | created: "2025-06-19T22:33:35.442011-04:00" 7 | description: Docker Registry V2 authentication server 8 | digest: 1f968367c9fbd8e3b322c0986fc501d3827f180c5f81969646220ffd262fec9b 9 | home: https://github.com/cesanta/docker_auth 10 | keywords: 11 | - docker 12 | - registry 13 | - docker-auth 14 | - docker-registry 15 | - token 16 | kubeVersion: '>=1.25' 17 | maintainers: 18 | - email: 1294057873@qq.com 19 | name: duyanghao 20 | - email: github@farberg.de 21 | name: pfisterer 22 | - email: hello@techknowlogick.com 23 | name: techknowlogick 24 | name: docker-auth 25 | sources: 26 | - https://github.com/cesanta/docker_auth 27 | urls: 28 | - https://cesanta.github.io/docker_auth/docker-auth-1.14.0.tgz 29 | version: 1.14.0 30 | - apiVersion: v2 31 | appVersion: 1.11.0 32 | created: "2025-06-19T22:33:35.44474-04:00" 33 | description: Docker Registry V2 authentication server 34 | digest: f62885093fb8aa931dfb05273e9f9429d8c0aba8f7800e9b3c8bdb605bd6842b 35 | home: https://github.com/cesanta/docker_auth 36 | keywords: 37 | - docker 38 | - registry 39 | - docker-auth 40 | - docker-registry 41 | - token 42 | maintainers: 43 | - email: 1294057873@qq.com 44 | name: duyanghao 45 | - email: github@farberg.de 46 | name: pfisterer 47 | name: docker-auth 48 | sources: 49 | - https://github.com/cesanta/docker_auth 50 | urls: 51 | - https://cesanta.github.io/docker_auth/docker-auth-1.5.0.tgz 52 | version: 1.5.0 53 | - apiVersion: v2 54 | appVersion: 1.8.0 55 | created: "2025-06-19T22:33:35.444085-04:00" 56 | description: Docker Registry V2 authentication server 57 | digest: ce41bfe9b4ddd392e67f338a95587169225e0fdc7e62258cbf7b1881c09c2d22 58 | home: https://github.com/cesanta/docker_auth 59 | keywords: 60 | - docker 61 | - registry 62 | - docker-auth 63 | - docker-registry 64 | - token 65 | maintainers: 66 | - email: 1294057873@qq.com 67 | name: duyanghao 68 | - email: github@farberg.de 69 | name: pfisterer 70 | name: docker-auth 71 | sources: 72 | - https://github.com/cesanta/docker_auth 73 | urls: 74 | - https://cesanta.github.io/docker_auth/docker-auth-1.4.0.tgz 75 | version: 1.4.0 76 | - apiVersion: v2 77 | appVersion: 1.8.0 78 | created: "2025-06-19T22:33:35.443038-04:00" 79 | description: Docker Registry V2 authentication server 80 | digest: f84d570ee0a2d37ef5838ec51af7d43a24810e8f7d6c423f87c6c046bf4d286a 81 | home: https://github.com/cesanta/docker_auth 82 | keywords: 83 | - docker 84 | - registry 85 | - docker-auth 86 | - docker-registry 87 | - token 88 | maintainers: 89 | - email: 1294057873@qq.com 90 | name: duyanghao 91 | - email: github@farberg.de 92 | name: pfisterer 93 | name: docker-auth 94 | sources: 95 | - https://github.com/cesanta/docker_auth 96 | urls: 97 | - https://cesanta.github.io/docker_auth/docker-auth-1.3.0.tgz 98 | version: 1.3.0 99 | - apiVersion: v2 100 | appVersion: 1.8.0 101 | created: "2025-06-19T22:33:35.442561-04:00" 102 | description: Docker Registry V2 authentication server 103 | digest: b656a46edc33434add27757dde85243909b4675c608c7b86f033de9f21faca8b 104 | home: https://github.com/cesanta/docker_auth 105 | keywords: 106 | - docker 107 | - registry 108 | - docker-auth 109 | - docker-registry 110 | - token 111 | maintainers: 112 | - email: 1294057873@qq.com 113 | name: duyanghao 114 | - email: github@farberg.de 115 | name: pfisterer 116 | name: docker-auth 117 | sources: 118 | - https://github.com/cesanta/docker_auth 119 | urls: 120 | - https://cesanta.github.io/docker_auth/docker-auth-1.2.0.tgz 121 | version: 1.2.0 122 | - apiVersion: v2 123 | appVersion: 1.7.0 124 | created: "2025-06-19T22:33:35.441554-04:00" 125 | description: Docker Registry V2 authentication server 126 | digest: add7b754e9b8ff9f0b9e839c0759aebb3e6a59f0db91a111b5c92009a5bd1ab6 127 | home: https://github.com/cesanta/docker_auth 128 | keywords: 129 | - docker 130 | - registry 131 | - docker-auth 132 | - docker-registry 133 | - token 134 | maintainers: 135 | - email: 1294057873@qq.com 136 | name: duyanghao 137 | - email: github@farberg.de 138 | name: pfisterer 139 | name: docker-auth 140 | sources: 141 | - https://github.com/cesanta/docker_auth 142 | urls: 143 | - https://cesanta.github.io/docker_auth/docker-auth-1.1.1.tgz 144 | version: 1.1.1 145 | - apiVersion: v2 146 | appVersion: 1.7.0 147 | created: "2025-06-19T22:33:35.440926-04:00" 148 | description: Docker Registry V2 authentication server 149 | digest: e20ff37b10dcfaa3b7cfcf78d6b9bdabfb5b1bfcdb2384fcd5ba99459297df56 150 | home: https://github.com/cesanta/docker_auth 151 | keywords: 152 | - docker 153 | - registry 154 | - docker-auth 155 | - docker-registry 156 | - token 157 | maintainers: 158 | - email: 1294057873@qq.com 159 | name: duyanghao 160 | - email: github@farberg.de 161 | name: pfisterer 162 | name: docker-auth 163 | sources: 164 | - https://github.com/cesanta/docker_auth 165 | urls: 166 | - https://cesanta.github.io/docker_auth/docker-auth-1.1.0.tgz 167 | version: 1.1.0 168 | - apiVersion: v2 169 | appVersion: 1.4.0 170 | created: "2025-06-19T22:33:35.440196-04:00" 171 | description: Docker Registry V2 authentication server 172 | digest: 7842a1c2672bb63393cec4df2ec6f554d114e32dcc91ced98e427f365be423c7 173 | home: https://github.com/cesanta/docker_auth 174 | keywords: 175 | - docker 176 | - registry 177 | - docker-auth 178 | - docker-registry 179 | - token 180 | maintainers: 181 | - email: 1294057873@qq.com 182 | name: duyanghao 183 | - email: github@farberg.de 184 | name: pfisterer 185 | name: docker-auth 186 | sources: 187 | - https://github.com/cesanta/docker_auth 188 | urls: 189 | - https://cesanta.github.io/docker_auth/docker-auth-1.0.3.tgz 190 | version: 1.0.3 191 | generated: "2025-06-19T22:33:35.439234-04:00" 192 | -------------------------------------------------------------------------------- /auth_server/authz/acl_mongo.go: -------------------------------------------------------------------------------- 1 | package authz 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "log" 9 | "sync" 10 | "time" 11 | 12 | "github.com/cesanta/glog" 13 | "go.mongodb.org/mongo-driver/mongo" 14 | "go.mongodb.org/mongo-driver/mongo/options" 15 | "gopkg.in/mgo.v2/bson" 16 | 17 | "github.com/cesanta/docker_auth/auth_server/api" 18 | "github.com/cesanta/docker_auth/auth_server/mgo_session" 19 | ) 20 | 21 | type MongoACL []MongoACLEntry 22 | 23 | type MongoACLEntry struct { 24 | ACLEntry `bson:",inline"` 25 | Seq *int 26 | } 27 | 28 | type ACLMongoConfig struct { 29 | MongoConfig *mgo_session.Config `yaml:"dial_info,omitempty"` 30 | Collection string `yaml:"collection,omitempty"` 31 | CacheTTL time.Duration `yaml:"cache_ttl,omitempty"` 32 | } 33 | 34 | type aclMongoAuthorizer struct { 35 | lastCacheUpdate time.Time 36 | lock sync.RWMutex 37 | config *ACLMongoConfig 38 | staticAuthorizer api.Authorizer 39 | session *mongo.Client 40 | context context.Context 41 | updateTicker *time.Ticker 42 | Collection string `yaml:"collection,omitempty"` 43 | CacheTTL time.Duration `yaml:"cache_ttl,omitempty"` 44 | } 45 | 46 | // NewACLMongoAuthorizer creates a new ACL MongoDB authorizer 47 | func NewACLMongoAuthorizer(c *ACLMongoConfig) (api.Authorizer, error) { 48 | // Attempt to create new MongoDB session. 49 | session, err := mgo_session.New(c.MongoConfig) 50 | if err != nil { 51 | return nil, err 52 | } 53 | 54 | authorizer := &aclMongoAuthorizer{ 55 | config: c, 56 | session: session, 57 | updateTicker: time.NewTicker(c.CacheTTL), 58 | } 59 | 60 | // Initially fetch the ACL from MongoDB 61 | if err := authorizer.updateACLCache(); err != nil { 62 | return nil, err 63 | } 64 | 65 | go authorizer.continuouslyUpdateACLCache() 66 | 67 | return authorizer, nil 68 | } 69 | 70 | func (ma *aclMongoAuthorizer) Authorize(ai *api.AuthRequestInfo) ([]string, error) { 71 | ma.lock.RLock() 72 | defer ma.lock.RUnlock() 73 | 74 | // Test if authorizer has been initialized 75 | if ma.staticAuthorizer == nil { 76 | return nil, fmt.Errorf("MongoDB authorizer is not ready") 77 | } 78 | 79 | return ma.staticAuthorizer.Authorize(ai) 80 | } 81 | 82 | // Validate ensures that any custom config options 83 | // in a Config are set correctly. 84 | func (c *ACLMongoConfig) Validate(configKey string) error { 85 | //First validate the MongoDB config. 86 | if err := c.MongoConfig.Validate(configKey); err != nil { 87 | return err 88 | } 89 | 90 | // Now check additional config fields. 91 | if c.Collection == "" { 92 | return fmt.Errorf("%s.collection is required", configKey) 93 | } 94 | if c.CacheTTL < 0 { 95 | return fmt.Errorf("%s.cache_ttl is required (e.g. \"1m\" for 1 minute)", configKey) 96 | } 97 | 98 | return nil 99 | } 100 | 101 | func (ma *aclMongoAuthorizer) Stop() { 102 | // This causes the background go routine which updates the ACL to stop 103 | ma.updateTicker.Stop() 104 | 105 | // Close connection to MongoDB database (if any) 106 | } 107 | 108 | func (ma *aclMongoAuthorizer) Name() string { 109 | return "MongoDB ACL" 110 | } 111 | 112 | // continuouslyUpdateACLCache checks if the ACL cache has expired and depending 113 | // on the the result it updates the cache with the ACL from the MongoDB server. 114 | // The ACL will be stored inside the static authorizer instance which we use 115 | // to minimize duplication of code and maximize reuse of existing code. 116 | func (ma *aclMongoAuthorizer) continuouslyUpdateACLCache() { 117 | var tick time.Time 118 | for ; true; tick = <-ma.updateTicker.C { 119 | aclAge := time.Now().Sub(ma.lastCacheUpdate) 120 | glog.V(2).Infof("Updating ACL at %s (ACL age: %s. CacheTTL: %s)", tick, aclAge, ma.config.CacheTTL) 121 | 122 | for true { 123 | err := ma.updateACLCache() 124 | if err == nil { 125 | break 126 | } else if err == io.EOF { 127 | glog.Warningf("EOF error received from Mongo. Retrying connection") 128 | time.Sleep(time.Second) 129 | continue 130 | } else { 131 | glog.Errorf("Failed to update ACL. ERROR: %s", err) 132 | glog.Warningf("Using stale ACL (Age: %s, TTL: %s)", aclAge, ma.config.CacheTTL) 133 | break 134 | } 135 | } 136 | } 137 | } 138 | 139 | func (ma *aclMongoAuthorizer) updateACLCache() error { 140 | // Get ACL from MongoDB 141 | var newACL MongoACL 142 | 143 | collection := ma.session.Database(ma.config.MongoConfig.DialInfo.Database).Collection(ma.config.Collection) 144 | 145 | // Create username index obj 146 | index := mongo.IndexModel{ 147 | Keys: bson.M{"seq": 1}, 148 | Options: options.Index().SetUnique(true), 149 | } 150 | 151 | // Enforce a username index. 152 | // mongodb will do no operation if index still exists. 153 | // see: https://pkg.go.dev/go.mongodb.org/mongo-driver/mongo#Collection.Indexes 154 | _, err := collection.Indexes().CreateOne(context.TODO(), index) 155 | if err != nil { 156 | fmt.Println(err.Error()) 157 | return err 158 | } 159 | 160 | // Get all ACLs that have the required key 161 | cur, err := collection.Find(context.TODO(), bson.M{}) 162 | 163 | if err != nil { 164 | return err 165 | } 166 | 167 | defer cur.Close(context.TODO()) 168 | for cur.Next(context.TODO()) { 169 | var result MongoACLEntry 170 | err := cur.Decode(&result) //Sort("seq") 171 | if err != nil { 172 | log.Fatal(err) 173 | } else { 174 | newACL = append(newACL, result) 175 | } 176 | // do something with result.... 177 | } 178 | if err := cur.Err(); err != nil { 179 | log.Fatal(err) 180 | } 181 | 182 | glog.V(2).Infof("Number of new ACL entries from MongoDB: %d", len(newACL)) 183 | 184 | // It is possible that the top document in the collection exists with a nil Seq. 185 | // if that's true we pull it out of the slice and complain about it. 186 | if len(newACL) > 0 && newACL[0].Seq == nil { 187 | topACL := newACL[0] 188 | return errors.New(fmt.Sprintf("Seq not set for ACL entry: %+v", topACL)) 189 | } 190 | 191 | var retACL ACL 192 | for _, e := range newACL { 193 | retACL = append(retACL, e.ACLEntry) 194 | } 195 | 196 | newStaticAuthorizer, err := NewACLAuthorizer(retACL) 197 | if err != nil { 198 | return err 199 | } 200 | 201 | ma.lock.Lock() 202 | ma.lastCacheUpdate = time.Now() 203 | ma.staticAuthorizer = newStaticAuthorizer 204 | ma.lock.Unlock() 205 | 206 | glog.V(2).Infof("Got new ACL from MongoDB: %s", retACL) 207 | glog.V(1).Infof("Installed new ACL from MongoDB (%d entries)", len(retACL)) 208 | return nil 209 | } 210 | -------------------------------------------------------------------------------- /auth_server/authz/casbin_authz_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 The casbin Authors. All Rights Reserved. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package authz 16 | 17 | import ( 18 | "fmt" 19 | "net" 20 | "strings" 21 | "testing" 22 | 23 | "github.com/casbin/casbin/v2" 24 | "github.com/casbin/casbin/v2/util" 25 | "github.com/cesanta/docker_auth/auth_server/api" 26 | ) 27 | 28 | func requestToString(ai api.AuthRequestInfo) string { 29 | return fmt.Sprintf("{%s | %s | %s | %s | %s | %s | %s}", ai.Account, ai.Type, ai.Name, ai.Service, ai.IP.String(), strings.Join(ai.Actions, ","), labelsToString(ai.Labels)) 30 | } 31 | 32 | func testRequest(t *testing.T, a api.Authorizer, account string, typ string, name string, service string, ip string, labels map[string][]string, actions []string, res []string) { 33 | ai := api.AuthRequestInfo{ 34 | Account: account, 35 | Type: typ, 36 | Name: name, 37 | Service: service, 38 | IP: net.ParseIP(ip), 39 | Actions: actions, 40 | Labels: labels} 41 | 42 | actions, err := a.Authorize(&ai) 43 | if err != nil { 44 | t.Error("Casbin authorizer fails to authorize.") 45 | return 46 | } 47 | 48 | if !util.ArrayEquals(actions, res) { 49 | t.Errorf("%s: %s, supposed to be %s", requestToString(ai), actions, res) 50 | } 51 | } 52 | 53 | func TestLabelsToString(t *testing.T) { 54 | label := map[string][]string{"a": {"b", "c"}, "d": {"e"}} 55 | labelStr := labelsToString(label) 56 | if labelStr != "{\"a\":[\"b\",\"c\"],\"d\":[\"e\"]}" { 57 | t.Errorf("%s: %s, supposed to be %s", label, labelStr, "{\"a\":[\"b\",\"c\"],\"d\":[\"e\"]}") 58 | } 59 | 60 | labelNew := stringToLabels(labelStr) 61 | if !labelMatch(label, labelNew) { 62 | t.Errorf("%s: %s, supposed to be %s", label, labelNew, label) 63 | } 64 | } 65 | 66 | func testLabels(t *testing.T, lbl1 api.Labels, lbl2 api.Labels, res bool) { 67 | myRes := labelMatch(lbl1, lbl2) 68 | if myRes != res { 69 | t.Errorf("%s matches %s: %v, supposed to be %v", lbl1, lbl2, myRes, res) 70 | } 71 | } 72 | 73 | func TestLabels(t *testing.T) { 74 | testLabels(t, map[string][]string{"a": {"b"}}, map[string][]string{"a": {"b"}}, true) 75 | testLabels(t, map[string][]string{"a": {"b"}}, map[string][]string{"a": {"c"}}, false) 76 | testLabels(t, map[string][]string{"a": {"b", "c"}}, map[string][]string{"a": {"b"}}, true) 77 | testLabels(t, map[string][]string{"a": {"b"}}, map[string][]string{"a": {"b", "c"}}, false) 78 | testLabels(t, map[string][]string{"a": {"b", "c"}, "d": {"e"}}, map[string][]string{"a": {"b", "c"}}, true) 79 | testLabels(t, map[string][]string{"a": {"b"}}, map[string][]string{"a": {"b", "c"}, "d": {"f"}}, false) 80 | } 81 | 82 | func TestPermissions(t *testing.T) { 83 | e, err := casbin.NewEnforcer("../../examples/casbin_authz_model.conf", 84 | "../../examples/casbin_authz_policy.csv") 85 | if err != nil { 86 | t.Errorf("Enforcer fails to create: %v", err) 87 | } 88 | a, err := NewCasbinAuthorizer(e) 89 | if err != nil { 90 | t.Error("Casbin authorizer fails to create.") 91 | } 92 | 93 | // alice is a user. 94 | testRequest(t, a, "alice", "book", "book1", "bookstore1", "1.2.3.4", map[string][]string{"a": {"b"}}, []string{"write", "read", "delete"}, []string{"write", "read"}) 95 | testRequest(t, a, "alice", "book", "book1", "bookstore1", "1.2.3.3", map[string][]string{"a": {"b"}}, []string{"write", "read", "delete"}, []string{}) 96 | testRequest(t, a, "alice", "book", "book2", "bookstore2", "1.2.3.4", map[string][]string{"a": {"b"}}, []string{"write", "read", "delete"}, []string{}) 97 | testRequest(t, a, "alice", "pen", "book1", "bookstore1", "1.2.3.4", map[string][]string{"a": {"b"}}, []string{"write", "read", "delete"}, []string{}) 98 | testRequest(t, a, "alice", "book", "book1", "bookstore1", "1.2.3.4", map[string][]string{"a": {"c"}}, []string{"write", "read", "delete"}, []string{}) 99 | testRequest(t, a, "alice", "book", "book1", "bookstore1", "1.2.3.4", map[string][]string{"a": {"b", "c"}}, []string{"write", "read", "delete"}, []string{"write", "read"}) 100 | 101 | // bob is a member of role1, so bob will have all permissions of role1. 102 | testRequest(t, a, "bob", "book", "book2", "bookstore1", "192.168.1.123", map[string][]string{"a": {"b", "c"}, "d": {"e"}}, []string{"write", "read", "delete"}, []string{"read"}) 103 | testRequest(t, a, "bob", "book", "book2", "bookstore1", "192.168.1.123", map[string][]string{"a": {"b"}, "d": {"e"}}, []string{"write", "read", "delete"}, []string{}) 104 | testRequest(t, a, "bob", "book", "book2", "bookstore1", "192.168.0.123", map[string][]string{"a": {"b", "c"}, "d": {"e"}}, []string{"write", "read", "delete"}, []string{}) 105 | testRequest(t, a, "bob", "book", "book2", "bookstore1", "192.168.1.123", map[string][]string{"a": {"b", "c"}}, []string{"write", "read", "delete"}, []string{"read"}) 106 | testRequest(t, a, "bob", "book", "book2", "restaurant", "192.168.1.123", map[string][]string{"a": {"b", "c"}, "d": {"e"}}, []string{"write", "read", "delete"}, []string{}) 107 | 108 | // admin is the administrator, so he can do anything without restriction. 109 | testRequest(t, a, "admin", "book", "book1", "bookstore1", "1.2.3.4", map[string][]string{"a": {"b"}}, []string{"write", "read", "delete"}, []string{"write", "read", "delete"}) 110 | testRequest(t, a, "admin", "book", "book1", "bookstore1", "1.2.3.3", map[string][]string{"a": {"b"}}, []string{"write", "read", "delete"}, []string{"write", "read", "delete"}) 111 | testRequest(t, a, "admin", "book", "book2", "bookstore2", "1.2.3.4", map[string][]string{"a": {"b"}}, []string{"write", "read", "delete"}, []string{"write", "read", "delete"}) 112 | testRequest(t, a, "admin", "pen", "book1", "bookstore1", "1.2.3.4", map[string][]string{"a": {"b"}}, []string{"write", "read", "delete"}, []string{"write", "read", "delete"}) 113 | testRequest(t, a, "admin", "book", "book1", "bookstore1", "1.2.3.4", map[string][]string{"a": {"c"}}, []string{"write", "read", "delete"}, []string{"write", "read", "delete"}) 114 | testRequest(t, a, "admin", "book", "book1", "bookstore1", "1.2.3.4", map[string][]string{"a": {"b", "c"}}, []string{"write", "read", "delete"}, []string{"write", "read", "delete"}) 115 | } 116 | -------------------------------------------------------------------------------- /chart/docker-auth/README.md: -------------------------------------------------------------------------------- 1 | # Helm Chart for docker_auth 2 | 3 | A Helm chart for deploying a [private Docker Registry](https://github.com/cesanta/docker_auth). 4 | 5 | ## Overview 6 | 7 | This chart deploys docker_auth, which provides token-based authentication and authorization for Docker Registry v2. It implements the Docker Registry authentication protocol and supports various authentication backends. 8 | 9 | ## Prerequisites 10 | 11 | - Kubernetes 1.25+ 12 | - Helm 3.0+ 13 | 14 | ## Installation 15 | 16 | ### Add Helm Repository 17 | 18 | ```bash 19 | helm repo add cesanta https://cesanta.github.io/docker_auth/ 20 | helm repo update 21 | ``` 22 | 23 | ### Basic Installation 24 | 25 | ```bash 26 | helm install my-docker-auth cesanta/docker-auth 27 | ``` 28 | 29 | ### Installation with Custom Values 30 | 31 | ```bash 32 | helm install docker-auth cesanta/docker-auth -f values.yaml 33 | ``` 34 | 35 | ### Uninstall 36 | 37 | ```bash 38 | helm uninstall docker-auth 39 | ``` 40 | 41 | ## Configuration 42 | 43 | ### Values 44 | 45 | | Parameter | Description | Default | 46 | |-----------|-------------|---------| 47 | | **Image** | | | 48 | | `image.repository` | Docker image repository | `cesanta/docker_auth` | 49 | | `image.tag` | Docker image tag | `1.14.0` | 50 | | `image.pullPolicy` | Image pull policy | `IfNotPresent` | 51 | | **Deployment** | | | 52 | | `replicaCount` | Number of replicas | `1` | 53 | | `nameOverride` | Override name of the chart | `""` | 54 | | `fullnameOverride` | Override full name of the chart | `""` | 55 | | **Logging** | | | 56 | | `logging.level` | Log verbosity level (0-10). Passed as `--v=X` flag to docker_auth binary. Higher numbers = more verbose logging. | `2` | 57 | | **Authentication** | | | 58 | | `configmap.data.token.issuer` | Token issuer name (must match registry config) | `"Acme auth server"` | 59 | | `configmap.data.token.expiration` | Token expiration time in seconds | `900` | 60 | | `configmap.data.token.disableLegacyKeyId` | Disables legacy key IDs for registry v3 | `false` | 61 | | `configmap.data.users` | Static user definitions | See values.yaml | 62 | | `configmap.data.acl` | Access control list rules | See values.yaml | 63 | | **TLS/Certificates** | | | 64 | | `secret.data.server.certificate` | Server certificate content (PEM format, base64 encoded) | `""` | 65 | | `secret.data.server.key` | Server private key content (PEM format, base64 encoded) | `""` | 66 | | `secret.secretName` | External secret name for certificates (alternative to inline cert/key) | `""` | 67 | | **Service** | | | 68 | | `service.type` | Kubernetes service type | `ClusterIP` | 69 | | `service.port` | Service port | `5001` | 70 | | `service.targetPort` | Container port | `5001` | 71 | | **Ingress** | | | 72 | | `ingress.enabled` | Enable ingress | `true` | 73 | | `ingress.className` | Ingress class name | `""` | 74 | | `ingress.annotations` | Ingress annotations | `{}` | 75 | | `ingress.labels` | Ingress labels | `{}` | 76 | | `ingress.hosts` | Ingress hosts configuration | See values.yaml | 77 | | `ingress.tls` | Ingress TLS configuration | `[]` | 78 | | **Resources** | | | 79 | | `resources` | CPU/Memory resource requests/limits | `{}` | 80 | | `nodeSelector` | Node selector | `{}` | 81 | | `tolerations` | Tolerations | `[]` | 82 | | `affinity` | Affinity rules | `{}` | 83 | | **Security** | | | 84 | | `podSecurityContext` | Pod security context | `{}` | 85 | | `containerSecurityContext` | Container security context | `{}` | 86 | | `podAnnotations` | Pod annotations | `{}` | 87 | | **Registry Integration** | | | 88 | | `registry.enabled` | Enable integrated docker-registry | `false` | 89 | 90 | ### Quick Start Example 91 | 92 | ```yaml 93 | # values.yaml 94 | ingress: 95 | enabled: true 96 | className: "nginx" 97 | annotations: 98 | cert-manager.io/cluster-issuer: "letsencrypt-prod" 99 | nginx.ingress.kubernetes.io/force-ssl-redirect: "true" 100 | hosts: 101 | - host: docker-auth.example.com 102 | paths: 103 | - path: / 104 | pathType: Prefix 105 | tls: 106 | - secretName: docker-auth-tls 107 | hosts: 108 | - docker-auth.example.com 109 | 110 | configmap: 111 | data: 112 | token: 113 | issuer: "docker-auth-prod" 114 | expiration: 900 115 | users: 116 | "admin": 117 | password: "$2y$05$..." # Generate with htpasswd -Bbn admin password 118 | acl: 119 | - match: {account: "admin"} 120 | actions: ["*"] 121 | comment: "Admin has full access" 122 | - match: {account: ""} 123 | actions: ["pull"] 124 | comment: "Anonymous users can pull" 125 | ``` 126 | 127 | ## Certificate Management 128 | 129 | ### Generate Self-Signed Certificates 130 | 131 | ```bash 132 | openssl req -new -newkey rsa:4096 -days 5000 -nodes -x509 \ 133 | -subj "/C=DE/ST=BW/L=Mannheim/O=ACME/CN=docker-auth" \ 134 | -keyout generated-docker-auth-server.key \ 135 | -out generated-docker-auth-server.pem 136 | 137 | CERT_PEM_BASE64=`cat generated-docker-auth-server.pem | base64` 138 | CERT_KEY_BASE64=`cat generated-docker-auth-server.key | base64` 139 | ``` 140 | 141 | ## Access Control Lists (ACL) 142 | 143 | ### ACL Configuration 144 | 145 | ```yaml 146 | configmap: 147 | data: 148 | acl: 149 | - match: { account: "admin" } 150 | actions: ["*"] 151 | comment: "Admin has full access to everything." 152 | - match: { account: "" } 153 | actions: ["pull"] 154 | comment: "Anonymous users can pull" 155 | ``` 156 | 157 | ## Monitoring and Logging 158 | 159 | ### Increase Log Verbosity 160 | 161 | ```yaml 162 | logging: 163 | level: 5 # Higher values = more verbose (0-10) 164 | ``` 165 | 166 | ## Troubleshooting 167 | 168 | ### Debug Commands 169 | 170 | ```bash 171 | # Check pod logs 172 | kubectl logs -l app.kubernetes.io/name=docker-auth 173 | 174 | # Check configuration 175 | kubectl get configmap docker-auth -o yaml 176 | 177 | # Test authentication endpoint 178 | curl -k https://docker-auth.example.com/auth 179 | 180 | # Verify certificate 181 | openssl x509 -in certificate.pem -text -noout 182 | ``` 183 | 184 | ## Integration with Docker Registry 185 | 186 | To use with Docker Registry, configure the registry with: 187 | 188 | ```yaml 189 | # Registry configuration 190 | auth: 191 | token: 192 | realm: https://docker-auth.example.com/auth 193 | service: token-service 194 | issuer: docker-auth-prod # Must match configmap.data.token.issuer 195 | rootcertbundle: /path/to/docker-auth.crt 196 | ``` 197 | 198 | ## Development 199 | 200 | ### Chart Development 201 | 202 | ```bash 203 | # Lint the chart 204 | helm lint chart/docker-auth 205 | 206 | # Test template rendering 207 | helm template test-release chart/docker-auth 208 | 209 | # Package the chart 210 | helm package chart/docker-auth 211 | ``` 212 | 213 | ### Update Repository 214 | 215 | ```bash 216 | cd chart/docker-auth 217 | helm lint 218 | helm package . 219 | mv docker-auth-*.tgz ../../docs/ 220 | helm repo index ../../docs/ --url https://cesanta.github.io/docker_auth/ 221 | git add ../../docs/ 222 | git commit -m "Updated helm repository" 223 | git push origin main 224 | ``` 225 | -------------------------------------------------------------------------------- /auth_server/authz/acl_test.go: -------------------------------------------------------------------------------- 1 | package authz 2 | 3 | import ( 4 | "net" 5 | "testing" 6 | 7 | "github.com/cesanta/docker_auth/auth_server/api" 8 | ) 9 | 10 | func sp(s string) *string { 11 | return &s 12 | } 13 | 14 | func TestValidation(t *testing.T) { 15 | cases := []struct { 16 | mc MatchConditions 17 | ok bool 18 | }{ 19 | // Valid stuff 20 | {MatchConditions{}, true}, 21 | {MatchConditions{Account: sp("foo")}, true}, 22 | {MatchConditions{Account: sp("foo?*")}, true}, 23 | {MatchConditions{Account: sp("/foo.*/")}, true}, 24 | {MatchConditions{Type: sp("foo")}, true}, 25 | {MatchConditions{Type: sp("foo?*")}, true}, 26 | {MatchConditions{Type: sp("/foo.*/")}, true}, 27 | {MatchConditions{Name: sp("foo")}, true}, 28 | {MatchConditions{Name: sp("foo?*")}, true}, 29 | {MatchConditions{Name: sp("/foo.*/")}, true}, 30 | {MatchConditions{Service: sp("foo")}, true}, 31 | {MatchConditions{Service: sp("foo?*")}, true}, 32 | {MatchConditions{Service: sp("/foo.*/")}, true}, 33 | {MatchConditions{IP: sp("192.168.0.1")}, true}, 34 | {MatchConditions{IP: sp("192.168.0.0/16")}, true}, 35 | {MatchConditions{IP: sp("2001:db8::1")}, true}, 36 | {MatchConditions{IP: sp("2001:db8::/48")}, true}, 37 | {MatchConditions{Labels: map[string]string{"foo": "bar"}}, true}, 38 | // Invalid stuff 39 | {MatchConditions{Account: sp("/foo?*/")}, false}, 40 | {MatchConditions{Type: sp("/foo?*/")}, false}, 41 | {MatchConditions{Name: sp("/foo?*/")}, false}, 42 | {MatchConditions{Service: sp("/foo?*/")}, false}, 43 | {MatchConditions{IP: sp("192.168.0.1/100")}, false}, 44 | {MatchConditions{IP: sp("192.168.0.*")}, false}, 45 | {MatchConditions{IP: sp("foo")}, false}, 46 | {MatchConditions{IP: sp("2001:db8::/222")}, false}, 47 | {MatchConditions{Labels: map[string]string{"foo": "/bar?*/"}}, false}, 48 | } 49 | for i, c := range cases { 50 | result := validateMatchConditions(&c.mc) 51 | if c.ok && result != nil { 52 | t.Errorf("%d: %v: expected to pass, got %s", i, c.mc, result) 53 | } else if !c.ok && result == nil { 54 | t.Errorf("%d: %v: expected to fail, but it passed", i, c.mc) 55 | } 56 | } 57 | } 58 | 59 | func TestMatching(t *testing.T) { 60 | ai1 := api.AuthRequestInfo{Account: "foo", Type: "bar", Name: "baz", Service: "notary"} 61 | ai2 := api.AuthRequestInfo{Account: "foo", Type: "bar", Name: "baz", Service: "notary", 62 | Labels: map[string][]string{"group": []string{"admins", "VIP"}}} 63 | ai3 := api.AuthRequestInfo{Account: "foo", Type: "bar", Name: "admins/foo", Service: "notary", 64 | Labels: map[string][]string{"group": []string{"admins", "VIP"}}} 65 | ai4 := api.AuthRequestInfo{Account: "foo", Type: "bar", Name: "VIP/api", Service: "notary", 66 | Labels: map[string][]string{"group": []string{"admins", "VIP"}, "project": []string{"api", "frontend"}}} 67 | ai5 := api.AuthRequestInfo{Account: "foo", Type: "bar", Name: "devs/api", Service: "notary", 68 | Labels: map[string][]string{"group": []string{"admins", "VIP"}, "project": []string{"api", "frontend"}}} 69 | cases := []struct { 70 | mc MatchConditions 71 | ai api.AuthRequestInfo 72 | matches bool 73 | }{ 74 | {MatchConditions{}, ai1, true}, 75 | {MatchConditions{Account: sp("foo")}, ai1, true}, 76 | {MatchConditions{Account: sp("foo"), Type: sp("bar")}, ai1, true}, 77 | {MatchConditions{Account: sp("foo"), Type: sp("baz")}, ai1, false}, 78 | {MatchConditions{Account: sp("fo?"), Type: sp("b*"), Name: sp("/z$/")}, ai1, true}, 79 | {MatchConditions{Account: sp("fo?"), Type: sp("b*"), Name: sp("/^z/")}, ai1, false}, 80 | {MatchConditions{Name: sp("${account}")}, api.AuthRequestInfo{Account: "foo", Name: "foo"}, true}, // Var subst 81 | {MatchConditions{Name: sp("/${account}_.*/")}, api.AuthRequestInfo{Account: "foo", Name: "foo_x"}, true}, 82 | {MatchConditions{Name: sp("/${account}_.*/")}, api.AuthRequestInfo{Account: ".*", Name: "foo_x"}, false}, // Quoting 83 | {MatchConditions{Account: sp(`/^(.+)@test\.com$/`), Name: sp(`${account:1}/*`)}, api.AuthRequestInfo{Account: "john.smith@test.com", Name: "john.smith/test"}, true}, 84 | {MatchConditions{Account: sp(`/^(.+)@test\.com$/`), Name: sp(`${account:3}/*`)}, api.AuthRequestInfo{Account: "john.smith@test.com", Name: "john.smith/test"}, false}, 85 | {MatchConditions{Account: sp(`/^(.+)@(.+?).test\.com$/`), Name: sp(`${account:1}-${account:2}/*`)}, api.AuthRequestInfo{Account: "john.smith@it.test.com", Name: "john.smith-it/test"}, true}, 86 | {MatchConditions{Service: sp("notary"), Type: sp("bar")}, ai1, true}, 87 | {MatchConditions{Service: sp("notary"), Type: sp("baz")}, ai1, false}, 88 | {MatchConditions{Service: sp("notary1"), Type: sp("bar")}, ai1, false}, 89 | // IP matching 90 | {MatchConditions{IP: sp("127.0.0.1")}, api.AuthRequestInfo{IP: nil}, false}, 91 | {MatchConditions{IP: sp("127.0.0.1")}, api.AuthRequestInfo{IP: net.IPv4(127, 0, 0, 1)}, true}, 92 | {MatchConditions{IP: sp("127.0.0.1")}, api.AuthRequestInfo{IP: net.IPv4(127, 0, 0, 2)}, false}, 93 | {MatchConditions{IP: sp("127.0.0.2")}, api.AuthRequestInfo{IP: net.IPv4(127, 0, 0, 1)}, false}, 94 | {MatchConditions{IP: sp("127.0.0.0/8")}, api.AuthRequestInfo{IP: net.IPv4(127, 0, 0, 1)}, true}, 95 | {MatchConditions{IP: sp("127.0.0.0/8")}, api.AuthRequestInfo{IP: net.IPv4(127, 0, 0, 2)}, true}, 96 | {MatchConditions{IP: sp("2001:db8::1")}, api.AuthRequestInfo{IP: nil}, false}, 97 | {MatchConditions{IP: sp("2001:db8::1")}, api.AuthRequestInfo{IP: net.ParseIP("2001:db8::1")}, true}, 98 | {MatchConditions{IP: sp("2001:db8::1")}, api.AuthRequestInfo{IP: net.ParseIP("2001:db8::2")}, false}, 99 | {MatchConditions{IP: sp("2001:db8::2")}, api.AuthRequestInfo{IP: net.ParseIP("2001:db8::1")}, false}, 100 | {MatchConditions{IP: sp("2001:db8::/48")}, api.AuthRequestInfo{IP: net.ParseIP("2001:db8::1")}, true}, 101 | {MatchConditions{IP: sp("2001:db8::/48")}, api.AuthRequestInfo{IP: net.ParseIP("2001:db8::2")}, true}, 102 | // Label matching 103 | {MatchConditions{Labels: map[string]string{"foo": "bar"}}, ai1, false}, 104 | {MatchConditions{Labels: map[string]string{"foo": "bar"}}, ai2, false}, 105 | {MatchConditions{Labels: map[string]string{"group": "admins"}}, ai2, true}, 106 | {MatchConditions{Labels: map[string]string{"foo": "bar", "group": "admins"}}, ai2, false}, // "and" logic 107 | {MatchConditions{Labels: map[string]string{"group": "VIP"}}, ai2, true}, 108 | {MatchConditions{Labels: map[string]string{"group": "a*"}}, ai2, true}, 109 | {MatchConditions{Labels: map[string]string{"group": "/(admins|VIP)/"}}, ai2, true}, 110 | // // Label placeholder matching 111 | {MatchConditions{Name: sp("${labels:group}/*")}, ai1, false}, // no labels 112 | {MatchConditions{Name: sp("${labels:noexist}/*")}, ai2, false}, // wrong labels 113 | {MatchConditions{Name: sp("${labels:group}/*")}, ai3, true}, // match label 114 | {MatchConditions{Name: sp("${labels:noexist}/*")}, ai3, false}, // missing label 115 | {MatchConditions{Name: sp("${labels:group}/${labels:project}")}, ai4, true}, // multiple label match success 116 | {MatchConditions{Name: sp("${labels:group}/${labels:noexist}")}, ai4, false}, // multiple label match fail 117 | {MatchConditions{Name: sp("${labels:group}/${labels:project}")}, ai4, true}, // multiple label match success 118 | {MatchConditions{Name: sp("${labels:group}/${labels:noexist}")}, ai4, false}, // multiple label match fail wrong label 119 | {MatchConditions{Name: sp("${labels:group}/${labels:project}")}, ai5, false}, // multiple label match fail. right label, wrong value 120 | } 121 | for i, c := range cases { 122 | if result := c.mc.Matches(&c.ai); result != c.matches { 123 | t.Errorf("%d: %#v vs %#v: expected %t, got %t", i, c.mc, c.ai, c.matches, result) 124 | } 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /auth_server/main.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2015 Cesanta Software Ltd. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | https://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package main 18 | 19 | import ( 20 | "context" 21 | "crypto/tls" 22 | "flag" 23 | "math/rand" 24 | "net" 25 | "net/http" 26 | "os" 27 | "os/signal" 28 | "strconv" 29 | "syscall" 30 | "time" 31 | 32 | "github.com/cesanta/glog" 33 | "golang.org/x/crypto/acme/autocert" 34 | fsnotify "gopkg.in/fsnotify.v1" 35 | 36 | "github.com/cesanta/docker_auth/auth_server/server" 37 | ) 38 | 39 | var ( 40 | // Version comment 41 | Version = "" 42 | // BuildID comment 43 | BuildID = "" 44 | ) 45 | 46 | type RestartableServer struct { 47 | configFile string 48 | authServer *server.AuthServer 49 | hs *http.Server 50 | } 51 | 52 | func stringToUint16(s string) uint16 { 53 | v, err := strconv.ParseUint(s, 0, 16) 54 | if err != nil { 55 | glog.Exitf("Failed to convert %s to uint16", s) 56 | } 57 | return uint16(v) 58 | } 59 | 60 | func ServeOnce(c *server.Config, cf string) (*server.AuthServer, *http.Server) { 61 | glog.Infof("Config from %s (%d users, %d ACL static entries)", cf, len(c.Users), len(c.ACL)) 62 | as, err := server.NewAuthServer(c) 63 | if err != nil { 64 | glog.Exitf("Failed to create auth server: %s", err) 65 | } 66 | 67 | tlsConfig := &tls.Config{} 68 | if c.Server.HSTS { 69 | glog.Info("HTTP Strict Transport Security enabled") 70 | } 71 | if c.Server.TLSMinVersion != "" { 72 | value, found := server.TLSVersionValues[c.Server.TLSMinVersion] 73 | if !found { 74 | value = stringToUint16(c.Server.TLSMinVersion) 75 | } 76 | tlsConfig.MinVersion = value 77 | glog.Infof("TLS MinVersion: %s", c.Server.TLSMinVersion) 78 | } 79 | if c.Server.TLSCurvePreferences != nil { 80 | var values []tls.CurveID 81 | for _, s := range c.Server.TLSCurvePreferences { 82 | value, found := server.TLSCurveIDValues[s] 83 | if !found { 84 | value = tls.CurveID(stringToUint16(s)) 85 | } 86 | values = append(values, value) 87 | } 88 | tlsConfig.CurvePreferences = values 89 | glog.Infof("TLS CurvePreferences: %s", c.Server.TLSCurvePreferences) 90 | } 91 | if c.Server.TLSCipherSuites != nil { 92 | var values []uint16 93 | for _, s := range c.Server.TLSCipherSuites { 94 | value, found := server.TLSCipherSuitesValues[s] 95 | if !found { 96 | value = stringToUint16(s) 97 | } 98 | values = append(values, value) 99 | } 100 | tlsConfig.CipherSuites = values 101 | glog.Infof("TLS CipherSuites: %s", c.Server.TLSCipherSuites) 102 | } else { 103 | for _, s := range tls.CipherSuites() { 104 | tlsConfig.CipherSuites = append(tlsConfig.CipherSuites, s.ID) 105 | } 106 | } 107 | if c.Server.CertFile != "" || c.Server.KeyFile != "" { 108 | // Check for partial configuration. 109 | if c.Server.CertFile == "" || c.Server.KeyFile == "" { 110 | glog.Exitf("Failed to load certificate and key: both were not provided") 111 | } 112 | glog.Infof("Cert file: %s", c.Server.CertFile) 113 | glog.Infof("Key file : %s", c.Server.KeyFile) 114 | tlsConfig.Certificates = make([]tls.Certificate, 1) 115 | tlsConfig.Certificates[0], err = tls.LoadX509KeyPair(c.Server.CertFile, c.Server.KeyFile) 116 | if err != nil { 117 | glog.Exitf("Failed to load certificate and key: %s", err) 118 | } 119 | } else if c.Server.LetsEncrypt.Email != "" { 120 | m := &autocert.Manager{ 121 | Email: c.Server.LetsEncrypt.Email, 122 | Cache: autocert.DirCache(c.Server.LetsEncrypt.CacheDir), 123 | Prompt: autocert.AcceptTOS, 124 | } 125 | if c.Server.LetsEncrypt.Host != "" { 126 | m.HostPolicy = autocert.HostWhitelist(c.Server.LetsEncrypt.Host) 127 | } 128 | glog.Infof("Using LetsEncrypt, host %q, email %q", c.Server.LetsEncrypt.Host, c.Server.LetsEncrypt.Email) 129 | tlsConfig.GetCertificate = m.GetCertificate 130 | } else { 131 | glog.Warning("Running without TLS") 132 | tlsConfig = nil 133 | } 134 | 135 | hs := &http.Server{ 136 | Addr: c.Server.ListenAddress, 137 | Handler: as, 138 | TLSConfig: tlsConfig, 139 | } 140 | 141 | var listener net.Listener 142 | if c.Server.Net == "unix" { 143 | // Remove socket, if exists 144 | if _, err := os.Stat(c.Server.ListenAddress); err == nil { 145 | if err := os.Remove(c.Server.ListenAddress); err != nil { 146 | glog.Fatal(err.Error()) 147 | } 148 | } 149 | listener, err = net.Listen("unix", c.Server.ListenAddress) 150 | if err != nil { 151 | glog.Fatal(err.Error()) 152 | } 153 | } else { 154 | listener, err = net.Listen("tcp", c.Server.ListenAddress) 155 | if err != nil { 156 | glog.Fatal(err.Error()) 157 | } 158 | } 159 | 160 | go func() { 161 | if c.Server.CertFile == "" && c.Server.KeyFile == "" { 162 | if err := hs.Serve(listener); err != nil { 163 | if err == http.ErrServerClosed { 164 | return 165 | } 166 | } 167 | } else { 168 | if err := hs.ServeTLS(listener, c.Server.CertFile, c.Server.KeyFile); err != nil { 169 | if err == http.ErrServerClosed { 170 | return 171 | } 172 | } 173 | } 174 | }() 175 | glog.Infof("Serving on %s", c.Server.ListenAddress) 176 | return as, hs 177 | } 178 | 179 | func (rs *RestartableServer) Serve(c *server.Config) { 180 | rs.authServer, rs.hs = ServeOnce(c, rs.configFile) 181 | rs.WatchConfig() 182 | } 183 | 184 | func (rs *RestartableServer) WatchConfig() { 185 | w, err := fsnotify.NewWatcher() 186 | if err != nil { 187 | glog.Fatalf("Failed to create watcher: %s", err) 188 | } 189 | defer w.Close() 190 | 191 | stopSignals := make(chan os.Signal, 1) 192 | signal.Notify(stopSignals, syscall.SIGTERM, syscall.SIGINT) 193 | 194 | err = w.Add(rs.configFile) 195 | watching, needRestart := (err == nil), false 196 | for { 197 | select { 198 | case <-time.After(1 * time.Second): 199 | if !watching { 200 | err = w.Add(rs.configFile) 201 | if err != nil { 202 | glog.Errorf("Failed to set up config watcher: %s", err) 203 | } else { 204 | watching, needRestart = true, true 205 | } 206 | } else if needRestart { 207 | rs.MaybeRestart() 208 | needRestart = false 209 | } 210 | case ev := <-w.Events: 211 | if ev.Op == fsnotify.Remove { 212 | glog.Warningf("Config file disappeared, serving continues") 213 | w.Remove(rs.configFile) 214 | watching, needRestart = false, false 215 | } else if ev.Op == fsnotify.Write { 216 | needRestart = true 217 | } 218 | case s := <-stopSignals: 219 | signal.Stop(stopSignals) 220 | glog.Infof("Signal: %s", s) 221 | if err := rs.hs.Shutdown(context.Background()); err != nil { 222 | glog.Errorf("HTTP server Shutdown: %v", err) 223 | } 224 | rs.authServer.Stop() 225 | glog.Exitf("Exiting") 226 | } 227 | } 228 | } 229 | 230 | func (rs *RestartableServer) MaybeRestart() { 231 | glog.Infof("Validating new config") 232 | c, err := server.LoadConfig(rs.configFile) 233 | if err != nil { 234 | glog.Errorf("Failed to reload config (server not restarted): %s", err) 235 | return 236 | } 237 | glog.Infof("Config ok, restarting server") 238 | rs.hs.Close() 239 | rs.authServer.Stop() 240 | rs.authServer, rs.hs = ServeOnce(c, rs.configFile) 241 | } 242 | 243 | func main() { 244 | flag.Parse() 245 | rand.Seed(time.Now().UnixNano()) 246 | glog.CopyStandardLogTo("INFO") 247 | 248 | glog.Infof("docker_auth %s build %s", Version, BuildID) 249 | 250 | cf := flag.Arg(0) 251 | if cf == "" { 252 | glog.Exitf("Config file not specified") 253 | } 254 | config, err := server.LoadConfig(cf) 255 | if err != nil { 256 | glog.Exitf("Failed to load config: %s", err) 257 | } 258 | rs := RestartableServer{ 259 | configFile: cf, 260 | } 261 | rs.Serve(config) 262 | } 263 | -------------------------------------------------------------------------------- /auth_server/authz/acl.go: -------------------------------------------------------------------------------- 1 | package authz 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net" 8 | "path" 9 | "reflect" 10 | "regexp" 11 | "strconv" 12 | "strings" 13 | 14 | "github.com/cesanta/glog" 15 | 16 | "github.com/cesanta/docker_auth/auth_server/api" 17 | ) 18 | 19 | type ACL []ACLEntry 20 | 21 | type ACLEntry struct { 22 | Match *MatchConditions `yaml:"match"` 23 | Actions *[]string `yaml:"actions,flow"` 24 | Comment *string `yaml:"comment,omitempty"` 25 | } 26 | 27 | type MatchConditions struct { 28 | Account *string `yaml:"account,omitempty" json:"account,omitempty"` 29 | Type *string `yaml:"type,omitempty" json:"type,omitempty"` 30 | Name *string `yaml:"name,omitempty" json:"name,omitempty"` 31 | IP *string `yaml:"ip,omitempty" json:"ip,omitempty"` 32 | Service *string `yaml:"service,omitempty" json:"service,omitempty"` 33 | Labels map[string]string `yaml:"labels,omitempty" json:"labels,omitempty"` 34 | } 35 | 36 | type aclAuthorizer struct { 37 | acl ACL 38 | } 39 | 40 | func validatePattern(p string) error { 41 | if len(p) > 2 && p[0] == '/' && p[len(p)-1] == '/' { 42 | _, err := regexp.Compile(p[1 : len(p)-1]) 43 | if err != nil { 44 | return fmt.Errorf("invalid regex pattern: %s", err) 45 | } 46 | } 47 | return nil 48 | } 49 | 50 | func parseIPPattern(ipp string) (*net.IPNet, error) { 51 | ipnet := net.IPNet{} 52 | ipnet.IP = net.ParseIP(ipp) 53 | if ipnet.IP != nil { 54 | if ipnet.IP.To4() != nil { 55 | ipnet.Mask = net.CIDRMask(32, 32) 56 | } else { 57 | ipnet.Mask = net.CIDRMask(128, 128) 58 | } 59 | return &ipnet, nil 60 | } else { 61 | _, ipnet, err := net.ParseCIDR(ipp) 62 | if err != nil { 63 | return nil, err 64 | } 65 | return ipnet, nil 66 | } 67 | } 68 | 69 | func validateMatchConditions(mc *MatchConditions) error { 70 | for _, p := range []*string{mc.Account, mc.Type, mc.Name, mc.Service} { 71 | if p == nil { 72 | continue 73 | } 74 | err := validatePattern(*p) 75 | if err != nil { 76 | return fmt.Errorf("invalid pattern %q: %s", *p, err) 77 | } 78 | } 79 | if mc.IP != nil { 80 | _, err := parseIPPattern(*mc.IP) 81 | if err != nil { 82 | return fmt.Errorf("invalid IP pattern: %s", err) 83 | } 84 | } 85 | for k, v := range mc.Labels { 86 | err := validatePattern(v) 87 | if err != nil { 88 | return fmt.Errorf("invalid match pattern %q for label %s: %s", v, k, err) 89 | } 90 | } 91 | return nil 92 | } 93 | 94 | func ValidateACL(acl ACL) error { 95 | for i, e := range acl { 96 | err := validateMatchConditions(e.Match) 97 | if err != nil { 98 | return fmt.Errorf("entry %d, invalid match conditions: %s", i, err) 99 | } 100 | } 101 | return nil 102 | } 103 | 104 | // NewACLAuthorizer Creates a new static authorizer with ACL that have been read from the config file 105 | func NewACLAuthorizer(acl ACL) (api.Authorizer, error) { 106 | if err := ValidateACL(acl); err != nil { 107 | return nil, err 108 | } 109 | glog.V(1).Infof("Created ACL Authorizer with %d entries", len(acl)) 110 | return &aclAuthorizer{acl: acl}, nil 111 | } 112 | 113 | func (aa *aclAuthorizer) Authorize(ai *api.AuthRequestInfo) ([]string, error) { 114 | for _, e := range aa.acl { 115 | matched := e.Matches(ai) 116 | if matched { 117 | comment := "(nil)" 118 | if e.Comment != nil { 119 | comment = *e.Comment 120 | } 121 | glog.V(2).Infof("%s matched %s (Comment: %s)", ai, e, comment) 122 | if len(*e.Actions) == 1 && (*e.Actions)[0] == "*" { 123 | return ai.Actions, nil 124 | } 125 | return StringSetIntersection(ai.Actions, *e.Actions), nil 126 | } 127 | } 128 | return nil, api.NoMatch 129 | } 130 | 131 | func (aa *aclAuthorizer) Stop() { 132 | // Nothing to do. 133 | } 134 | 135 | func (aa *aclAuthorizer) Name() string { 136 | return "static ACL" 137 | } 138 | 139 | type aclEntryJSON *ACLEntry 140 | 141 | func (e ACLEntry) String() string { 142 | b, _ := json.Marshal(e) 143 | return string(b) 144 | } 145 | 146 | func matchString(pp *string, s string, vars []string) bool { 147 | if pp == nil { 148 | return true 149 | } 150 | p := strings.NewReplacer(vars...).Replace(*pp) 151 | 152 | var matched bool 153 | var err error 154 | if len(p) > 2 && p[0] == '/' && p[len(p)-1] == '/' { 155 | matched, err = regexp.Match(p[1:len(p)-1], []byte(s)) 156 | } else { 157 | matched, err = path.Match(p, s) 158 | } 159 | return err == nil && matched 160 | } 161 | 162 | func matchStringWithLabelPermutations(pp *string, s string, vars []string, labelMap *map[string][]string) bool { 163 | var matched bool 164 | // First try basic matching 165 | matched = matchString(pp, s, vars) 166 | // If basic matching fails then try with label permuations 167 | if !matched { 168 | // Take the labelMap and build the structure required for the cartesian library 169 | var labelSets [][]interface{} 170 | for placeholder, labels := range *labelMap { 171 | // Don't bother generating perumations for placeholders not in match string 172 | // Since the label permuations are a cartesian product this can have 173 | // a huge impact on performance 174 | if strings.Contains(*pp, placeholder) { 175 | var labelSet []interface{} 176 | for _, label := range labels { 177 | labelSet = append(labelSet, []string{placeholder, label}) 178 | } 179 | labelSets = append(labelSets, labelSet) 180 | } 181 | } 182 | if len(labelSets) > 0 { 183 | ctx, cancel := context.WithCancel(context.Background()) 184 | defer cancel() 185 | 186 | for permuation := range IterWithContext(ctx, labelSets...) { 187 | var labelVars []string 188 | for _, val := range permuation { 189 | labelVars = append(labelVars, val.([]string)...) 190 | } 191 | matched = matchString(pp, s, append(vars, labelVars...)) 192 | if matched { 193 | return matched 194 | } 195 | } 196 | } 197 | } 198 | return matched 199 | } 200 | 201 | func IterWithContext(ctx context.Context, params ...[]interface{}) <-chan []interface{} { 202 | c := make(chan []interface{}) 203 | 204 | if len(params) == 0 { 205 | close(c) 206 | return c 207 | } 208 | 209 | go func() { 210 | defer close(c) // Ensure the channel is closed when the goroutine exits 211 | 212 | iterate(ctx, c, params[0], []interface{}{}, params[1:]...) 213 | }() 214 | 215 | return c 216 | } 217 | 218 | func iterate(ctx context.Context, channel chan []interface{}, topLevel, result []interface{}, needUnpacking ...[]interface{}) { 219 | if len(needUnpacking) == 0 { 220 | for _, p := range topLevel { 221 | select { 222 | case <-ctx.Done(): 223 | return // Exit if the context is canceled 224 | case channel <- append(append([]interface{}{}, result...), p): 225 | } 226 | } 227 | return 228 | } 229 | 230 | for _, p := range topLevel { 231 | select { 232 | case <-ctx.Done(): 233 | return // Exit if the context is canceled 234 | default: 235 | iterate(ctx, channel, needUnpacking[0], append(result, p), needUnpacking[1:]...) 236 | } 237 | } 238 | } 239 | 240 | func matchIP(ipp *string, ip net.IP) bool { 241 | if ipp == nil { 242 | return true 243 | } 244 | if ip == nil { 245 | return false 246 | } 247 | ipnet, err := parseIPPattern(*ipp) 248 | if err != nil { // Can't happen, it supposed to have been validated 249 | glog.Fatalf("Invalid IP pattern: %s", *ipp) 250 | } 251 | return ipnet.Contains(ip) 252 | } 253 | 254 | func matchLabels(ml map[string]string, rl api.Labels, vars []string) bool { 255 | for label, pattern := range ml { 256 | labelValues := rl[label] 257 | matched := false 258 | for _, lv := range labelValues { 259 | if matchString(&pattern, lv, vars) { 260 | matched = true 261 | break 262 | } 263 | } 264 | if !matched { 265 | return false 266 | } 267 | } 268 | return true 269 | } 270 | 271 | var captureGroupRegex = regexp.MustCompile(`\$\{(.+?):(\d+)\}`) 272 | 273 | func getField(i interface{}, name string) (string, bool) { 274 | s := reflect.Indirect(reflect.ValueOf(i)) 275 | f := reflect.Indirect(s.FieldByName(name)) 276 | if !f.IsValid() { 277 | return "", false 278 | } 279 | return f.String(), true 280 | } 281 | 282 | func (mc *MatchConditions) Matches(ai *api.AuthRequestInfo) bool { 283 | vars := []string{ 284 | "${account}", regexp.QuoteMeta(ai.Account), 285 | "${type}", regexp.QuoteMeta(ai.Type), 286 | "${name}", regexp.QuoteMeta(ai.Name), 287 | "${service}", regexp.QuoteMeta(ai.Service), 288 | } 289 | for _, x := range []string{"Account", "Type", "Name", "Service"} { 290 | field, _ := getField(mc, x) 291 | for _, found := range captureGroupRegex.FindAllStringSubmatch(field, -1) { 292 | key := strings.Title(found[1]) 293 | index, _ := strconv.Atoi(found[2]) 294 | field, has := getField(mc, key) 295 | if !has { 296 | glog.Errorf("No field in '%s' in MatchConditions", key) 297 | continue 298 | } 299 | if len(field) < 2 || field[0] != '/' || field[len(field)-1] != '/' { 300 | continue 301 | } 302 | regex, err := regexp.Compile(field[1 : len(field)-1]) 303 | if err != nil { 304 | glog.Errorf("Invalid regex in '%s' of MatchConditions", key) 305 | continue 306 | } 307 | info, has := getField(ai, key) 308 | if !has { 309 | glog.Errorf("No field in '%s' in AuthRequestInfo", key) 310 | continue 311 | } 312 | text := regex.FindStringSubmatch(info) 313 | if index < 1 || index > len(text)-1 { 314 | glog.Errorf("%s: Capture group index out of range", key) 315 | continue 316 | } 317 | vars = append(vars, found[0], text[index]) 318 | } 319 | } 320 | labelMap := make(map[string][]string) 321 | for label, labelValues := range ai.Labels { 322 | var labelSet []string 323 | for _, lv := range labelValues { 324 | labelSet = append(labelSet, lv) 325 | } 326 | labelMap[fmt.Sprintf("${labels:%s}", label)] = labelSet 327 | } 328 | return matchStringWithLabelPermutations(mc.Account, ai.Account, vars, &labelMap) && 329 | matchStringWithLabelPermutations(mc.Type, ai.Type, vars, &labelMap) && 330 | matchStringWithLabelPermutations(mc.Name, ai.Name, vars, &labelMap) && 331 | matchStringWithLabelPermutations(mc.Service, ai.Service, vars, &labelMap) && 332 | matchIP(mc.IP, ai.IP) && 333 | matchLabels(mc.Labels, ai.Labels, vars) 334 | } 335 | 336 | func (e *ACLEntry) Matches(ai *api.AuthRequestInfo) bool { 337 | return e.Match.Matches(ai) 338 | } 339 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | -------------------------------------------------------------------------------- /auth_server/authn/ldap_auth.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2015 Cesanta Software Ltd. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | https://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package authn 18 | 19 | import ( 20 | "crypto/tls" 21 | "crypto/x509" 22 | "fmt" 23 | "io/ioutil" 24 | "strings" 25 | 26 | "github.com/cesanta/glog" 27 | "github.com/go-ldap/ldap" 28 | 29 | "github.com/cesanta/docker_auth/auth_server/api" 30 | ) 31 | 32 | type LabelMap struct { 33 | Attribute string `yaml:"attribute,omitempty"` 34 | ParseCN bool `yaml:"parse_cn,omitempty"` 35 | LowerCase bool `yaml:"lower_case",omitempty"` 36 | } 37 | 38 | type LDAPAuthConfig struct { 39 | Addr string `yaml:"addr,omitempty"` 40 | TLS string `yaml:"tls,omitempty"` 41 | InsecureTLSSkipVerify bool `yaml:"insecure_tls_skip_verify,omitempty"` 42 | CACertificate string `yaml:"ca_certificate,omitempty"` 43 | Base string `yaml:"base,omitempty"` 44 | Filter string `yaml:"filter,omitempty"` 45 | BindDN string `yaml:"bind_dn,omitempty"` 46 | BindPasswordFile string `yaml:"bind_password_file,omitempty"` 47 | LabelMaps map[string]LabelMap `yaml:"labels,omitempty"` 48 | InitialBindAsUser bool `yaml:"initial_bind_as_user,omitempty"` 49 | } 50 | 51 | type LDAPAuth struct { 52 | config *LDAPAuthConfig 53 | } 54 | 55 | func NewLDAPAuth(c *LDAPAuthConfig) (*LDAPAuth, error) { 56 | if c.TLS == "" && strings.HasSuffix(c.Addr, ":636") { 57 | c.TLS = "always" 58 | } 59 | return &LDAPAuth{ 60 | config: c, 61 | }, nil 62 | } 63 | 64 | //How to authenticate user, please refer to https://github.com/go-ldap/ldap/blob/master/example_test.go#L166 65 | func (la *LDAPAuth) Authenticate(account string, password api.PasswordString) (bool, api.Labels, error) { 66 | if account == "" || password == "" { 67 | return false, nil, api.NoMatch 68 | } 69 | l, err := la.ldapConnection() 70 | if err != nil { 71 | return false, nil, err 72 | } 73 | defer l.Close() 74 | 75 | account = la.escapeAccountInput(account) 76 | if la.config.InitialBindAsUser { 77 | if bindErr := la.bindInitialAsUser(l, account, password); bindErr != nil { 78 | if ldap.IsErrorWithCode(bindErr, ldap.LDAPResultInvalidCredentials) { 79 | return false, nil, api.WrongPass 80 | } 81 | return false, nil, bindErr 82 | } 83 | } else { 84 | // First bind with a read only user, to prevent the following search won't perform any write action 85 | if bindErr := la.bindReadOnlyUser(l); bindErr != nil { 86 | return false, nil, bindErr 87 | } 88 | } 89 | 90 | filter := la.getFilter(account) 91 | 92 | labelAttributes, labelsConfigErr := la.getLabelAttributes() 93 | if labelsConfigErr != nil { 94 | return false, nil, labelsConfigErr 95 | } 96 | 97 | accountEntryDN, entryAttrMap, uSearchErr := la.ldapSearch(l, &la.config.Base, &filter, &labelAttributes) 98 | if uSearchErr != nil { 99 | return false, nil, uSearchErr 100 | } 101 | if accountEntryDN == "" { 102 | return false, nil, api.NoMatch // User does not exist 103 | } 104 | 105 | // Bind as the user to verify their password 106 | if len(accountEntryDN) > 0 { 107 | err := l.Bind(accountEntryDN, string(password)) 108 | if err != nil { 109 | if ldap.IsErrorWithCode(err, ldap.LDAPResultInvalidCredentials) { 110 | return false, nil, nil 111 | } 112 | return false, nil, err 113 | } 114 | } 115 | // Rebind as the read only user for any futher queries 116 | if !la.config.InitialBindAsUser { 117 | if bindErr := la.bindReadOnlyUser(l); bindErr != nil { 118 | return false, nil, bindErr 119 | } 120 | } 121 | 122 | // Extract labels from the attribute values 123 | labels, labelsExtractErr := la.getLabelsFromMap(entryAttrMap) 124 | if labelsExtractErr != nil { 125 | return false, nil, labelsExtractErr 126 | } 127 | 128 | return true, labels, nil 129 | } 130 | 131 | func (la *LDAPAuth) bindReadOnlyUser(l *ldap.Conn) error { 132 | if la.config.BindDN != "" { 133 | password, err := ioutil.ReadFile(la.config.BindPasswordFile) 134 | if err != nil { 135 | return err 136 | } 137 | password_str := strings.TrimSpace(string(password)) 138 | glog.V(2).Infof("Bind read-only user (DN = %s)", la.config.BindDN) 139 | err = l.Bind(la.config.BindDN, password_str) 140 | if err != nil { 141 | return err 142 | } 143 | } 144 | return nil 145 | } 146 | 147 | func (la *LDAPAuth) getInitialBindDN(account string) string { 148 | initialBindDN := strings.NewReplacer("${account}", account).Replace(la.config.BindDN) 149 | glog.V(2).Infof("Initial BindDN is %s", initialBindDN) 150 | return initialBindDN 151 | } 152 | 153 | func (la *LDAPAuth) bindInitialAsUser(l *ldap.Conn, account string, password api.PasswordString) error { 154 | accountEntryDN := la.getInitialBindDN(account) 155 | glog.V(2).Infof("Bind as initial user (DN = %s)", accountEntryDN) 156 | err := l.Bind(accountEntryDN, string(password)) 157 | if err != nil { 158 | return err 159 | } 160 | return nil 161 | } 162 | 163 | //To prevent LDAP injection, some characters must be escaped for searching 164 | //e.g. char '\' will be replaced by hex '\5c' 165 | //Filter meta chars are choosen based on filter complier code 166 | //https://github.com/go-ldap/ldap/blob/master/filter.go#L159 167 | func (la *LDAPAuth) escapeAccountInput(account string) string { 168 | r := strings.NewReplacer( 169 | `\`, `\5c`, 170 | `(`, `\28`, 171 | `)`, `\29`, 172 | `!`, `\21`, 173 | `*`, `\2a`, 174 | `&`, `\26`, 175 | `|`, `\7c`, 176 | `=`, `\3d`, 177 | `>`, `\3e`, 178 | `<`, `\3c`, 179 | `~`, `\7e`, 180 | ) 181 | return r.Replace(account) 182 | } 183 | 184 | func (la *LDAPAuth) ldapConnection() (*ldap.Conn, error) { 185 | var l *ldap.Conn 186 | var err error 187 | 188 | tlsConfig := &tls.Config{InsecureSkipVerify: true} 189 | if !la.config.InsecureTLSSkipVerify { 190 | addr := strings.Split(la.config.Addr, ":") 191 | if la.config.CACertificate != "" { 192 | pool := x509.NewCertPool() 193 | pem, err := ioutil.ReadFile(la.config.CACertificate) 194 | if err != nil { 195 | return nil, fmt.Errorf("Error loading CA File: %s", err) 196 | } 197 | ok := pool.AppendCertsFromPEM(pem) 198 | if !ok { 199 | return nil, fmt.Errorf("Error loading CA File: Couldn't parse PEM in: %s", la.config.CACertificate) 200 | } 201 | tlsConfig = &tls.Config{InsecureSkipVerify: false, ServerName: addr[0], RootCAs: pool} 202 | } else { 203 | tlsConfig = &tls.Config{InsecureSkipVerify: false, ServerName: addr[0]} 204 | } 205 | } 206 | 207 | if la.config.TLS == "" || la.config.TLS == "none" || la.config.TLS == "starttls" { 208 | glog.V(2).Infof("Dial: starting...%s", la.config.Addr) 209 | l, err = ldap.Dial("tcp", fmt.Sprintf("%s", la.config.Addr)) 210 | if err == nil && la.config.TLS == "starttls" { 211 | glog.V(2).Infof("StartTLS...") 212 | if tlserr := l.StartTLS(tlsConfig); tlserr != nil { 213 | return nil, tlserr 214 | } 215 | } 216 | } else if la.config.TLS == "always" { 217 | glog.V(2).Infof("DialTLS: starting...%s", la.config.Addr) 218 | l, err = ldap.DialTLS("tcp", fmt.Sprintf("%s", la.config.Addr), tlsConfig) 219 | } 220 | if err != nil { 221 | return nil, err 222 | } 223 | return l, nil 224 | } 225 | 226 | func (la *LDAPAuth) getFilter(account string) string { 227 | filter := strings.NewReplacer("${account}", account).Replace(la.config.Filter) 228 | glog.V(2).Infof("search filter is %s", filter) 229 | return filter 230 | } 231 | 232 | //ldap search and return required attributes' value from searched entries 233 | //default return entry's DN value if you leave attrs array empty 234 | func (la *LDAPAuth) ldapSearch(l *ldap.Conn, baseDN *string, filter *string, attrs *[]string) (string, map[string][]string, error) { 235 | if l == nil { 236 | return "", nil, fmt.Errorf("No ldap connection!") 237 | } 238 | glog.V(2).Infof("Searching...basedDN:%s, filter:%s", *baseDN, *filter) 239 | searchRequest := ldap.NewSearchRequest( 240 | *baseDN, 241 | ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, 242 | *filter, 243 | *attrs, 244 | nil) 245 | sr, err := l.Search(searchRequest) 246 | if err != nil { 247 | return "", nil, err 248 | } 249 | 250 | if len(sr.Entries) == 0 { 251 | return "", nil, nil // User does not exist 252 | } else if len(sr.Entries) > 1 { 253 | return "", nil, fmt.Errorf("Too many entries returned.") 254 | } 255 | 256 | attributes := make(map[string][]string) 257 | var entryDn string 258 | for _, entry := range sr.Entries { 259 | entryDn = entry.DN 260 | if len(*attrs) == 0 { 261 | glog.V(2).Infof("Entry DN = %s", entryDn) 262 | } else { 263 | for _, attr := range *attrs { 264 | values := entry.GetAttributeValues(attr) 265 | glog.V(2).Infof("Entry %s = %s", attr, strings.Join(values, "\n")) 266 | attributes[attr] = values 267 | } 268 | } 269 | } 270 | 271 | return entryDn, attributes, nil 272 | } 273 | 274 | func (la *LDAPAuth) getLabelAttributes() ([]string, error) { 275 | labelAttributes := make([]string, len(la.config.LabelMaps)) 276 | i := 0 277 | for key, mapping := range la.config.LabelMaps { 278 | if mapping.Attribute == "" { 279 | return nil, fmt.Errorf("Label %s is missing 'attribute' to map from", key) 280 | } 281 | labelAttributes[i] = mapping.Attribute 282 | i++ 283 | } 284 | return labelAttributes, nil 285 | } 286 | 287 | func (la *LDAPAuth) getLabelsFromMap(attrMap map[string][]string) (map[string][]string, error) { 288 | labels := make(map[string][]string) 289 | for key, mapping := range la.config.LabelMaps { 290 | if mapping.Attribute == "" { 291 | return nil, fmt.Errorf("Label %s is missing 'attribute' to map from", key) 292 | } 293 | 294 | mappingValues := attrMap[mapping.Attribute] 295 | if mappingValues != nil { 296 | if mapping.ParseCN { 297 | // shorten attribute to its common name 298 | for i, value := range mappingValues { 299 | cn := la.getCNFromDN(value) 300 | mappingValues[i] = cn 301 | } 302 | } 303 | if mapping.LowerCase { 304 | for i, value := range mappingValues { 305 | mappingValues[i] = strings.ToLower(value) 306 | } 307 | } 308 | labels[key] = mappingValues 309 | } 310 | } 311 | return labels, nil 312 | } 313 | 314 | func (la *LDAPAuth) getCNFromDN(dn string) string { 315 | parsedDN, err := ldap.ParseDN(dn) 316 | if err != nil || len(parsedDN.RDNs) > 0 { 317 | for _, rdn := range parsedDN.RDNs { 318 | for _, rdnAttr := range rdn.Attributes { 319 | if strings.ToUpper(rdnAttr.Type) == "CN" { 320 | return rdnAttr.Value 321 | } 322 | } 323 | } 324 | } 325 | 326 | // else try using raw DN 327 | return dn 328 | } 329 | 330 | func (la *LDAPAuth) Stop() { 331 | } 332 | 333 | func (la *LDAPAuth) Name() string { 334 | return "LDAP" 335 | } 336 | -------------------------------------------------------------------------------- /auth_server/authn/gitlab_auth.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2016 Cesanta Software Ltd. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | https://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package authn 18 | 19 | import ( 20 | "bytes" 21 | "encoding/json" 22 | "errors" 23 | "fmt" 24 | "html/template" 25 | "io" 26 | "net/http" 27 | "net/url" 28 | "strings" 29 | "time" 30 | 31 | "github.com/cesanta/glog" 32 | 33 | "github.com/cesanta/docker_auth/auth_server/api" 34 | ) 35 | 36 | type GitlabTeamCollection []GitlabTeam 37 | 38 | type GitlabTeam struct { 39 | Id int64 `json:"id"` 40 | Url string `json:"url,omitempty"` 41 | Name string `json:"name,omitempty"` 42 | Slug string `json:"slug,omitempty"` 43 | Organization *GitlabOrganization `json:"organization"` 44 | Parent *ParentGitlabTeam `json:"parent,omitempty"` 45 | } 46 | 47 | type GitlabOrganization struct { 48 | Login string `json:"login"` 49 | Id int64 `json:"id,omitempty"` 50 | } 51 | 52 | type ParentGitlabTeam struct { 53 | Id int64 `json:"id"` 54 | Name string `json:"name,omitempty"` 55 | Slug string `json:"slug,omitempty"` 56 | } 57 | 58 | type GitlabAuthConfig struct { 59 | Organization string `yaml:"organization,omitempty"` 60 | ClientId string `yaml:"client_id,omitempty"` 61 | ClientSecret string `yaml:"client_secret,omitempty"` 62 | ClientSecretFile string `yaml:"client_secret_file,omitempty"` 63 | LevelTokenDB *LevelDBStoreConfig `yaml:"level_token_db,omitempty"` 64 | GCSTokenDB *GCSStoreConfig `yaml:"gcs_token_db,omitempty"` 65 | RedisTokenDB *RedisStoreConfig `yaml:"redis_token_db,omitempty"` 66 | HTTPTimeout time.Duration `yaml:"http_timeout,omitempty"` 67 | RevalidateAfter time.Duration `yaml:"revalidate_after,omitempty"` 68 | GitlabWebUri string `yaml:"gitlab_web_uri,omitempty"` 69 | GitlabApiUri string `yaml:"gitlab_api_uri,omitempty"` 70 | RegistryUrl string `yaml:"registry_url,omitempty"` 71 | GrantType string `yaml:"grant_type,omitempty"` 72 | RedirectUri string `yaml:"redirect_uri,omitempty"` 73 | } 74 | 75 | type CodeToGitlabTokenResponse struct { 76 | AccessToken string `json:"access_token,omitempty"` 77 | TokenType string `json:"token_type,omitempty"` 78 | ExpiresIn int64 `json:"expires_in,omitempty"` 79 | RefreshToken string `json:"refresh_token,omitempty"` 80 | CreatedAt int64 `json:"created_at,omitempty"` 81 | 82 | // Returned in case of error. 83 | Error string `json:"error,omitempty"` 84 | ErrorDescription string `json:"error_description,omitempty"` 85 | } 86 | 87 | type GitlabAuthRequest struct { 88 | Action string `json:"action,omitempty"` 89 | Code string `json:"code,omitempty"` 90 | Token string `json:"token,omitempty"` 91 | } 92 | 93 | type GitlabTokenUser struct { 94 | Login string `json:"username,omitempty"` 95 | Email string `json:"email,omitempty"` 96 | } 97 | 98 | type GitlabAuth struct { 99 | config *GitlabAuthConfig 100 | db TokenDB 101 | client *http.Client 102 | tmpl *template.Template 103 | tmplResult *template.Template 104 | } 105 | 106 | func NewGitlabAuth(c *GitlabAuthConfig) (*GitlabAuth, error) { 107 | var db TokenDB 108 | var err error 109 | var dbName string 110 | 111 | switch { 112 | case c.GCSTokenDB != nil: 113 | db, err = NewGCSTokenDB(c.GCSTokenDB) 114 | dbName = "GCS: " + c.GCSTokenDB.Bucket 115 | case c.RedisTokenDB != nil: 116 | db, err = NewRedisTokenDB(c.RedisTokenDB) 117 | dbName = db.(*redisTokenDB).String() 118 | default: 119 | db, err = NewTokenDB(c.LevelTokenDB) 120 | dbName = c.LevelTokenDB.Path 121 | } 122 | 123 | if err != nil { 124 | return nil, err 125 | } 126 | glog.Infof("GitLab auth token DB at %s", dbName) 127 | gitlab_auth, _ := static.ReadFile("data/gitlab_auth.tmpl") 128 | gitlab_auth_result, _ := static.ReadFile("data/gitlab_auth_result.tmpl") 129 | return &GitlabAuth{ 130 | config: c, 131 | db: db, 132 | client: &http.Client{Timeout: c.HTTPTimeout}, 133 | tmpl: template.Must(template.New("gitlab_auth").Parse(string(gitlab_auth))), 134 | tmplResult: template.Must(template.New("gitlab_auth_result").Parse(string(gitlab_auth_result))), 135 | }, nil 136 | } 137 | 138 | func (glab *GitlabAuth) doGitlabAuthPage(rw http.ResponseWriter, req *http.Request) { 139 | if err := glab.tmpl.Execute(rw, struct { 140 | ClientId, GitlabWebUri, Organization, RedirectUri string 141 | }{ 142 | ClientId: glab.config.ClientId, 143 | GitlabWebUri: glab.getGitlabWebUri(), 144 | Organization: glab.config.Organization, 145 | RedirectUri: glab.config.RedirectUri}); err != nil { 146 | http.Error(rw, fmt.Sprintf("Template error: %s", err), http.StatusInternalServerError) 147 | } 148 | } 149 | 150 | func (glab *GitlabAuth) doGitlabAuthResultPage(rw http.ResponseWriter, username string, password string) { 151 | if err := glab.tmplResult.Execute(rw, struct { 152 | Organization, Username, Password, RegistryUrl string 153 | }{Organization: glab.config.Organization, 154 | Username: username, 155 | Password: password, 156 | RegistryUrl: glab.config.RegistryUrl}); err != nil { 157 | http.Error(rw, fmt.Sprintf("Template error: %s", err), http.StatusInternalServerError) 158 | } 159 | } 160 | 161 | func (glab *GitlabAuth) DoGitlabAuth(rw http.ResponseWriter, req *http.Request) { 162 | code := req.URL.Query().Get("code") 163 | 164 | if code != "" { 165 | glab.doGitlabAuthCreateToken(rw, code) 166 | } else if req.Method == "GET" { 167 | glab.doGitlabAuthPage(rw, req) 168 | return 169 | } 170 | } 171 | 172 | func (glab *GitlabAuth) getGitlabApiUri() string { 173 | if glab.config.GitlabApiUri != "" { 174 | return glab.config.GitlabApiUri 175 | } else { 176 | return "https://gitlab.com" 177 | } 178 | } 179 | 180 | func (glab *GitlabAuth) getGitlabWebUri() string { 181 | if glab.config.GitlabWebUri != "" { 182 | return glab.config.GitlabWebUri 183 | } else { 184 | return "https://gitlab.com/api/v4" 185 | } 186 | } 187 | 188 | func (glab *GitlabAuth) doGitlabAuthCreateToken(rw http.ResponseWriter, code string) { 189 | data := url.Values{ 190 | "client_id": []string{glab.config.ClientId}, 191 | "client_secret": []string{glab.config.ClientSecret}, 192 | "code": []string{string(code)}, 193 | "grant_type": []string{glab.config.GrantType}, 194 | "redirect_uri": []string{glab.config.RedirectUri}, 195 | } 196 | req, err := http.NewRequest("POST", fmt.Sprintf("%s/oauth/token", glab.getGitlabWebUri()), bytes.NewBufferString(data.Encode())) 197 | if err != nil { 198 | http.Error(rw, fmt.Sprintf("Error creating request to GitHub auth backend: %s", err), http.StatusServiceUnavailable) 199 | return 200 | } 201 | req.Header.Add("Accept", "application/json") 202 | resp, err := glab.client.Do(req) 203 | if err != nil { 204 | http.Error(rw, fmt.Sprintf("Error talking to GitLab auth backend: %s", err), http.StatusServiceUnavailable) 205 | return 206 | } 207 | codeResp, _ := io.ReadAll(resp.Body) 208 | resp.Body.Close() 209 | glog.V(2).Infof("Code to token resp: %s", strings.Replace(string(codeResp), "\n", " ", -1)) 210 | 211 | var c2t CodeToTokenResponse 212 | err = json.Unmarshal(codeResp, &c2t) 213 | if err != nil || c2t.Error != "" || c2t.ErrorDescription != "" { 214 | var et string 215 | if err != nil { 216 | et = err.Error() 217 | } else { 218 | et = fmt.Sprintf("%s: %s", c2t.Error, c2t.ErrorDescription) 219 | } 220 | http.Error(rw, fmt.Sprintf("Failed to get token: %s", et), http.StatusBadRequest) 221 | return 222 | } 223 | user, err := glab.validateGitlabAccessToken(c2t.AccessToken) 224 | if err != nil { 225 | glog.Errorf("Newly-acquired token is invalid: %+v %s", c2t, err) 226 | http.Error(rw, "Newly-acquired token is invalid", http.StatusInternalServerError) 227 | return 228 | } 229 | 230 | glog.Infof("New GitLab auth token for %s", user) 231 | 232 | v := &TokenDBValue{ 233 | TokenType: c2t.TokenType, 234 | AccessToken: c2t.AccessToken, 235 | ValidUntil: time.Now().Add(glab.config.RevalidateAfter), 236 | } 237 | dp, err := glab.db.StoreToken(user, v, true) 238 | if err != nil { 239 | glog.Errorf("Failed to record server token: %s", err) 240 | http.Error(rw, "Failed to record server token: %s", http.StatusInternalServerError) 241 | return 242 | } 243 | glab.doGitlabAuthResultPage(rw, user, dp) 244 | } 245 | 246 | func (glab *GitlabAuth) validateGitlabAccessToken(token string) (user string, err error) { 247 | glog.Infof("Gitlab API: Fetching user info") 248 | req, err := http.NewRequest("GET", fmt.Sprintf("%s/user", glab.getGitlabApiUri()), nil) 249 | 250 | if err != nil { 251 | err = fmt.Errorf("could not create request to get information for token %s: %s", token, err) 252 | return 253 | } 254 | req.Header.Add("Accept", "application/json") 255 | req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token)) 256 | 257 | resp, err := glab.client.Do(req) 258 | if err != nil { 259 | err = fmt.Errorf("could not verify token %s: %s", token, err) 260 | return 261 | } 262 | body, _ := io.ReadAll(resp.Body) 263 | resp.Body.Close() 264 | var ti GitlabTokenUser 265 | err = json.Unmarshal(body, &ti) 266 | if err != nil { 267 | err = fmt.Errorf("could not unmarshal token user info %q: %s", string(body), err) 268 | return 269 | } 270 | glog.V(2).Infof("Token user info: %+v", strings.Replace(string(body), "\n", " ", -1)) 271 | return ti.Login, nil 272 | } 273 | 274 | func (glab *GitlabAuth) checkGitlabOrganization(token, user string) (err error) { 275 | if glab.config.Organization == "" { 276 | return nil 277 | } 278 | glog.Infof("Gitlab API: Fetching organization membership info") 279 | url := fmt.Sprintf("%s/orgs/%s/members/%s", glab.getGitlabApiUri(), glab.config.Organization, user) 280 | req, err := http.NewRequest("GET", url, nil) 281 | if err != nil { 282 | err = fmt.Errorf("could not create request to get organization membership: %s", err) 283 | return 284 | } 285 | req.Header.Add("Authorization", fmt.Sprintf("token %s", token)) 286 | 287 | resp, err := glab.client.Do(req) 288 | if err != nil { 289 | return 290 | } 291 | switch resp.StatusCode { 292 | case http.StatusNoContent: 293 | return nil 294 | case http.StatusNotFound: 295 | return fmt.Errorf("user %s is not a member of organization %s", user, glab.config.Organization) 296 | case http.StatusFound: 297 | return fmt.Errorf("token %s could not get membership for organization %s", token, glab.config.Organization) 298 | } 299 | 300 | return fmt.Errorf("Unknown status for membership of organization %s: %s", glab.config.Organization, resp.Status) 301 | } 302 | 303 | func (glab *GitlabAuth) validateGitlabServerToken(user string) (*TokenDBValue, error) { 304 | v, err := glab.db.GetValue(user) 305 | if err != nil || v == nil { 306 | if err == nil { 307 | err = errors.New("no db value, please sign out and sign in again") 308 | } 309 | return nil, err 310 | } 311 | 312 | texp := v.ValidUntil.Sub(time.Now()) 313 | glog.V(3).Infof("Existing Gitlab auth token for <%s> expires after: <%d> sec", user, int(texp.Seconds())) 314 | 315 | glog.V(1).Infof("Token has expired. I will revalidate the access token.") 316 | glog.V(3).Infof("Old token is: %+v", v) 317 | tokenUser, err := glab.validateGitlabAccessToken(v.AccessToken) 318 | if err != nil { 319 | glog.Warningf("Token for %q failed validation: %s", user, err) 320 | return nil, fmt.Errorf("server token invalid: %s", err) 321 | } 322 | if tokenUser != user { 323 | glog.Errorf("token for wrong user: expected %s, found %s", user, tokenUser) 324 | return nil, fmt.Errorf("found token for wrong user") 325 | } 326 | 327 | // Update revalidation timestamp 328 | v.ValidUntil = time.Now().Add(glab.config.RevalidateAfter) 329 | glog.V(3).Infof("New token is: %+v", v) 330 | 331 | // Update token 332 | _, err = glab.db.StoreToken(user, v, false) 333 | if err != nil { 334 | glog.Errorf("Failed to record server token: %s", err) 335 | return nil, fmt.Errorf("Unable to store renewed token expiry time: %s", err) 336 | } 337 | glog.V(2).Infof("Successfully revalidated token") 338 | 339 | texp = v.ValidUntil.Sub(time.Now()) 340 | glog.V(3).Infof("Re-validated Gitlab auth token for %s. Next revalidation in %dsec.", user, int64(texp.Seconds())) 341 | return v, nil 342 | } 343 | 344 | func (glab *GitlabAuth) Authenticate(user string, password api.PasswordString) (bool, api.Labels, error) { 345 | err := glab.db.ValidateToken(user, password) 346 | if err == ExpiredToken { 347 | _, err = glab.validateGitlabServerToken(user) 348 | if err != nil { 349 | return false, nil, err 350 | } 351 | } else if err != nil { 352 | return false, nil, err 353 | } 354 | 355 | v, err := glab.db.GetValue(user) 356 | if err != nil || v == nil { 357 | if err == nil { 358 | err = errors.New("no db value, please sign out and sign in again") 359 | } 360 | return false, nil, err 361 | } 362 | 363 | return true, v.Labels, nil 364 | } 365 | 366 | func (glab *GitlabAuth) Stop() { 367 | glab.db.Close() 368 | glog.Info("Token DB closed") 369 | } 370 | 371 | func (glab *GitlabAuth) Name() string { 372 | return "Gitlab" 373 | } 374 | -------------------------------------------------------------------------------- /auth_server/authn/oidc_auth.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2015 Cesanta Software Ltd. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | https://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package authn 18 | 19 | import ( 20 | "context" 21 | "encoding/json" 22 | "errors" 23 | "fmt" 24 | "html/template" 25 | "io" 26 | "net/http" 27 | "strings" 28 | "time" 29 | 30 | "golang.org/x/oauth2" 31 | 32 | "github.com/coreos/go-oidc/v3/oidc" 33 | 34 | "github.com/cesanta/glog" 35 | 36 | "github.com/cesanta/docker_auth/auth_server/api" 37 | ) 38 | 39 | // All configuration options 40 | type OIDCAuthConfig struct { 41 | // --- necessary --- 42 | // URL of the authentication provider. Must be able to serve the /.well-known/openid-configuration 43 | Issuer string `yaml:"issuer,omitempty"` 44 | // URL of the auth server. Has to end with /oidc_auth 45 | RedirectURL string `yaml:"redirect_url,omitempty"` 46 | // ID and secret, priovided by the OIDC provider after registration of the auth server 47 | ClientId string `yaml:"client_id,omitempty"` 48 | ClientSecret string `yaml:"client_secret,omitempty"` 49 | ClientSecretFile string `yaml:"client_secret_file,omitempty"` 50 | // path where the tokendb should be stored within the container 51 | LevelTokenDB *LevelDBStoreConfig `yaml:"level_token_db,omitempty"` 52 | GCSTokenDB *GCSStoreConfig `yaml:"gcs_token_db,omitempty"` 53 | RedisTokenDB *RedisStoreConfig `yaml:"redis_token_db,omitempty"` 54 | // --- optional --- 55 | HTTPTimeout time.Duration `yaml:"http_timeout,omitempty"` 56 | // the URL of the docker registry. Used to generate a full docker login command after authentication 57 | RegistryURL string `yaml:"registry_url,omitempty"` 58 | // --- optional --- 59 | // String claim to use for the username 60 | UserClaim string `yaml:"user_claim,omitempty"` 61 | // --- optional --- 62 | // []string to add as labels. 63 | LabelsClaims []string `yaml:"labels_claims,omitempty"` 64 | // --- optional --- 65 | Scopes []string `yaml:"scopes,omitempty"` 66 | } 67 | 68 | // OIDCRefreshTokenResponse is sent by OIDC provider in response to the grant_type=refresh_token request. 69 | type OIDCRefreshTokenResponse struct { 70 | AccessToken string `json:"access_token,omitempty"` 71 | ExpiresIn int64 `json:"expires_in,omitempty"` 72 | TokenType string `json:"token_type,omitempty"` 73 | RefreshToken string `json:"refresh_token,omitempty"` 74 | 75 | // Returned in case of error. 76 | Error string `json:"error,omitempty"` 77 | ErrorDescription string `json:"error_description,omitempty"` 78 | } 79 | 80 | // The specific OIDC authenticator 81 | type OIDCAuth struct { 82 | config *OIDCAuthConfig 83 | db TokenDB 84 | client *http.Client 85 | tmpl *template.Template 86 | tmplResult *template.Template 87 | ctx context.Context 88 | provider *oidc.Provider 89 | verifier *oidc.IDTokenVerifier 90 | oauth oauth2.Config 91 | } 92 | 93 | /* 94 | Creates everything necessary for OIDC auth. 95 | */ 96 | func NewOIDCAuth(c *OIDCAuthConfig) (*OIDCAuth, error) { 97 | var db TokenDB 98 | var err error 99 | var dbName string 100 | 101 | switch { 102 | case c.GCSTokenDB != nil: 103 | db, err = NewGCSTokenDB(c.GCSTokenDB) 104 | dbName = "GCS: " + c.GCSTokenDB.Bucket 105 | case c.RedisTokenDB != nil: 106 | db, err = NewRedisTokenDB(c.RedisTokenDB) 107 | dbName = db.(*redisTokenDB).String() 108 | default: 109 | db, err = NewTokenDB(c.LevelTokenDB) 110 | dbName = c.LevelTokenDB.Path 111 | } 112 | 113 | if err != nil { 114 | return nil, err 115 | } 116 | glog.Infof("OIDC auth token DB at %s", dbName) 117 | ctx := context.Background() 118 | oidcAuth, _ := static.ReadFile("data/oidc_auth.tmpl") 119 | oidcAuthResult, _ := static.ReadFile("data/oidc_auth_result.tmpl") 120 | 121 | prov, err := oidc.NewProvider(ctx, c.Issuer) 122 | if err != nil { 123 | return nil, err 124 | } 125 | conf := oauth2.Config{ 126 | ClientID: c.ClientId, 127 | ClientSecret: c.ClientSecret, 128 | Endpoint: prov.Endpoint(), 129 | RedirectURL: c.RedirectURL, 130 | Scopes: c.Scopes, 131 | } 132 | return &OIDCAuth{ 133 | config: c, 134 | db: db, 135 | client: &http.Client{Timeout: c.HTTPTimeout}, 136 | tmpl: template.Must(template.New("oidc_auth").Parse(string(oidcAuth))), 137 | tmplResult: template.Must(template.New("oidc_auth_result").Parse(string(oidcAuthResult))), 138 | ctx: ctx, 139 | provider: prov, 140 | verifier: prov.Verifier(&oidc.Config{ClientID: conf.ClientID}), 141 | oauth: conf, 142 | }, nil 143 | } 144 | 145 | /* 146 | This function will be used by the server if the OIDC auth method is selected. It starts the page for OIDC login or 147 | requests an access token by using the code given by the OIDC provider. 148 | */ 149 | func (ga *OIDCAuth) DoOIDCAuth(rw http.ResponseWriter, req *http.Request) { 150 | code := req.URL.Query().Get("code") 151 | if code != "" { 152 | ga.doOIDCAuthCreateToken(rw, code) 153 | } else if req.Method == "GET" { 154 | ga.doOIDCAuthPage(rw) 155 | } else { 156 | http.Error(rw, "Invalid auth request", http.StatusBadRequest) 157 | } 158 | } 159 | 160 | /* 161 | Executes tmpl for the OIDC login page. 162 | */ 163 | func (ga *OIDCAuth) doOIDCAuthPage(rw http.ResponseWriter) { 164 | if err := ga.tmpl.Execute(rw, struct { 165 | AuthEndpoint, RedirectURI, ClientId, Scope string 166 | }{ 167 | AuthEndpoint: ga.provider.Endpoint().AuthURL, 168 | RedirectURI: ga.oauth.RedirectURL, 169 | ClientId: ga.oauth.ClientID, 170 | Scope: strings.Join(ga.config.Scopes, " "), 171 | }); err != nil { 172 | http.Error(rw, fmt.Sprintf("Template error: %s", err), http.StatusInternalServerError) 173 | } 174 | } 175 | 176 | /* 177 | Executes tmplResult for the result of the login process. 178 | */ 179 | func (ga *OIDCAuth) doOIDCAuthResultPage(rw http.ResponseWriter, un string, pw string) { 180 | if err := ga.tmplResult.Execute(rw, struct { 181 | Username, Password, RegistryUrl string 182 | }{ 183 | Username: un, 184 | Password: pw, 185 | RegistryUrl: ga.config.RegistryURL, 186 | }); err != nil { 187 | http.Error(rw, fmt.Sprintf("Template error: %s", err), http.StatusInternalServerError) 188 | } 189 | } 190 | 191 | /* 192 | Requests an OIDC token by using the code that was provided by the OIDC provider. If it was successfull, 193 | the access token and refresh token is used to create a new token for the users mail address, which is taken from the ID 194 | token. 195 | */ 196 | func (ga *OIDCAuth) doOIDCAuthCreateToken(rw http.ResponseWriter, code string) { 197 | 198 | tok, err := ga.oauth.Exchange(ga.ctx, code) 199 | if err != nil { 200 | http.Error(rw, fmt.Sprintf("Error talking to OIDC auth backend: %s", err), http.StatusInternalServerError) 201 | return 202 | } 203 | rawIdTok, ok := tok.Extra("id_token").(string) 204 | if !ok { 205 | http.Error(rw, "No id_token field in oauth2 token.", http.StatusInternalServerError) 206 | return 207 | } 208 | idTok, err := ga.verifier.Verify(ga.ctx, rawIdTok) 209 | if err != nil { 210 | http.Error(rw, fmt.Sprintf("Failed to verify ID token: %s", err), http.StatusInternalServerError) 211 | return 212 | } 213 | var claims map[string]interface{} 214 | if err := idTok.Claims(&claims); err != nil { 215 | http.Error(rw, fmt.Sprintf("Failed to get claims from ID token: %s", err), http.StatusInternalServerError) 216 | return 217 | } 218 | username, _ := claims[ga.config.UserClaim].(string) 219 | if username == "" { 220 | http.Error(rw, fmt.Sprintf("No %q claim in ID token", ga.config.UserClaim), http.StatusInternalServerError) 221 | return 222 | } 223 | 224 | glog.V(2).Infof("New OIDC auth token for %s (Current time: %s, expiration time: %s)", username, time.Now().String(), tok.Expiry.String()) 225 | 226 | dbVal := &TokenDBValue{ 227 | TokenType: tok.TokenType, 228 | AccessToken: tok.AccessToken, 229 | RefreshToken: tok.RefreshToken, 230 | ValidUntil: tok.Expiry.Add(time.Duration(-30) * time.Second), 231 | Labels: ga.getLabels(claims), 232 | } 233 | dp, err := ga.db.StoreToken(username, dbVal, true) 234 | if err != nil { 235 | glog.Errorf("Failed to record server token: %s", err) 236 | http.Error(rw, "Failed to record server token: %s", http.StatusInternalServerError) 237 | return 238 | } 239 | 240 | ga.doOIDCAuthResultPage(rw, username, dp) 241 | } 242 | 243 | func (ga *OIDCAuth) getLabels(claims map[string]interface{}) api.Labels { 244 | labels := make(api.Labels, len(ga.config.LabelsClaims)) 245 | for _, claim := range ga.config.LabelsClaims { 246 | values, _ := claims[claim].([]interface{}) 247 | for _, v := range values { 248 | if str, _ := v.(string); str != "" { 249 | labels[claim] = append(labels[claim], str) 250 | } 251 | } 252 | } 253 | return labels 254 | } 255 | 256 | /* 257 | Refreshes the access token of the user. Not usable with all OIDC provider, since not all provide refresh tokens. 258 | */ 259 | func (ga *OIDCAuth) refreshAccessToken(refreshToken string) (rtr OIDCRefreshTokenResponse, err error) { 260 | 261 | url := ga.provider.Endpoint().TokenURL 262 | pl := strings.NewReader(fmt.Sprintf( 263 | "grant_type=refresh_token&client_id=%s&client_secret=%s&refresh_token=%s", 264 | ga.oauth.ClientID, ga.oauth.ClientSecret, refreshToken)) 265 | req, err := http.NewRequest("POST", url, pl) 266 | if err != nil { 267 | err = fmt.Errorf("could not create refresh request: %s", err) 268 | return 269 | } 270 | req.Header.Add("content-type", "application/x-www-form-urlencoded") 271 | 272 | resp, err := ga.client.Do(req) 273 | if err != nil { 274 | err = fmt.Errorf("error talking to OIDC auth backend: %s", err) 275 | return 276 | } 277 | respStr, _ := io.ReadAll(resp.Body) 278 | glog.V(2).Infof("Refresh token resp: %s", strings.Replace(string(respStr), "\n", " ", -1)) 279 | 280 | err = json.Unmarshal(respStr, &rtr) 281 | if err != nil { 282 | err = fmt.Errorf("error in reading response of refresh request: %s", err) 283 | return 284 | } 285 | if rtr.Error != "" || rtr.ErrorDescription != "" { 286 | err = fmt.Errorf("%s: %s", rtr.Error, rtr.ErrorDescription) 287 | return 288 | } 289 | return rtr, err 290 | } 291 | 292 | /* 293 | In case the DB token is expired, this function uses the refresh token and tries to refresh the access token stored in the 294 | DB. Afterwards, checks if the access token really authenticates the user trying to log in. 295 | */ 296 | func (ga *OIDCAuth) validateServerToken(user string) (*TokenDBValue, error) { 297 | v, err := ga.db.GetValue(user) 298 | if err != nil || v == nil { 299 | if err == nil { 300 | err = errors.New("no db value, please sign out and sign in again") 301 | } 302 | return nil, err 303 | } 304 | if v.RefreshToken == "" { 305 | return nil, errors.New("refresh of your session is not possible. Please sign out and sign in again") 306 | } 307 | 308 | glog.V(2).Infof("Refreshing token for %s", user) 309 | rtr, err := ga.refreshAccessToken(v.RefreshToken) 310 | if err != nil { 311 | glog.Warningf("Failed to refresh token for %q: %s", user, err) 312 | return nil, fmt.Errorf("failed to refresh token: %s", err) 313 | } 314 | v.AccessToken = rtr.AccessToken 315 | v.ValidUntil = time.Now().Add(time.Duration(rtr.ExpiresIn-30) * time.Second) 316 | glog.Infof("Refreshed auth token for %s (exp %d)", user, rtr.ExpiresIn) 317 | _, err = ga.db.StoreToken(user, v, false) 318 | if err != nil { 319 | glog.Errorf("Failed to record refreshed token: %s", err) 320 | return nil, fmt.Errorf("failed to record refreshed token: %s", err) 321 | } 322 | tokUser, err := ga.provider.UserInfo(ga.ctx, oauth2.StaticTokenSource(&oauth2.Token{AccessToken: v.AccessToken, 323 | TokenType: v.TokenType, 324 | RefreshToken: v.RefreshToken, 325 | Expiry: v.ValidUntil, 326 | })) 327 | if err != nil { 328 | glog.Warningf("Token for %q failed validation: %s", user, err) 329 | return nil, fmt.Errorf("server token invalid: %s", err) 330 | } 331 | 332 | var claims map[string]interface{} 333 | if err := tokUser.Claims(&claims); err != nil { 334 | glog.Errorf("error retrieving claims: %v", err) 335 | return nil, fmt.Errorf("error retrieving claims: %w", err) 336 | } 337 | claimUsername, _ := claims[ga.config.UserClaim].(string) 338 | if claimUsername != user { 339 | glog.Errorf("token for wrong user: expected %s, found %s", user, claimUsername) 340 | return nil, fmt.Errorf("found token for wrong user") 341 | } 342 | texp := v.ValidUntil.Sub(time.Now()) 343 | glog.V(1).Infof("Validated OIDC auth token for %s (exp %d)", user, int(texp.Seconds())) 344 | return v, nil 345 | } 346 | 347 | /* 348 | First checks if OIDC token is valid. Then delete the corresponding DB token from the database. The user is now signed out 349 | Not deleted because maybe it will be implemented in the future. 350 | */ 351 | //func (ga *OIDCAuth) doOIDCAuthSignOut(rw http.ResponseWriter, token string) { 352 | // // Authenticate web user. 353 | // ui, err := ga.validateIDToken(token) 354 | // if err != nil || ui == ""{ 355 | // http.Error(rw, fmt.Sprintf("Could not verify user token: %s", err), http.StatusBadRequest) 356 | // return 357 | // } 358 | // err = ga.db.DeleteToken(ui) 359 | // if err != nil { 360 | // glog.Error(err) 361 | // } 362 | // fmt.Fprint(rw, "signed out") 363 | //} 364 | 365 | /* 366 | Called by server. Authenticates user with credentials that were given in the docker login command. If the token in the 367 | DB is expired, the OIDC access token is validated and, if possible, refreshed. 368 | */ 369 | func (ga *OIDCAuth) Authenticate(user string, password api.PasswordString) (bool, api.Labels, error) { 370 | err := ga.db.ValidateToken(user, password) 371 | if err == ExpiredToken { 372 | _, err = ga.validateServerToken(user) 373 | if err != nil { 374 | return false, nil, err 375 | } 376 | } else if err != nil { 377 | return false, nil, err 378 | } 379 | 380 | v, err := ga.db.GetValue(user) 381 | if err != nil || v == nil { 382 | if err == nil { 383 | err = errors.New("no db value, please sign out and sign in again") 384 | } 385 | return false, nil, err 386 | } 387 | return true, v.Labels, err 388 | } 389 | 390 | func (ga *OIDCAuth) Stop() { 391 | err := ga.db.Close() 392 | if err != nil { 393 | glog.Info("Problems at closing the token DB") 394 | } else { 395 | glog.Info("Token DB closed") 396 | } 397 | } 398 | 399 | func (ga *OIDCAuth) Name() string { 400 | return "OpenID Connect" 401 | } 402 | --------------------------------------------------------------------------------