commit 1a5dbfcd0dd7cbc919247ca7bc5ca9a4f2059efc Author: dragonflylee Date: Tue Aug 22 12:56:35 2023 +0800 initial commit Signed-off-by: dragonflylee 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() +}