diff --git a/Makefile b/Makefile index acef9d6..3bb7760 100644 --- a/Makefile +++ b/Makefile @@ -135,7 +135,7 @@ qtest: covdir @#time richgo test $(VERBOSE) $(TEST) -coverprofile=.coverage/coverage.out -run TestNewJwksKeyFromRSAPublicKeyPEM ./pkg/idp/oauth/*.go @#time richgo test $(VERBOSE) $(TEST) -coverprofile=.coverage/coverage.out -run TestNewIdentityProviderConfig ./pkg/idp/*.go @#time richgo test $(VERBOSE) $(TEST) -coverprofile=.coverage/coverage.out ./pkg/authn/ui/... - @time richgo test $(VERBOSE) $(TEST) -coverprofile=.coverage/coverage.out ./pkg/ids/... + @#time richgo test $(VERBOSE) $(TEST) -coverprofile=.coverage/coverage.out ./pkg/ids/... @#time richgo test $(VERBOSE) $(TEST) -coverprofile=.coverage/coverage.out ./pkg/ids/local/*.go @#time richgo test $(VERBOSE) $(TEST) -coverprofile=.coverage/coverage.out ./pkg/ids/ldap/*.go @#time richgo test $(VERBOSE) $(TEST) -coverprofile=.coverage/coverage.out ./pkg/authz/... @@ -152,7 +152,7 @@ qtest: covdir @#time richgo test $(VERBOSE) $(TEST) -coverprofile=.coverage/coverage.out ./pkg/authproxy/... @#time richgo test $(VERBOSE) $(TEST) -coverprofile=.coverage/coverage.out ./pkg/identity/... @#time richgo test $(VERBOSE) $(TEST) -coverprofile=.coverage/coverage.out ./pkg/authn/backends/... - @#time richgo test $(VERBOSE) $(TEST) -coverprofile=.coverage/coverage.out -run NewAPIKey ./pkg/identity/... + @time richgo test $(VERBOSE) $(TEST) -coverprofile=.coverage/coverage.out -run NewPublicKey ./pkg/identity/... @# @go tool cover -html=.coverage/coverage.out -o .coverage/coverage.html; @#go tool cover -func=.coverage/coverage.out | grep -v "100.0" diff --git a/pkg/authn/api_add_user_ssh_key.go b/pkg/authn/api_add_user_ssh_key.go new file mode 100644 index 0000000..f112d7f --- /dev/null +++ b/pkg/authn/api_add_user_ssh_key.go @@ -0,0 +1,118 @@ +// Copyright 2024 Paul Greenberg greenpau@outlook.com +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package authn + +import ( + "context" + "fmt" + "net/http" + "regexp" + "strings" + + "github.com/greenpau/go-authcrunch/pkg/authn/enums/operator" + "github.com/greenpau/go-authcrunch/pkg/ids" + "github.com/greenpau/go-authcrunch/pkg/requests" + "github.com/greenpau/go-authcrunch/pkg/tagging" + "github.com/greenpau/go-authcrunch/pkg/user" +) + +var sshKeyTitleRegexPattern = regexp.MustCompile(`^[\w\@\.\s\(\)]+$`) + +// AddUserSSHKey adds SSH key to user identity. +func (p *Portal) AddUserSSHKey( + ctx context.Context, + w http.ResponseWriter, + r *http.Request, + rr *requests.Request, + parsedUser *user.User, + resp map[string]interface{}, + usr *user.User, + backend ids.IdentityStore, + bodyData map[string]interface{}) error { + + var keyTitle, keyDescription, keyPayload string + var keyLabels []string = []string{} + var keyTags []tagging.Tag = []tagging.Tag{} + + // Extract data. + if v, exists := bodyData["content"]; exists { + switch exp := v.(type) { + case string: + keyPayload = strings.TrimSpace(exp) + default: + resp["message"] = "Profile API did find key content in the request payload, but it is malformed" + return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) + } + } else { + resp["message"] = "Profile API did not find key content in the request payload" + return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) + } + if v, exists := bodyData["title"]; exists { + keyTitle = v.(string) + } else { + resp["message"] = "Profile API did not find title in the request payload" + return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) + } + if v, exists := bodyData["description"]; exists { + keyDescription = v.(string) + } else { + resp["message"] = "Profile API did not find description in the request payload" + return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) + } + if extractedTokenTags, err := tagging.ExtractTags(bodyData); err == nil { + for _, extractedTokenTag := range extractedTokenTags { + keyTags = append(keyTags, *extractedTokenTag) + } + } else { + resp["message"] = "Profile API find malformed tags in the request payload" + return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) + } + if extractedTokenLabels, err := tagging.ExtractLabels(bodyData); err == nil { + keyLabels = extractedTokenLabels + } else { + resp["message"] = "Profile API find malformed tags in the request payload" + return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) + } + + // Validate data. + if !sshKeyTitleRegexPattern.MatchString(keyTitle) { + resp["message"] = "Profile API found non-compliant SSH key title value" + return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) + } + if !tokenDescriptionRegexPattern.MatchString(keyDescription) && (keyDescription != "") { + resp["message"] = "Profile API found non-compliant SSH key description value" + return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) + } + if !sshKeyRegexPattern1.MatchString(keyPayload) && !sshKeyRegexPattern2.MatchString(keyPayload) { + resp["message"] = "Profile API found non-compliant SSH public key value" + return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) + } + + rr.Key.Usage = "ssh" + rr.Key.Comment = keyTitle + rr.Key.Description = keyDescription + rr.Key.Payload = keyPayload + rr.Key.Labels = keyLabels + rr.Key.Tags = keyTags + + if err := backend.Request(operator.AddKeySSH, rr); err != nil { + var errMsg string = fmt.Sprintf("the Profile API failed to add SSH key to identity store: %v", err) + resp["message"] = errMsg + return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) + } + + resp["entry"] = "Created" + return handleAPIProfileResponse(w, rr, http.StatusOK, resp) +} diff --git a/pkg/authn/api_fetch_user_api_keys.go b/pkg/authn/api_fetch_user_api_keys.go index 6f9ecc8..a385c32 100644 --- a/pkg/authn/api_fetch_user_api_keys.go +++ b/pkg/authn/api_fetch_user_api_keys.go @@ -42,7 +42,13 @@ func (p *Portal) FetchUserAPIKeys( return handleAPIProfileResponse(w, rr, http.StatusInternalServerError, resp) } bundle := rr.Response.Payload.(*identity.APIKeyBundle) - resp["entries"] = bundle.Get() + + apiKeys := bundle.Get() + for _, apiKey := range apiKeys { + apiKey.Payload = "" + apiKey.Prefix = "" + } + resp["entries"] = apiKeys return handleAPIProfileResponse(w, rr, http.StatusOK, resp) } diff --git a/pkg/authn/api_fetch_user_multi_factor_authenticators.go b/pkg/authn/api_fetch_user_multi_factor_authenticators.go index 5301ded..b3a9a9e 100644 --- a/pkg/authn/api_fetch_user_multi_factor_authenticators.go +++ b/pkg/authn/api_fetch_user_multi_factor_authenticators.go @@ -42,7 +42,12 @@ func (p *Portal) FetchUserMultiFactorVerifiers( return handleAPIProfileResponse(w, rr, http.StatusInternalServerError, resp) } bundle := rr.Response.Payload.(*identity.MfaTokenBundle) - resp["entries"] = bundle.Get() + + tokens := bundle.Get() + for _, token := range tokens { + token.Secret = "" + } + resp["entries"] = tokens return handleAPIProfileResponse(w, rr, http.StatusOK, resp) } diff --git a/pkg/authn/api_fetch_user_ssh_keys.go b/pkg/authn/api_fetch_user_ssh_keys.go index a6de5d5..66ac021 100644 --- a/pkg/authn/api_fetch_user_ssh_keys.go +++ b/pkg/authn/api_fetch_user_ssh_keys.go @@ -42,7 +42,13 @@ func (p *Portal) FetchUserSSHKeys( return handleAPIProfileResponse(w, rr, http.StatusInternalServerError, resp) } bundle := rr.Response.Payload.(*identity.PublicKeyBundle) - resp["entries"] = bundle.Get() + sshKeys := bundle.Get() + for _, sshKey := range sshKeys { + sshKey.Payload = "" + sshKey.OpenSSH = "" + } + resp["entries"] = sshKeys + return handleAPIProfileResponse(w, rr, http.StatusOK, resp) } diff --git a/pkg/authn/api_test_user_ssh_key.go b/pkg/authn/api_test_user_ssh_key.go new file mode 100644 index 0000000..87542d9 --- /dev/null +++ b/pkg/authn/api_test_user_ssh_key.go @@ -0,0 +1,90 @@ +// Copyright 2024 Paul Greenberg greenpau@outlook.com +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package authn + +import ( + "context" + "fmt" + "net/http" + "regexp" + + "github.com/greenpau/go-authcrunch/pkg/identity" + "github.com/greenpau/go-authcrunch/pkg/ids" + "github.com/greenpau/go-authcrunch/pkg/requests" + "github.com/greenpau/go-authcrunch/pkg/user" +) + +var sshKeyRegexPattern1 = regexp.MustCompile(`^ssh-[a-z]+\s*[A-z0-9\+\/\=\n]+\s*`) +var sshKeyRegexPattern2 = regexp.MustCompile(`^[-]{3,5}\s*BEGIN\s[A-Z0-9]+\sPUBLIC\sKEY[-]{3,5}\s*`) + +// TestUserSSHKey tests SSH key. +func (p *Portal) TestUserSSHKey( + ctx context.Context, + w http.ResponseWriter, + r *http.Request, + rr *requests.Request, + parsedUser *user.User, + resp map[string]interface{}, + usr *user.User, + backend ids.IdentityStore, + bodyData map[string]interface{}) error { + + rr.Key.Usage = "ssh" + + // Extract data. + if v, exists := bodyData["content"]; exists { + switch keyContent := v.(type) { + case string: + rr.Key.Payload = keyContent + default: + resp["message"] = "Profile API did find key content in the request payload, but it is malformed" + return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) + } + } else { + resp["message"] = "Profile API did not find key content in the request payload" + return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) + } + + // Validate data. + if !sshKeyRegexPattern1.MatchString(rr.Key.Payload) && !sshKeyRegexPattern2.MatchString(rr.Key.Payload) { + resp["message"] = "Profile API found non-compliant SSH public key value" + return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) + } + + pk, err := identity.NewPublicKey(rr) + if err != nil { + var errMsg string = fmt.Sprintf("the Profile API failed to convert input into SSH key: %v", err) + resp["message"] = errMsg + return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) + } + + respData := make(map[string]interface{}) + if pk != nil { + respData["success"] = true + if pk.FingerprintMD5 != "" { + respData["fingerprint_md5"] = pk.FingerprintMD5 + } + if pk.Fingerprint != "" { + respData["fingerprint"] = pk.Fingerprint + } + if pk.Comment != "" { + respData["comment"] = pk.Comment + } + } else { + respData["success"] = false + } + resp["entry"] = respData + return handleAPIProfileResponse(w, rr, http.StatusOK, resp) +} diff --git a/pkg/authn/handle_api_profile.go b/pkg/authn/handle_api_profile.go index de2e5d4..661a16f 100644 --- a/pkg/authn/handle_api_profile.go +++ b/pkg/authn/handle_api_profile.go @@ -108,11 +108,13 @@ func (p *Portal) handleAPIProfile(ctx context.Context, w http.ResponseWriter, r case "fetch_user_ssh_keys": case "fetch_user_ssh_key": case "delete_user_ssh_key": + case "test_user_ssh_key": + case "add_user_ssh_key": case "fetch_user_gpg_keys": case "fetch_user_gpg_key": case "delete_user_gpg_key": default: - resp["message"] = "Profile API recieved unsupported request type" + resp["message"] = "Profile API received unsupported request type" return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) } @@ -182,6 +184,10 @@ func (p *Portal) handleAPIProfile(ctx context.Context, w http.ResponseWriter, r return p.FetchUserSSHKey(ctx, w, r, rr, parsedUser, resp, usr, backend, bodyData) case "delete_user_ssh_key": return p.DeleteUserSSHKey(ctx, w, r, rr, parsedUser, resp, usr, backend, bodyData) + case "test_user_ssh_key": + return p.TestUserSSHKey(ctx, w, r, rr, parsedUser, resp, usr, backend, bodyData) + case "add_user_ssh_key": + return p.AddUserSSHKey(ctx, w, r, rr, parsedUser, resp, usr, backend, bodyData) case "fetch_user_gpg_keys": return p.FetchUserGPGKeys(ctx, w, r, rr, parsedUser, resp, usr, backend) case "fetch_user_gpg_key": diff --git a/pkg/identity/public_key.go b/pkg/identity/public_key.go index b40a505..2c46246 100644 --- a/pkg/identity/public_key.go +++ b/pkg/identity/public_key.go @@ -259,6 +259,7 @@ func (p *PublicKey) parsePublicKeyRSA() error { if p.Usage != "ssh" { return errors.ErrPublicKeyUsagePayloadMismatch.WithArgs(p.Usage) } + block, _ := pem.Decode(bytes.TrimSpace([]byte(p.Payload))) if block == nil { return errors.ErrPublicKeyBlockType.WithArgs("") @@ -266,10 +267,12 @@ func (p *PublicKey) parsePublicKeyRSA() error { if block.Type != "RSA PUBLIC KEY" { return errors.ErrPublicKeyBlockType.WithArgs(block.Type) } - publicKeyInterface, err := x509.ParsePKIXPublicKey(block.Bytes) + + publicKeyInterface, err := x509.ParsePKCS1PublicKey(block.Bytes) if err != nil { return errors.ErrPublicKeyParse.WithArgs(err) } + publicKey, err := ssh.NewPublicKey(publicKeyInterface) if err != nil { return fmt.Errorf("failed ssh.NewPublicKey: %s", err) diff --git a/pkg/identity/public_key_test.go b/pkg/identity/public_key_test.go index fd9b803..10af3bf 100644 --- a/pkg/identity/public_key_test.go +++ b/pkg/identity/public_key_test.go @@ -22,13 +22,14 @@ import ( "crypto/x509" "encoding/pem" "fmt" + "os" + "strings" + "testing" + "github.com/greenpau/go-authcrunch/internal/tests" "github.com/greenpau/go-authcrunch/pkg/errors" "github.com/greenpau/go-authcrunch/pkg/requests" "golang.org/x/crypto/ssh" - "os" - "strings" - "testing" ) func readPEMFile(fp string) string { @@ -50,31 +51,34 @@ func readPEMFile(fp string) string { } func getPublicKey(t *testing.T, pk *rsa.PrivateKey, keyType string) string { - // Derive Public Key - pubKeyBytes, err := x509.MarshalPKIXPublicKey(pk.Public()) - if err != nil { - t.Fatalf("failed creating rsa public key: %v", err) - } - - // Create PEM encoded string - pubKeyEncoded := pem.EncodeToMemory( - &pem.Block{ - Type: "RSA PUBLIC KEY", - Bytes: pubKeyBytes, - }, - ) - - // Create OpenSSH formatted string - pubKeyOpenSSH, err := ssh.NewPublicKey(pk.Public()) - if err != nil { - t.Fatalf("failed creating openssh key: %v", err) - } - authorizedKeyBytes := ssh.MarshalAuthorizedKey(pubKeyOpenSSH) switch keyType { case "openssh": + // Create OpenSSH formatted string + pubKeyOpenSSH, err := ssh.NewPublicKey(pk.Public()) + if err != nil { + t.Fatalf("failed creating openssh key: %v", err) + } + authorizedKeyBytes := ssh.MarshalAuthorizedKey(pubKeyOpenSSH) return string(authorizedKeyBytes) + case "rsa": + switch pubKey := pk.Public().(type) { + case *rsa.PublicKey: + pubKeyBytes := x509.MarshalPKCS1PublicKey(pubKey) + // Create PEM encoded string + pubKeyEncoded := pem.EncodeToMemory( + &pem.Block{ + Type: "RSA PUBLIC KEY", + Bytes: pubKeyBytes, + }, + ) + return string(pubKeyEncoded) + default: + t.Fatalf("unsupported key type: %s", keyType) + } + default: + t.Fatalf("unsupported key type: %s", keyType) } - return string(pubKeyEncoded) + return "" } func TestNewPublicKey(t *testing.T) { @@ -222,11 +226,11 @@ func TestNewPublicKey(t *testing.T) { } else { msgs = append(msgs, fmt.Sprintf("payload:\n%s", string(tc.req.Key.Payload))) } - // t.Logf("public key:\n%s", tc.req.Key.Payload) if tc.req.Key.Payload == "rsa" || tc.req.Key.Payload == "openssh" { tc.req.Key.Payload = getPublicKey(t, pk, tc.req.Key.Payload) } + // t.Logf("public key:\n%s", tc.req.Key.Payload) key, err := NewPublicKey(tc.req) if tests.EvalErrWithLog(t, err, "new public key", tc.shouldErr, tc.err, msgs) { diff --git a/pkg/identity/user.go b/pkg/identity/user.go index b127f57..12966eb 100644 --- a/pkg/identity/user.go +++ b/pkg/identity/user.go @@ -385,7 +385,7 @@ func (user *User) AddPublicKey(r *requests.Request) error { if k.Type != key.Type { continue } - if k.Fingerprint != key.Fingerprint { + if (k.Fingerprint != key.Fingerprint) && (k.FingerprintMD5 != key.FingerprintMD5) { continue } return errors.ErrAddPublicKey.WithArgs(r.Key.Usage, "already exists")