From 1a5dbfcd0dd7cbc919247ca7bc5ca9a4f2059efc Mon Sep 17 00:00:00 2001 From: dragonflylee Date: Tue, 22 Aug 2023 12:56:35 +0800 Subject: [PATCH] initial commit Signed-off-by: dragonflylee --- .gitignore | 1 + README.md | 26 ++++++++ auth.crt | 30 ++++++++++ auth.key | 52 ++++++++++++++++ go.mod | 8 +++ go.sum | 6 ++ server.go | 90 ++++++++++++++++++++++++++++ token.go | 170 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 383 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 auth.crt create mode 100644 auth.key create mode 100644 go.mod create mode 100644 go.sum create mode 100644 server.go create mode 100644 token.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5fa1d81 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +auth diff --git a/README.md b/README.md new file mode 100644 index 0000000..a6fa187 --- /dev/null +++ b/README.md @@ -0,0 +1,26 @@ +# Docker registry + +```bash +openssl req -x509 -nodes -days 3650 -newkey rsa:4096 -keyout auth.key -out auth.crt -subj "/CN=omnibus-gitlab-issuer" + +docker run --restart always --network kind --name minio -v minio:/data \ + -e MINIO_ROOT_USER=minio -e MINIO_ROOT_PASSWORD=minio123 \ + -d minio/minio server --console-address :9090 /data + +docker run --rm --network kind -v $HOME/.mc:/root/.mc -it minio/mc alias set s3 http://minio:9000 minio minio123 +docker run --rm --network kind -v $HOME/.mc:/root/.mc -it minio/mc mb s3/registry +docker run --rm --network kind -v $HOME/.mc:/root/.mc -it minio/mc admin user add s3 registry registry123 +docker run --rm --network kind -v $HOME/.mc:/root/.mc -it minio/mc admin policy attach s3 readwrite --user registry + +docker run --restart always --network kind --name registry -v $PWD:/cert \ + -e REGISTRY_AUTH=token -e REGISTRY_AUTH_TOKEN_REALM=http://172.18.0.1:5001/auth \ + -e REGISTRY_AUTH_TOKEN_SERVICE=docker -e REGISTRY_AUTH_TOKEN_ISSUER=omnibus-gitlab-issuer \ + -e REGISTRY_AUTH_TOKEN_ROOTCERTBUNDLE=/cert/auth.crt \ + -p 0.0.0.0:5000:5000 --tmpfs /var/lib/registry -d registry:2 + +docker run --restart always --network kind --name registry --tmpfs /var/lib/registry \ + -e REGISTRY_STORAGE=s3 -e REGISTRY_STORAGE_S3_ACCESSKEY=registry -e REGISTRY_STORAGE_S3_SECRETKEY=registry123 \ + -e REGISTRY_STORAGE_S3_REGION=us-east-1 -e REGISTRY_STORAGE_S3_REGIONENDPOINT=http://minio:9000 \ + -e REGISTRY_STORAGE_S3_BUCKET=registry -e REGISTRY_STORAGE_REDIRECT_DISABLE=true \ + -p 0.0.0.0:5000:5000 -d registry:2 +``` \ No newline at end of file diff --git a/auth.crt b/auth.crt new file mode 100644 index 0000000..ad20428 --- /dev/null +++ b/auth.crt @@ -0,0 +1,30 @@ +-----BEGIN CERTIFICATE----- +MIIFITCCAwmgAwIBAgIUCKn3oDHzPwNRwVciKAuyyMeboYMwDQYJKoZIhvcNAQEL +BQAwIDEeMBwGA1UEAwwVb21uaWJ1cy1naXRsYWItaXNzdWVyMB4XDTIzMDgyMjA1 +NDcwNloXDTMzMDgxOTA1NDcwNlowIDEeMBwGA1UEAwwVb21uaWJ1cy1naXRsYWIt +aXNzdWVyMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA1MPpzW0v5dkt +YXHsh8MPqZlbQhpvOhe9n7iiDjEo3yaD6pxF1Gn/bEct4bjLG1KF1/zVZonmAoel +NqhT/lJwhcp06ML7z3e56wmk+AuSJ5KZiYeQWt9vHidI3UH7RzWHhb6hplBE7V9K +NWheGF4Rc7KFHKrQAK7Re+N8XuY1Z2MSHSG5c0xRxEpSUesVkesmU1hT0BToC9AI +dCOnoi33RByDBMeLf7nuKtCo5qIhBiTl1uusfDA6rGojtw085p0C32LfpzVPQGkD +lXzOp97iXP2Z3GpUuXXvOjBuxpzvUQDcWiPN8DqjWGCFgU6WrxP3a85oAyTnqnBS +SzTcD2VmHcA8SwhfWFgap4A872wRQ8AMVM+6y5LUoadJ/Cp03fy6WBZrqZXAsBBn +Sc3s18LAHuYEPSxQd/dpCbLwMzuUFnYgDUfezS42pFrJcg+l2+sgbKVBBlSKDvKj +8bz32RQ7HC28XACmi2uTMKhRDHJEoMljy4VqQ1M31h/KTHP2RfQQ0S/FW2LCIiVM +BRnzMbUoT+HC6IzPboRwf3LUUe+RRw7UXjJrLLAuoevs/OLFjNkRuVHdUtQQaWHF +ImpkKsiylWhtjrUjhV+fyPKXLnVH0KZS9svrqPoymmGgZmC3TmvQjX/gEf1DVrdx +oDcREztOc+cA2mNRBuvlBwDUOWrkWF8CAwEAAaNTMFEwHQYDVR0OBBYEFE32q/qx +88lPURQuhtu1VI3sxzK0MB8GA1UdIwQYMBaAFE32q/qx88lPURQuhtu1VI3sxzK0 +MA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggIBABBLgiL0IKq2Jdt7 +H6yWG70r4aLmgOFrnxwGZjRAVS+1pJh3MiWvlqyNOjaLwHX1WxtgAQm5dsVL9H1m +O1b1G5UrtP25192h/PzsgSP/qejSIfXAkHUPloeDzKPuJmkCNAkd+AGnVXqB1hF7 +ngrUyCMt3Ed7gny94ImQf6DVhcq8IvsddoOq+2UFPx9fGe7Xd882NzSZbi6kEmsv +Yb28ovYi6Gsjp9o93VjTa9umtx2Kagn/+TNI5rK+bGp+lNLiTk7XkVt70pS37Fas +eIL4IZ6pwG/G7e1MCSJkGLuXODY5ebaMj3MYn6xbJA1KsozvFv6Hmy+s8upfWYGS +PndK/4UIcroy+zbuwPPlsoHorFiLCUW9+ThXIZ+062ahz+CX/2ETo8FpYToOhlDH +cHKddWtx50Hl0yrJdSiq/RmKWDtspHlY5fESjbdNQauuxSrNlG+k9pq2t+jc+Mk3 +L1Yj1aFZIMie8NAmRLqC5XIhAKALqk0gPb0vpcvpZd1xs45OB/FO846z/oWnolsp +qgeRTxUTbpLlmsYYGB4VXTepakf0F4opPnrUoxVDbT6T3uDde9N9nWN/PemCs4Mv +8WNxs6WYi2/K5InVGrZygaLZVO4xThSWLq53cPDRZ2LPyRgRTIB8qHjc9M1X1MFY +UNAqtEZGKvCSxfZ1UfUnTBw6HJgV +-----END CERTIFICATE----- diff --git a/auth.key b/auth.key new file mode 100644 index 0000000..59a804c --- /dev/null +++ b/auth.key @@ -0,0 +1,52 @@ +-----BEGIN PRIVATE KEY----- +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 +s51eargpxc9q7tj4V1bt3njoJ86V +-----END PRIVATE KEY----- diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d054cec --- /dev/null +++ b/go.mod @@ -0,0 +1,8 @@ +module auth + +go 1.16 + +require ( + github.com/golang-jwt/jwt v3.2.2+incompatible + github.com/gorilla/handlers v1.5.1 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..3e05288 --- /dev/null +++ b/go.sum @@ -0,0 +1,6 @@ +github.com/felixge/httpsnoop v1.0.1 h1:lvB5Jl89CsZtGIWuTcDM1E/vkVs49/Ml7JJe07l8SPQ= +github.com/felixge/httpsnoop v1.0.1/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= +github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= +github.com/gorilla/handlers v1.5.1 h1:9lRY6j8DEeeBT10CvO9hGW0gmky0BprnvDI5vfhUHH4= +github.com/gorilla/handlers v1.5.1/go.mod h1:t8XrUpc4KVXb7HGyJ4/cEnwQiaxrX/hz1Zv/4g96P1Q= diff --git a/server.go b/server.go new file mode 100644 index 0000000..d7815b0 --- /dev/null +++ b/server.go @@ -0,0 +1,90 @@ +package main + +import ( + "crypto/tls" + "encoding/json" + "flag" + "fmt" + "io" + "log" + "net/http" + "os" + "time" + + "github.com/gorilla/handlers" +) + +var ( + addr string + ti TokenIssuer +) + +func main() { + flag.StringVar(&addr, "addr", ":5001", "address") + flag.StringVar(&ti.Issuer, "issuer", "omnibus-gitlab-issuer", "issuer name") + flag.StringVar(&ti.Audience, "audience", "docker", "audience name") + flag.Int64Var(&ti.Expiration, "expires", 900, "expiration") + flag.Parse() + + log.SetFlags(log.LstdFlags | log.Lshortfile) + + cert, err := tls.LoadX509KeyPair("auth.crt", "auth.key") + if err != nil { + log.Fatal(err) + } + ti.SigningKey = cert.PrivateKey + + mux := http.NewServeMux() + mux.HandleFunc("/auth", func(w http.ResponseWriter, r *http.Request) { + var resp struct { + Token string `json:"access_token"` + RefreshToken string `json:"refresh_token,omitempty"` + ExpiresIn int64 `json:"expires_in,omitempty"` + } + + user, _, ok := r.BasicAuth() + if !ok { + w.Header().Set("WWW-Authenticate", "Basic realm=Restricted") + w.WriteHeader(http.StatusUnauthorized) + return + } + + authRequest := ResolveScopeList(r.URL.Query().Get("scope")) + if len(authRequest) == 0 { + // Authentication-only request ("docker login"), pass through. + resp.Token, _ = ti.CreateJWT(user, authRequest) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(&resp) + return + } + + authResult, err := Authorized(user, authRequest) + if err != nil { + w.WriteHeader(http.StatusUnauthorized) + return + } + + if resp.Token, err = ti.CreateJWT(user, authResult); err != nil { + log.Printf("CreateJWT %v", err) + w.WriteHeader(http.StatusUnauthorized) + return + } + + resp.ExpiresIn = ti.Expiration + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(&resp) + }) + + h := handlers.CustomLoggingHandler(os.Stdout, mux, func(w io.Writer, p handlers.LogFormatterParams) { + fmt.Fprintf(w, "%s %s %d %s %d %s (%s)\n", p.TimeStamp.Format("2006/01/02 15:04:05"), + p.Request.Method, p.StatusCode, p.URL.RequestURI(), p.Size, + p.Request.RemoteAddr, time.Since(p.TimeStamp)) + }) + + log.Fatal(http.ListenAndServe(addr, h)) +} + +func Authorized(user string, actions []ResourceActions) ([]ResourceActions, error) { + return actions, nil +} diff --git a/token.go b/token.go new file mode 100644 index 0000000..c5c8f7c --- /dev/null +++ b/token.go @@ -0,0 +1,170 @@ +package main + +import ( + "bytes" + "crypto" + "crypto/ecdsa" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base32" + "encoding/base64" + "fmt" + "io" + "regexp" + "sort" + "strings" + "time" + + "github.com/golang-jwt/jwt" +) + +// Resource describes a resource by type and name. +type Resource struct { + Type string + Class string + Name string +} + +// ResourceActions stores allowed actions on a named and typed resource. +type ResourceActions struct { + Type string `json:"type"` + Class string `json:"class,omitempty"` + Name string `json:"name"` + Actions []string `json:"actions"` +} + +// ClaimSet describes the main section of a JSON Web Token. +type ClaimSet struct { + jwt.StandardClaims + // Private claims + Access []ResourceActions `json:"access"` +} + +// ResolveScopeList converts a scope list from a token request's +// `scope` parameter into a list of standard access objects. +func ResolveScopeList(scopeList string) []ResourceActions { + scopeSpecs := strings.Split(scopeList, " ") + accessSet := make(map[Resource]map[string]bool, 2*len(scopeSpecs)) + + for _, scopeSpecifier := range scopeSpecs { + // There should be 3 parts, separated by a `:` character. + parts := strings.SplitN(scopeSpecifier, ":", 3) + if len(parts) != 3 { + continue + } + + resourceType, resourceName, actions := parts[0], parts[1], parts[2] + resourceType, resourceClass := splitResourceClass(resourceType) + if resourceType == "" { + continue + } + + requestedResource := Resource{ + Type: resourceType, + Class: resourceClass, + Name: resourceName, + } + + requestedAction, has := accessSet[requestedResource] + if !has { + requestedAction = make(map[string]bool, 2) + accessSet[requestedResource] = requestedAction + } + // Actions should be a comma-separated list of actions. + for _, action := range strings.Split(actions, ",") { + requestedAction[action] = true + } + } + + requestedList := make([]ResourceActions, 0, len(accessSet)) + for resource, actions := range accessSet { + ra := ResourceActions{ + Name: resource.Name, + Class: resource.Class, + Type: resource.Type, + } + for action := range actions { + ra.Actions = append(ra.Actions, action) + } + sort.Strings(ra.Actions) + requestedList = append(requestedList, ra) + } + return requestedList +} + +var typeRegexp = regexp.MustCompile(`^([a-z0-9]+)(\([a-z0-9]+\))?$`) + +func splitResourceClass(t string) (string, string) { + matches := typeRegexp.FindStringSubmatch(t) + if len(matches) < 2 { + return "", "" + } + if len(matches) == 2 || len(matches[2]) < 2 { + return matches[1], "" + } + return matches[1], matches[2][1 : len(matches[2])-1] +} + +// TokenIssuer represents an issuer capable of generating JWT tokens +type TokenIssuer struct { + Issuer string + Audience string + SigningKey crypto.PrivateKey + Expiration int64 // second +} + +// CreateJWT creates and signs a JSON Web Token for the given subject and +// audience with the granted access. +func (issuer *TokenIssuer) CreateJWT(account string, grantedAccessList []ResourceActions) (string, error) { + randomBytes := make([]byte, 15) + if _, err := io.ReadFull(rand.Reader, randomBytes); err != nil { + return "", err + } + randomID := base64.URLEncoding.EncodeToString(randomBytes) + + now := time.Now().Unix() + token := ClaimSet{ + StandardClaims: jwt.StandardClaims{ + Subject: account, + Issuer: issuer.Issuer, + Audience: issuer.Audience, + ExpiresAt: now + issuer.Expiration, + NotBefore: now, + IssuedAt: now, + Id: randomID, + }, + Access: grantedAccessList, + } + + var jwtToken *jwt.Token + switch key := issuer.SigningKey.(type) { + case *rsa.PrivateKey: + jwtToken = jwt.NewWithClaims(jwt.SigningMethodRS256, token) + jwtToken.Header["kid"] = keyIDEncode(key.Public()) + case *ecdsa.PrivateKey: + jwtToken = jwt.NewWithClaims(jwt.SigningMethodES256, token) + jwtToken.Header["kid"] = keyIDEncode(key.Public()) + default: + return "", fmt.Errorf("unable to get PrivateKey %T", issuer.SigningKey) + } + + return jwtToken.SignedString(issuer.SigningKey) +} + +func keyIDEncode(pub crypto.PublicKey) string { + derBytes, _ := x509.MarshalPKIXPublicKey(pub) + sum := sha256.Sum256(derBytes) + s := strings.TrimRight(base32.StdEncoding.EncodeToString(sum[:30]), "=") + + var buf bytes.Buffer + var i int + for i = 0; i < len(s)/4-1; i++ { + start := i * 4 + end := start + 4 + buf.WriteString(s[start:end] + ":") + } + buf.WriteString(s[i*4:]) + return buf.String() +}