From f2c2b5d8653e5d357d938bb149ec220cf4cab1ec Mon Sep 17 00:00:00 2001 From: Paul Greenberg Date: Mon, 18 Mar 2024 17:51:54 -0400 Subject: [PATCH] misc: refactor profile api functions --- Makefile | 5 +- internal/tests/data.go | 39 ++ internal/tests/data_test.go | 92 ++++ ...add_user_app_multi_factor_authenticator.go | 153 ++++++ ..._delete_user_multi_factor_authenticator.go | 54 +++ ...ser_app_multi_factor_authenticator_code.go | 123 +++++ pkg/authn/api_fetch_user_dashboard_data.go | 129 +++++ ..._fetch_user_multi_factor_authenticators.go | 76 +++ ...est_user_app_multi_factor_authenticator.go | 128 +++++ pkg/authn/enums/operator/operator.go | 4 + pkg/authn/handle_api_profile.go | 457 +----------------- pkg/errors/mfa_token.go | 1 + pkg/identity/database.go | 21 + pkg/identity/mfa_token.go | 2 + pkg/ids/local/authenticator.go | 12 +- pkg/ids/local/store.go | 3 + pkg/ids/local/store_test.go | 9 +- pkg/tagging/tag.go | 105 ++++ pkg/tagging/tag_test.go | 231 ++++++--- 19 files changed, 1141 insertions(+), 503 deletions(-) create mode 100644 internal/tests/data.go create mode 100644 internal/tests/data_test.go create mode 100644 pkg/authn/api_add_user_app_multi_factor_authenticator.go create mode 100644 pkg/authn/api_delete_user_multi_factor_authenticator.go create mode 100644 pkg/authn/api_fetch_user_app_multi_factor_authenticator_code.go create mode 100644 pkg/authn/api_fetch_user_dashboard_data.go create mode 100644 pkg/authn/api_fetch_user_multi_factor_authenticators.go create mode 100644 pkg/authn/api_test_user_app_multi_factor_authenticator.go diff --git a/Makefile b/Makefile index b6069ac..acef9d6 100644 --- a/Makefile +++ b/Makefile @@ -101,6 +101,7 @@ clean: .PHONY: qtest qtest: covdir @echo "Perform quick tests ..." + @#time richgo test $(VERBOSE) $(TEST) -coverprofile=.coverage/coverage.out ./pkg/tagging/... @#time richgo test -v -coverprofile=.coverage/coverage.out internal/tag/*.go @#time richgo test -v -coverprofile=.coverage/coverage.out internal/testutils/*.go @#time richgo test $(VERBOSE) $(TEST) -coverprofile=.coverage/coverage.out ./pkg/util/data/... @@ -126,7 +127,7 @@ qtest: covdir @#time richgo test $(VERBOSE) $(TEST) -coverprofile=.coverage/coverage.out -run TestValidateJwksKey ./pkg/authn/backends/oauth2/jwks*.go @#time richgo test $(VERBOSE) $(TEST) -coverprofile=.coverage/coverage.out -run TestTransformData ./pkg/authn/transformer/*.go @#time richgo test $(VERBOSE) $(TEST) -coverprofile=.coverage/coverage.out ./pkg/authn/transformer/*.go - @time richgo test $(VERBOSE) $(TEST) -coverprofile=.coverage/coverage.out ./pkg/redirects/*.go + @#time richgo test $(VERBOSE) $(TEST) -coverprofile=.coverage/coverage.out ./pkg/redirects/*.go @#time richgo test $(VERBOSE) $(TEST) -coverprofile=.coverage/coverage.out ./pkg/authn/icons/... @#time richgo test $(VERBOSE) $(TEST) -coverprofile=.coverage/coverage.out ./pkg/idp/... @#time richgo test $(VERBOSE) $(TEST) -coverprofile=.coverage/coverage.out ./pkg/idp/saml/*.go @@ -134,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/... diff --git a/internal/tests/data.go b/internal/tests/data.go new file mode 100644 index 0000000..cd05fed --- /dev/null +++ b/internal/tests/data.go @@ -0,0 +1,39 @@ +// 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 tests + +import ( + "encoding/json" +) + +// UnpackDict unpacks interface into a map. +func UnpackDict(i interface{}) (map[string]interface{}, error) { + var m map[string]interface{} + switch v := i.(type) { + case string: + if err := json.Unmarshal([]byte(v), &m); err != nil { + return nil, err + } + default: + b, err := json.Marshal(i) + if err != nil { + return nil, err + } + if err := json.Unmarshal(b, &m); err != nil { + return nil, err + } + } + return m, nil +} diff --git a/internal/tests/data_test.go b/internal/tests/data_test.go new file mode 100644 index 0000000..f472453 --- /dev/null +++ b/internal/tests/data_test.go @@ -0,0 +1,92 @@ +// Copyright 2022 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 tests + +import ( + "fmt" + + "testing" +) + +func TestUnpackDict(t *testing.T) { + + testcases := []struct { + name string + input interface{} + want map[string]interface{} + shouldErr bool + err error + disabled bool + }{ + { + name: "test unpack json string", + disabled: false, + input: `{ + "foo": { + "bar": "baz" + } + }`, + want: map[string]interface{}{ + "foo": map[string]interface{}{ + "bar": "baz", + }, + }, + }, + { + name: "test malformed json string", + disabled: false, + input: `{ + { + }`, + shouldErr: true, + err: fmt.Errorf("invalid character '{' looking for beginning of object key string"), + }, + { + name: "test unpack map", + disabled: false, + input: map[string]interface{}{ + "foo": map[string]interface{}{ + "bar": "baz", + }, + }, + want: map[string]interface{}{ + "foo": map[string]interface{}{ + "bar": "baz", + }, + }, + }, + { + name: "test unpack non map", + disabled: false, + input: 123, + shouldErr: true, + err: fmt.Errorf("json: cannot unmarshal number into Go value of type map[string]interface {}"), + }, + } + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + if tc.disabled { + return + } + msgs := []string{fmt.Sprintf("test name: %s", tc.name)} + msgs = append(msgs, fmt.Sprintf("input:\n%v", tc.input)) + got, err := UnpackDict(tc.input) + if EvalErrWithLog(t, err, "UnpackDict", tc.shouldErr, tc.err, msgs) { + return + } + EvalObjectsWithLog(t, "UnpackDict", tc.want, got, msgs) + }) + } +} diff --git a/pkg/authn/api_add_user_app_multi_factor_authenticator.go b/pkg/authn/api_add_user_app_multi_factor_authenticator.go new file mode 100644 index 0000000..fdef7eb --- /dev/null +++ b/pkg/authn/api_add_user_app_multi_factor_authenticator.go @@ -0,0 +1,153 @@ +// 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" + "encoding/json" + "net/http" + + "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" +) + +// AddUserAppMultiFactorVerifier adds app multi factor authenticator to user identity. +func (p *Portal) AddUserAppMultiFactorVerifier( + 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 tokenTitle, tokenDescription, tokenSecret string + var tokenLifetime, tokenDigits int + var tokenLabels []string = []string{} + var tokenTags []tagging.Tag = []tagging.Tag{} + + // Extract data. + if v, exists := bodyData["period"]; exists { + switch exp := v.(type) { + case float64: + tokenLifetime = int(exp) + case int: + tokenLifetime = exp + case int64: + tokenLifetime = int(exp) + case json.Number: + i, _ := exp.Int64() + tokenLifetime = int(i) + } + } else { + resp["message"] = "Profile API did not find period in the request payload" + return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) + } + if v, exists := bodyData["digits"]; exists { + switch exp := v.(type) { + case float64: + tokenDigits = int(exp) + case int: + tokenDigits = exp + case int64: + tokenDigits = int(exp) + case json.Number: + i, _ := exp.Int64() + tokenDigits = int(i) + } + } else { + resp["message"] = "Profile API did not find digits in the request payload" + return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) + } + if v, exists := bodyData["title"]; exists { + tokenTitle = 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 { + tokenDescription = v.(string) + } else { + resp["message"] = "Profile API did not find description in the request payload" + return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) + } + if v, exists := bodyData["secret"]; exists { + tokenSecret = v.(string) + } else { + resp["message"] = "Profile API did not find secret in the request payload" + return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) + } + + if extractedTokenTags, err := tagging.ExtractTags(bodyData); err == nil { + for _, extractedTokenTag := range extractedTokenTags { + tokenTags = append(tokenTags, *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 { + tokenLabels = extractedTokenLabels + } else { + resp["message"] = "Profile API find malformed tags in the request payload" + return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) + } + + // Validate data. + if !tokenIssuerRegexPattern.MatchString(tokenTitle) { + resp["message"] = "Profile API found non-compliant token title value" + return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) + } + if !tokenDescriptionRegexPattern.MatchString(tokenDescription) && (tokenDescription != "") { + resp["message"] = "Profile API found non-compliant token description value" + return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) + } + if !tokenSecretRegexPattern.MatchString(tokenSecret) { + resp["message"] = "Profile API found non-compliant token secret value" + return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) + } + if tokenLifetime != 15 && tokenLifetime != 30 && tokenLifetime != 60 && tokenLifetime != 90 { + resp["message"] = "Profile API found non-compliant token lifetime value" + return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) + } + if tokenDigits != 4 && tokenDigits != 6 && tokenDigits != 8 { + resp["message"] = "Profile API found non-compliant token digits value" + return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) + } + + rr.MfaToken.SkipVerification = true + rr.MfaToken.Comment = tokenTitle + rr.MfaToken.Description = tokenDescription + rr.MfaToken.Secret = tokenSecret + rr.MfaToken.Type = "totp" + rr.MfaToken.Period = tokenLifetime + rr.MfaToken.Digits = tokenDigits + rr.MfaToken.Labels = tokenLabels + rr.MfaToken.Tags = tokenTags + + if err := backend.Request(operator.AddMfaToken, rr); err != nil { + resp["message"] = "Profile API failed to add token identity store" + return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) + } + + resp["entry"] = "Created" + return handleAPIProfileResponse(w, rr, http.StatusOK, resp) +} diff --git a/pkg/authn/api_delete_user_multi_factor_authenticator.go b/pkg/authn/api_delete_user_multi_factor_authenticator.go new file mode 100644 index 0000000..314b0e3 --- /dev/null +++ b/pkg/authn/api_delete_user_multi_factor_authenticator.go @@ -0,0 +1,54 @@ +// 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" + "net/http" + + "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/user" +) + +// DeleteUserMultiFactorVerifier deletes app multi factor authenticator from user identity. +func (p *Portal) DeleteUserMultiFactorVerifier( + 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 { + + if v, exists := bodyData["id"]; exists { + rr.MfaToken.ID = v.(string) + } else { + resp["message"] = "Profile API did not find id in the request payload" + return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) + } + + // Get MFA Token + if err := backend.Request(operator.DeleteMfaToken, rr); err != nil { + resp["message"] = "Profile API failed to delete user multi factor authenticator" + return handleAPIProfileResponse(w, rr, http.StatusInternalServerError, resp) + } + + resp["entry"] = rr.MfaToken.ID + return handleAPIProfileResponse(w, rr, http.StatusOK, resp) +} diff --git a/pkg/authn/api_fetch_user_app_multi_factor_authenticator_code.go b/pkg/authn/api_fetch_user_app_multi_factor_authenticator_code.go new file mode 100644 index 0000000..708ab6f --- /dev/null +++ b/pkg/authn/api_fetch_user_app_multi_factor_authenticator_code.go @@ -0,0 +1,123 @@ +// 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" + "encoding/json" + "fmt" + "net/http" + + "github.com/greenpau/go-authcrunch/pkg/identity/qr" + "github.com/greenpau/go-authcrunch/pkg/ids" + "github.com/greenpau/go-authcrunch/pkg/requests" + "github.com/greenpau/go-authcrunch/pkg/user" +) + +// FetchUserAppMultiFactorVerifierCode fetches app multi factor authenticator passcode. +func (p *Portal) FetchUserAppMultiFactorVerifierCode( + 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 tokenLifetime, tokenDigits int + var tokenIssuer, tokenSecret string + + // Extract data. + if v, exists := bodyData["period"]; exists { + switch exp := v.(type) { + case float64: + tokenLifetime = int(exp) + case int: + tokenLifetime = exp + case int64: + tokenLifetime = int(exp) + case json.Number: + i, _ := exp.Int64() + tokenLifetime = int(i) + } + } else { + resp["message"] = "Profile API did not find period in the request payload" + return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) + } + if v, exists := bodyData["digits"]; exists { + switch exp := v.(type) { + case float64: + tokenDigits = int(exp) + case int: + tokenDigits = exp + case int64: + tokenDigits = int(exp) + case json.Number: + i, _ := exp.Int64() + tokenDigits = int(i) + } + } else { + resp["message"] = "Profile API did not find digits in the request payload" + return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) + } + if v, exists := bodyData["issuer"]; exists { + tokenIssuer = v.(string) + } else { + resp["message"] = "Profile API did not find issuer in the request payload" + return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) + } + if v, exists := bodyData["secret"]; exists { + tokenSecret = v.(string) + } else { + resp["message"] = "Profile API did not find secret in the request payload" + return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) + } + + // Validate data. + if !tokenIssuerRegexPattern.MatchString(tokenIssuer) { + resp["message"] = "Profile API found non-compliant token issuer value" + return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) + } + if !tokenSecretRegexPattern.MatchString(tokenSecret) { + resp["message"] = "Profile API found non-compliant token secret value" + return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) + } + if tokenLifetime != 15 && tokenLifetime != 30 && tokenLifetime != 60 && tokenLifetime != 90 { + resp["message"] = "Profile API found non-compliant token lifetime value" + return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) + } + if tokenDigits != 4 && tokenDigits != 6 && tokenDigits != 8 { + resp["message"] = "Profile API found non-compliant token digits value" + return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) + } + + code := qr.NewCode() + code.Secret = tokenSecret + code.Type = "totp" + code.Period = tokenLifetime + code.Issuer = fmt.Sprintf("AuthCrunch@%s", tokenIssuer) + code.Label = fmt.Sprintf("%s:%s", code.Issuer, usr.Claims.Email) + code.Digits = tokenDigits + if err := code.Build(); err != nil { + resp["message"] = "Profile API failed to build QR code" + return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) + } + codeData := make(map[string]interface{}) + codeData["uri"] = code.Get() + codeData["uri_encoded"] = code.GetEncoded() + resp["entry"] = codeData + return handleAPIProfileResponse(w, rr, http.StatusOK, resp) +} diff --git a/pkg/authn/api_fetch_user_dashboard_data.go b/pkg/authn/api_fetch_user_dashboard_data.go new file mode 100644 index 0000000..3ba4fad --- /dev/null +++ b/pkg/authn/api_fetch_user_dashboard_data.go @@ -0,0 +1,129 @@ +// 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" + "net/http" + + "github.com/greenpau/go-authcrunch/pkg/authn/enums/operator" + "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" + "go.uber.org/zap" +) + +// FetchUserDashboardData fetches user identity information. +func (p *Portal) FetchUserDashboardData( + 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) error { + + // Data Buckets + entry := make(map[string]interface{}) + assetCount := make(map[string]interface{}) + + // General Info + err := backend.Request(operator.GetUser, rr) + if err != nil { + resp["message"] = "failed to extract user metadata" + p.logger.Debug( + "failed to extract user metadata", + zap.String("session_id", rr.Upstream.SessionID), + zap.String("request_id", rr.ID), + zap.Error(err), + ) + return handleAPIProfileResponse(w, rr, http.StatusInternalServerError, resp) + } + user := rr.Response.Payload.(*identity.User) + entry["metadata"] = user.GetMetadata() + + // API Keys + rr.Key.Usage = "api" + err = backend.Request(operator.GetAPIKeys, rr) + if err != nil { + resp["message"] = "failed to extract user api keys" + p.logger.Debug( + "failed to extract user api keys", + zap.String("session_id", rr.Upstream.SessionID), + zap.String("request_id", rr.ID), + zap.Error(err), + ) + return handleAPIProfileResponse(w, rr, http.StatusInternalServerError, resp) + } + apiKeysBundle := rr.Response.Payload.(*identity.APIKeyBundle) + apiKeys := apiKeysBundle.Get() + assetCount["api_key"] = len(apiKeys) + + // SSH Keys + rr.Key.Usage = "ssh" + err = backend.Request(operator.GetPublicKeys, rr) + if err != nil { + resp["message"] = "failed to extract user ssh keys" + p.logger.Debug( + "failed to extract user api keys", + zap.String("session_id", rr.Upstream.SessionID), + zap.String("request_id", rr.ID), + zap.Error(err), + ) + return handleAPIProfileResponse(w, rr, http.StatusInternalServerError, resp) + } + sshKeysBundle := rr.Response.Payload.(*identity.PublicKeyBundle) + sshKeys := sshKeysBundle.Get() + assetCount["ssh_key"] = len(sshKeys) + + // GPG Keys + rr.Key.Usage = "gpg" + err = backend.Request(operator.GetPublicKeys, rr) + if err != nil { + resp["message"] = "failed to extract user gpg keys" + p.logger.Debug( + "failed to extract user api keys", + zap.String("session_id", rr.Upstream.SessionID), + zap.String("request_id", rr.ID), + zap.Error(err), + ) + return handleAPIProfileResponse(w, rr, http.StatusInternalServerError, resp) + } + gpgKeysBundle := rr.Response.Payload.(*identity.PublicKeyBundle) + gpgKeys := gpgKeysBundle.Get() + assetCount["gpg_key"] = len(gpgKeys) + + // MFA and 2FA + if err := backend.Request(operator.GetMfaTokens, rr); err != nil { + resp["message"] = "failed to extract user MFA/2FA" + return handleAPIProfileResponse(w, rr, http.StatusInternalServerError, resp) + } + mfaTokensBundle := rr.Response.Payload.(*identity.MfaTokenBundle) + mfaTokens := mfaTokensBundle.Get() + assetCount["mfa_2fa"] = len(mfaTokens) + + // User Roles + + entry["roles"] = parsedUser.Claims.Roles + + // Finalize + + entry["asset_count"] = assetCount + entry["connected_accounts"] = []interface{}{} + resp["entry"] = entry + 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 new file mode 100644 index 0000000..ae64c0a --- /dev/null +++ b/pkg/authn/api_fetch_user_multi_factor_authenticators.go @@ -0,0 +1,76 @@ +// 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" + "net/http" + + "github.com/greenpau/go-authcrunch/pkg/authn/enums/operator" + "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" +) + +// FetchUserMultiFactorVerifiers fetches app multi factor authenticators from user identity. +func (p *Portal) FetchUserMultiFactorVerifiers( + 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) error { + + // List MFA Tokens. + if err := backend.Request(operator.GetMfaTokens, rr); err != nil { + resp["message"] = "Profile API failed to get user multi factor authenticators" + return handleAPIProfileResponse(w, rr, http.StatusInternalServerError, resp) + } + bundle := rr.Response.Payload.(*identity.MfaTokenBundle) + resp["entries"] = bundle.Get() + return handleAPIProfileResponse(w, rr, http.StatusOK, resp) +} + +// FetchUserMultiFactorVerifier fetches app multi factor authenticators from user identity. +func (p *Portal) FetchUserMultiFactorVerifier( + 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 { + + if v, exists := bodyData["id"]; exists { + rr.MfaToken.ID = v.(string) + } else { + resp["message"] = "Profile API did not find id in the request payload" + return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) + } + + // Get MFA Token + if err := backend.Request(operator.GetMfaToken, rr); err != nil { + resp["message"] = "Profile API failed to get user multi factor authenticator" + return handleAPIProfileResponse(w, rr, http.StatusInternalServerError, resp) + } + token := rr.Response.Payload.(*identity.MfaToken) + resp["entry"] = token + return handleAPIProfileResponse(w, rr, http.StatusOK, resp) +} diff --git a/pkg/authn/api_test_user_app_multi_factor_authenticator.go b/pkg/authn/api_test_user_app_multi_factor_authenticator.go new file mode 100644 index 0000000..9210487 --- /dev/null +++ b/pkg/authn/api_test_user_app_multi_factor_authenticator.go @@ -0,0 +1,128 @@ +// 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" + "encoding/json" + "net/http" + "time" + + "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" + "github.com/greenpau/go-authcrunch/pkg/util" +) + +// TestUserAppMultiFactorVerifier tests app multi factor authenticator passcode. +func (p *Portal) TestUserAppMultiFactorVerifier( + 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 tokenLifetime, tokenDigits int + var tokenSecret, tokenPasscode string + + // Extract data. + if v, exists := bodyData["period"]; exists { + switch exp := v.(type) { + case float64: + tokenLifetime = int(exp) + case int: + tokenLifetime = exp + case int64: + tokenLifetime = int(exp) + case json.Number: + i, _ := exp.Int64() + tokenLifetime = int(i) + } + } else { + resp["message"] = "Profile API did not find period in the request payload" + return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) + } + if v, exists := bodyData["digits"]; exists { + switch exp := v.(type) { + case float64: + tokenDigits = int(exp) + case int: + tokenDigits = exp + case int64: + tokenDigits = int(exp) + case json.Number: + i, _ := exp.Int64() + tokenDigits = int(i) + } + } else { + resp["message"] = "Profile API did not find digits in the request payload" + return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) + } + if v, exists := bodyData["secret"]; exists { + tokenSecret = v.(string) + } else { + resp["message"] = "Profile API did not find secret in the request payload" + return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) + } + if v, exists := bodyData["passcode"]; exists { + tokenPasscode = v.(string) + } else { + resp["message"] = "Profile API did not find passcode in the request payload" + return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) + } + + // Validate data. + if !tokenSecretRegexPattern.MatchString(tokenSecret) { + resp["message"] = "Profile API found non-compliant token secret value" + return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) + } + if !tokenPasscodeRegexPattern.MatchString(tokenPasscode) { + resp["message"] = "Profile API found non-compliant token passcode value" + return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) + } + if tokenLifetime != 15 && tokenLifetime != 30 && tokenLifetime != 60 && tokenLifetime != 90 { + resp["message"] = "Profile API found non-compliant token lifetime value" + return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) + } + if tokenDigits != 4 && tokenDigits != 6 && tokenDigits != 8 { + resp["message"] = "Profile API found non-compliant token digits value" + return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) + } + + respData := make(map[string]interface{}) + appToken := identity.MfaToken{ + ID: util.GetRandomString(40), + CreatedAt: time.Now().UTC(), + Parameters: make(map[string]string), + Flags: make(map[string]bool), + Comment: "TBD", + Type: "totp", + Secret: tokenSecret, + Algorithm: "sha1", + Period: tokenLifetime, + Digits: tokenDigits, + } + if err := appToken.ValidateCodeWithTime(tokenPasscode, time.Now().Add(-time.Second*time.Duration(appToken.Period)).UTC()); err != nil { + respData["success"] = false + } else { + respData["success"] = true + } + resp["entry"] = respData + return handleAPIProfileResponse(w, rr, http.StatusOK, resp) +} diff --git a/pkg/authn/enums/operator/operator.go b/pkg/authn/enums/operator/operator.go index a476b22..d2be939 100644 --- a/pkg/authn/enums/operator/operator.go +++ b/pkg/authn/enums/operator/operator.go @@ -44,6 +44,8 @@ const ( DeleteAPIKey // GetMfaTokens operator signals the retrieval of MFA tokens. GetMfaTokens + // GetMfaToken operator signals the retrieval of a single MFA token. + GetMfaToken // AddMfaToken operator signals the addition of an MFA token. AddMfaToken // DeleteMfaToken operator signals the deletion of an MFA token. @@ -83,6 +85,8 @@ func (e Type) String() string { return "DeletePublicKey" case GetMfaTokens: return "GetMfaTokens" + case GetMfaToken: + return "GetMfaToken" case AddMfaToken: return "AddMfaToken" case DeleteMfaToken: diff --git a/pkg/authn/handle_api_profile.go b/pkg/authn/handle_api_profile.go index fec6f1b..e46ce0c 100644 --- a/pkg/authn/handle_api_profile.go +++ b/pkg/authn/handle_api_profile.go @@ -22,12 +22,7 @@ import ( "net/http" "time" - "github.com/greenpau/go-authcrunch/pkg/authn/enums/operator" - "github.com/greenpau/go-authcrunch/pkg/identity" - "github.com/greenpau/go-authcrunch/pkg/identity/qr" "github.com/greenpau/go-authcrunch/pkg/requests" - "github.com/greenpau/go-authcrunch/pkg/tagging" - "github.com/greenpau/go-authcrunch/pkg/util" "regexp" @@ -38,7 +33,7 @@ import ( var tokenSecretRegexPattern = regexp.MustCompile(`^[A-Za-z0-9]{10,200}$`) var tokenIssuerRegexPattern = regexp.MustCompile(`^[A-Za-z0-9]{3,50}$`) -var tokenDescriptionRegexPattern = regexp.MustCompile(`[\W\s]{3,255}$`) +var tokenDescriptionRegexPattern = regexp.MustCompile(`^[\w\s\-\_,\.]{3,255}$`) var tokenPasscodeRegexPattern = regexp.MustCompile(`^[0-9]{4,8}$`) func handleAPIProfileResponse(w http.ResponseWriter, rr *requests.Request, code int, resp map[string]interface{}) error { @@ -51,7 +46,6 @@ func handleAPIProfileResponse(w http.ResponseWriter, rr *requests.Request, code } func (p *Portal) handleAPIProfile(ctx context.Context, w http.ResponseWriter, r *http.Request, rr *requests.Request, parsedUser *user.User) error { - entry := make(map[string]interface{}) resp := make(map[string]interface{}) resp["timestamp"] = time.Now().UTC().Format(time.RFC3339Nano) @@ -101,11 +95,12 @@ func (p *Portal) handleAPIProfile(ctx context.Context, w http.ResponseWriter, r switch reqKind { case "fetch_user_dashboard_data": - case "delete_user_multi_factor_verifier": - case "fetch_user_multi_factor_verifiers": - case "fetch_user_app_multi_factor_verifier_code": - case "test_user_app_multi_factor_verifier": - case "add_user_app_multi_factor_verifier": + case "delete_user_multi_factor_authenticator": + case "fetch_user_multi_factor_authenticators": + case "fetch_user_multi_factor_authenticator": + case "fetch_user_app_multi_factor_authenticator_code": + case "test_user_app_multi_factor_authenticator": + case "add_user_app_multi_factor_authenticator": default: resp["message"] = "Profile API recieved unsupported request type" return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) @@ -150,431 +145,19 @@ func (p *Portal) handleAPIProfile(ctx context.Context, w http.ResponseWriter, r switch reqKind { case "fetch_user_dashboard_data": - assetCount := make(map[string]interface{}) - generalSettingsResp := make(map[string]interface{}) - if err := p.handleHTTPGeneralSettings(ctx, r, rr, usr, backend, generalSettingsResp); err != nil { - resp["message"] = "failed to extract user metadata" - p.logger.Debug( - "failed to extract user metadata", - zap.String("session_id", rr.Upstream.SessionID), - zap.String("request_id", rr.ID), - zap.Error(err), - ) - return handleAPIProfileResponse(w, rr, http.StatusInternalServerError, resp) - } - if metadata, exists := generalSettingsResp["metadata"]; exists { - entry["metadata"] = metadata - } else { - resp["message"] = "Profile API failed to extract user metadata" - return handleAPIProfileResponse(w, rr, http.StatusInternalServerError, resp) - } - - // API Keys - - apiKeysResp := make(map[string]interface{}) - apiKeysResp["endpoint"] = "/list" - if err := p.handleHTTPAPIKeysSettings(ctx, r, rr, usr, backend, apiKeysResp); err != nil { - resp["message"] = "failed to extract user api keys" - p.logger.Debug( - "failed to extract user api keys", - zap.String("session_id", rr.Upstream.SessionID), - zap.String("request_id", rr.ID), - zap.Error(err), - ) - return handleAPIProfileResponse(w, rr, http.StatusInternalServerError, resp) - } - - if apikeys, exists := apiKeysResp["apikeys"]; exists { - assetCount["api_key"] = len(apikeys.([]*identity.APIKey)) - } else { - assetCount["api_key"] = 0 - } - - // SSH Keys - - sshKeysResp := make(map[string]interface{}) - sshKeysResp["endpoint"] = "/list" - if err := p.handleHTTPSSHKeysSettings(ctx, r, rr, usr, backend, sshKeysResp); err != nil { - resp["message"] = "failed to extract user ssh keys" - p.logger.Debug( - "failed to extract user ssh keys", - zap.String("session_id", rr.Upstream.SessionID), - zap.String("request_id", rr.ID), - zap.Error(err), - ) - return handleAPIProfileResponse(w, rr, http.StatusInternalServerError, resp) - } - - if sshkeys, exists := sshKeysResp["sshkeys"]; exists { - assetCount["ssh_key"] = len(sshkeys.([]*identity.PublicKey)) - } else { - assetCount["ssh_key"] = 0 - } - - // GPG Keys - - gpgKeysResp := make(map[string]interface{}) - gpgKeysResp["endpoint"] = "/list" - if err := p.handleHTTPGPGKeysSettings(ctx, r, rr, usr, backend, gpgKeysResp); err != nil { - resp["message"] = "failed to extract user gpg keys" - p.logger.Debug( - "failed to extract user gpg keys", - zap.String("session_id", rr.Upstream.SessionID), - zap.String("request_id", rr.ID), - zap.Error(err), - ) - return handleAPIProfileResponse(w, rr, http.StatusInternalServerError, resp) - } - - if gpgkeys, exists := gpgKeysResp["gpgkeys"]; exists { - assetCount["gpg_key"] = len(gpgkeys.([]*identity.PublicKey)) - } else { - assetCount["gpg_key"] = 0 - } - - // MFA/2FA - - mfa2faResp := make(map[string]interface{}) - mfa2faResp["endpoint"] = "/list" - if err := p.handleHTTPMfaSettings(ctx, r, rr, usr, backend, mfa2faResp); err != nil { - resp["message"] = "failed to extract user MFA/2FA" - p.logger.Debug( - "failed to extract user MFA/2FA", - zap.String("session_id", rr.Upstream.SessionID), - zap.String("request_id", rr.ID), - zap.Error(err), - ) - return handleAPIProfileResponse(w, rr, http.StatusInternalServerError, resp) - } - - if mfaTokens, exists := mfa2faResp["mfa_tokens"]; exists { - assetCount["mfa_2fa"] = len(mfaTokens.([]*identity.MfaToken)) - } else { - assetCount["mfa_2fa"] = 0 - } - - // User Roles - - entry["roles"] = parsedUser.Claims.Roles - - // Finalize - - entry["asset_count"] = assetCount - entry["connected_accounts"] = []interface{}{} - resp["entry"] = entry - return handleAPIProfileResponse(w, rr, http.StatusOK, resp) - case "fetch_user_multi_factor_verifiers": - fetchedData := make(map[string]interface{}) - fetchedData["endpoint"] = "/list" - if err := p.handleHTTPMfaSettings(ctx, r, rr, usr, backend, fetchedData); err != nil { - resp["message"] = "failed to extract user MFA/2FA" - p.logger.Debug( - "failed to extract user MFA/2FA", - zap.String("session_id", rr.Upstream.SessionID), - zap.String("request_id", rr.ID), - zap.Error(err), - ) - return handleAPIProfileResponse(w, rr, http.StatusInternalServerError, resp) - } - - if mfaTokens, exists := fetchedData["mfa_tokens"]; exists { - resp["entries"] = mfaTokens - } else { - resp["entries"] = []string{} - } - return handleAPIProfileResponse(w, rr, http.StatusOK, resp) - case "delete_user_multi_factor_verifier": - fetchedData := make(map[string]interface{}) - var verifierID string - if v, exists := bodyData["id"]; exists { - verifierID = v.(string) - } else { - resp["message"] = "Profile API did not find id in the request payload" - return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) - } - fetchedData["endpoint"] = fmt.Sprintf("/delete/%s", verifierID) - if err := p.handleHTTPMfaSettings(ctx, r, rr, usr, backend, fetchedData); err != nil { - resp["message"] = "failed to delete user MFA/2FA" - p.logger.Debug( - "failed to delete user MFA/2FA", - zap.String("session_id", rr.Upstream.SessionID), - zap.String("request_id", rr.ID), - zap.Error(err), - ) - return handleAPIProfileResponse(w, rr, http.StatusInternalServerError, resp) - } - resp["entry"] = verifierID - return handleAPIProfileResponse(w, rr, http.StatusOK, resp) - - case "fetch_user_app_multi_factor_verifier_code": - var tokenLifetime, tokenDigits int - var tokenIssuer, tokenSecret string - - // Extract data. - if v, exists := bodyData["period"]; exists { - switch exp := v.(type) { - case float64: - tokenLifetime = int(exp) - case int: - tokenLifetime = exp - case int64: - tokenLifetime = int(exp) - case json.Number: - i, _ := exp.Int64() - tokenLifetime = int(i) - } - } else { - resp["message"] = "Profile API did not find period in the request payload" - return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) - } - if v, exists := bodyData["digits"]; exists { - switch exp := v.(type) { - case float64: - tokenDigits = int(exp) - case int: - tokenDigits = exp - case int64: - tokenDigits = int(exp) - case json.Number: - i, _ := exp.Int64() - tokenDigits = int(i) - } - } else { - resp["message"] = "Profile API did not find digits in the request payload" - return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) - } - if v, exists := bodyData["issuer"]; exists { - tokenIssuer = v.(string) - } else { - resp["message"] = "Profile API did not find issuer in the request payload" - return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) - } - if v, exists := bodyData["secret"]; exists { - tokenSecret = v.(string) - } else { - resp["message"] = "Profile API did not find secret in the request payload" - return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) - } - - // Validate data. - if !tokenIssuerRegexPattern.MatchString(tokenIssuer) { - resp["message"] = "Profile API found non-compliant token issuer value" - return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) - } - if !tokenSecretRegexPattern.MatchString(tokenSecret) { - resp["message"] = "Profile API found non-compliant token secret value" - return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) - } - if tokenLifetime != 15 && tokenLifetime != 30 && tokenLifetime != 60 && tokenLifetime != 90 { - resp["message"] = "Profile API found non-compliant token lifetime value" - return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) - } - if tokenDigits != 4 && tokenDigits != 6 && tokenDigits != 8 { - resp["message"] = "Profile API found non-compliant token digits value" - return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) - } - - code := qr.NewCode() - code.Secret = tokenSecret - code.Type = "totp" - code.Period = tokenLifetime - code.Issuer = fmt.Sprintf("AuthCrunch@%s", tokenIssuer) - code.Label = fmt.Sprintf("%s:%s", code.Issuer, usr.Claims.Email) - code.Digits = tokenDigits - if err := code.Build(); err != nil { - resp["message"] = "Profile API failed to build QR code" - return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) - } - codeData := make(map[string]interface{}) - codeData["uri"] = code.Get() - codeData["uri_encoded"] = code.GetEncoded() - resp["entry"] = codeData - return handleAPIProfileResponse(w, rr, http.StatusOK, resp) - case "test_user_app_multi_factor_verifier": - var tokenLifetime, tokenDigits int - var tokenSecret, tokenPasscode string - - // Extract data. - if v, exists := bodyData["period"]; exists { - switch exp := v.(type) { - case float64: - tokenLifetime = int(exp) - case int: - tokenLifetime = exp - case int64: - tokenLifetime = int(exp) - case json.Number: - i, _ := exp.Int64() - tokenLifetime = int(i) - } - } else { - resp["message"] = "Profile API did not find period in the request payload" - return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) - } - if v, exists := bodyData["digits"]; exists { - switch exp := v.(type) { - case float64: - tokenDigits = int(exp) - case int: - tokenDigits = exp - case int64: - tokenDigits = int(exp) - case json.Number: - i, _ := exp.Int64() - tokenDigits = int(i) - } - } else { - resp["message"] = "Profile API did not find digits in the request payload" - return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) - } - if v, exists := bodyData["secret"]; exists { - tokenSecret = v.(string) - } else { - resp["message"] = "Profile API did not find secret in the request payload" - return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) - } - if v, exists := bodyData["passcode"]; exists { - tokenPasscode = v.(string) - } else { - resp["message"] = "Profile API did not find passcode in the request payload" - return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) - } - - // Validate data. - if !tokenSecretRegexPattern.MatchString(tokenSecret) { - resp["message"] = "Profile API found non-compliant token secret value" - return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) - } - if !tokenPasscodeRegexPattern.MatchString(tokenPasscode) { - resp["message"] = "Profile API found non-compliant token passcode value" - return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) - } - if tokenLifetime != 15 && tokenLifetime != 30 && tokenLifetime != 60 && tokenLifetime != 90 { - resp["message"] = "Profile API found non-compliant token lifetime value" - return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) - } - if tokenDigits != 4 && tokenDigits != 6 && tokenDigits != 8 { - resp["message"] = "Profile API found non-compliant token digits value" - return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) - } - - respData := make(map[string]interface{}) - appToken := identity.MfaToken{ - ID: util.GetRandomString(40), - CreatedAt: time.Now().UTC(), - Parameters: make(map[string]string), - Flags: make(map[string]bool), - Comment: "TBD", - Type: "totp", - Secret: tokenSecret, - Algorithm: "sha1", - Period: tokenLifetime, - Digits: tokenDigits, - } - if err := appToken.ValidateCodeWithTime(tokenPasscode, time.Now().Add(-time.Second*time.Duration(appToken.Period)).UTC()); err != nil { - respData["success"] = false - } else { - respData["success"] = true - } - resp["entry"] = respData - return handleAPIProfileResponse(w, rr, http.StatusOK, resp) - case "add_user_app_multi_factor_verifier": - var tokenTitle, tokenDescription, tokenSecret string - var tokenLifetime, tokenDigits int - var tokenLabels []string = []string{} - var tokenTags []tagging.Tag = []tagging.Tag{} - - // Extract data. - if v, exists := bodyData["period"]; exists { - switch exp := v.(type) { - case float64: - tokenLifetime = int(exp) - case int: - tokenLifetime = exp - case int64: - tokenLifetime = int(exp) - case json.Number: - i, _ := exp.Int64() - tokenLifetime = int(i) - } - } else { - resp["message"] = "Profile API did not find period in the request payload" - return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) - } - if v, exists := bodyData["digits"]; exists { - switch exp := v.(type) { - case float64: - tokenDigits = int(exp) - case int: - tokenDigits = exp - case int64: - tokenDigits = int(exp) - case json.Number: - i, _ := exp.Int64() - tokenDigits = int(i) - } - } else { - resp["message"] = "Profile API did not find digits in the request payload" - return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) - } - if v, exists := bodyData["title"]; exists { - tokenTitle = 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 { - tokenDescription = v.(string) - } else { - resp["message"] = "Profile API did not find description in the request payload" - return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) - } - if v, exists := bodyData["secret"]; exists { - tokenSecret = v.(string) - } else { - resp["message"] = "Profile API did not find secret in the request payload" - return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) - } - - // Validate data. - if !tokenIssuerRegexPattern.MatchString(tokenTitle) { - resp["message"] = "Profile API found non-compliant token title value" - return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) - } - if !tokenDescriptionRegexPattern.MatchString(tokenDescription) && (tokenDescription != "") { - resp["message"] = "Profile API found non-compliant token description value" - return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) - } - if !tokenSecretRegexPattern.MatchString(tokenSecret) { - resp["message"] = "Profile API found non-compliant token secret value" - return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) - } - if tokenLifetime != 15 && tokenLifetime != 30 && tokenLifetime != 60 && tokenLifetime != 90 { - resp["message"] = "Profile API found non-compliant token lifetime value" - return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) - } - if tokenDigits != 4 && tokenDigits != 6 && tokenDigits != 8 { - resp["message"] = "Profile API found non-compliant token digits value" - return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) - } - - rr.MfaToken.SkipVerification = true - rr.MfaToken.Comment = tokenTitle - rr.MfaToken.Description = tokenDescription - rr.MfaToken.Secret = tokenSecret - rr.MfaToken.Type = "totp" - rr.MfaToken.Period = tokenLifetime - rr.MfaToken.Digits = tokenDigits - rr.MfaToken.Labels = tokenLabels - rr.MfaToken.Tags = tokenTags - - if err = backend.Request(operator.AddMfaToken, rr); err != nil { - resp["message"] = "Profile API failed to add token identity store" - return handleAPIProfileResponse(w, rr, http.StatusBadRequest, resp) - } - - resp["entry"] = "Created" - return handleAPIProfileResponse(w, rr, http.StatusOK, resp) + return p.FetchUserDashboardData(ctx, w, r, rr, parsedUser, resp, usr, backend) + case "fetch_user_multi_factor_authenticators": + return p.FetchUserMultiFactorVerifiers(ctx, w, r, rr, parsedUser, resp, usr, backend) + case "fetch_user_multi_factor_authenticator": + return p.FetchUserMultiFactorVerifier(ctx, w, r, rr, parsedUser, resp, usr, backend, bodyData) + case "delete_user_multi_factor_authenticator": + return p.DeleteUserMultiFactorVerifier(ctx, w, r, rr, parsedUser, resp, usr, backend, bodyData) + case "fetch_user_app_multi_factor_authenticator_code": + return p.FetchUserAppMultiFactorVerifierCode(ctx, w, r, rr, parsedUser, resp, usr, backend, bodyData) + case "test_user_app_multi_factor_authenticator": + return p.TestUserAppMultiFactorVerifier(ctx, w, r, rr, parsedUser, resp, usr, backend, bodyData) + case "add_user_app_multi_factor_authenticator": + return p.AddUserAppMultiFactorVerifier(ctx, w, r, rr, parsedUser, resp, usr, backend, bodyData) } // Default response diff --git a/pkg/errors/mfa_token.go b/pkg/errors/mfa_token.go index 5a4f658..7a98209 100644 --- a/pkg/errors/mfa_token.go +++ b/pkg/errors/mfa_token.go @@ -19,6 +19,7 @@ const ( ErrAddMfaToken StandardError = "failed adding MFA token: %v" ErrDeleteMfaToken StandardError = "failed deleting MFA token %q: %v" ErrGetMfaTokens StandardError = "failed getting MFA tokens: %v" + ErrGetMfaToken StandardError = "failed getting MFA token: %v" ErrDuplicateMfaTokenSecret StandardError = "duplicate MFA token secret" ErrDuplicateMfaTokenComment StandardError = "duplicate MFA token comment" diff --git a/pkg/identity/database.go b/pkg/identity/database.go index defc533..1f488fa 100644 --- a/pkg/identity/database.go +++ b/pkg/identity/database.go @@ -765,6 +765,27 @@ func (db *Database) GetMfaTokens(r *requests.Request) error { return nil } +// GetMfaToken returns a single MFA token associated with a user. +func (db *Database) GetMfaToken(r *requests.Request) error { + db.mu.RLock() + defer db.mu.RUnlock() + user, err := db.validateUserIdentity(r.User.Username, r.User.Email) + if err != nil { + return errors.ErrGetMfaTokens.WithArgs(err) + } + for _, token := range user.MfaTokens { + if token.Disabled { + continue + } + if token.ID != r.MfaToken.ID { + continue + } + r.Response.Payload = token + return nil + } + return errors.ErrGetMfaToken.WithArgs("not found") +} + // DeleteMfaToken deletes MFA token associated with a user by token id. func (db *Database) DeleteMfaToken(r *requests.Request) error { db.mu.Lock() diff --git a/pkg/identity/mfa_token.go b/pkg/identity/mfa_token.go index 7848212..edbac5f 100644 --- a/pkg/identity/mfa_token.go +++ b/pkg/identity/mfa_token.go @@ -112,6 +112,8 @@ func NewMfaToken(req *requests.Request) (*MfaToken, error) { Comment: req.MfaToken.Comment, Type: req.MfaToken.Type, Description: req.MfaToken.Description, + Tags: req.MfaToken.Tags, + Labels: req.MfaToken.Labels, } if req.MfaToken.Disabled { diff --git a/pkg/ids/local/authenticator.go b/pkg/ids/local/authenticator.go index ea281c7..b4be3d3 100644 --- a/pkg/ids/local/authenticator.go +++ b/pkg/ids/local/authenticator.go @@ -15,12 +15,13 @@ package local import ( + "os" + "sync" + "github.com/google/uuid" "github.com/greenpau/go-authcrunch/pkg/identity" "github.com/greenpau/go-authcrunch/pkg/requests" "go.uber.org/zap" - "os" - "sync" ) // Authenticator represents database connector. @@ -243,6 +244,13 @@ func (sa *Authenticator) GetMfaTokens(r *requests.Request) error { return sa.db.GetMfaTokens(r) } +// GetMfaToken returns a single MFA token associated with a user. +func (sa *Authenticator) GetMfaToken(r *requests.Request) error { + sa.mux.Lock() + defer sa.mux.Unlock() + return sa.db.GetMfaToken(r) +} + // IdentifyUser returns user challenges. func (sa *Authenticator) IdentifyUser(r *requests.Request) error { sa.mux.Lock() diff --git a/pkg/ids/local/store.go b/pkg/ids/local/store.go index dccb2a1..6512791 100644 --- a/pkg/ids/local/store.go +++ b/pkg/ids/local/store.go @@ -16,6 +16,7 @@ package local import ( "encoding/json" + "github.com/greenpau/go-authcrunch/pkg/authn/enums/operator" "github.com/greenpau/go-authcrunch/pkg/authn/icons" "github.com/greenpau/go-authcrunch/pkg/errors" @@ -135,6 +136,8 @@ func (b *IdentityStore) Request(op operator.Type, r *requests.Request) error { return b.authenticator.GetAPIKeys(r) case operator.GetMfaTokens: return b.authenticator.GetMfaTokens(r) + case operator.GetMfaToken: + return b.authenticator.GetMfaToken(r) case operator.AddUser: return b.authenticator.AddUser(r) case operator.GetUsers: diff --git a/pkg/ids/local/store_test.go b/pkg/ids/local/store_test.go index 97c19ec..3839335 100644 --- a/pkg/ids/local/store_test.go +++ b/pkg/ids/local/store_test.go @@ -16,6 +16,10 @@ package local import ( "fmt" + "path" + "path/filepath" + "testing" + "github.com/greenpau/go-authcrunch/internal/tests" "github.com/greenpau/go-authcrunch/internal/testutils" "github.com/greenpau/go-authcrunch/pkg/authn/enums/operator" @@ -23,9 +27,6 @@ import ( "github.com/greenpau/go-authcrunch/pkg/requests" logutil "github.com/greenpau/go-authcrunch/pkg/util/log" "go.uber.org/zap" - "path" - "path/filepath" - "testing" ) func TestNewIdentityStore(t *testing.T) { @@ -107,6 +108,7 @@ func TestNewIdentityStore(t *testing.T) { "DeleteUser": true, "GetAPIKeys": false, "GetMfaTokens": false, + "GetMfaToken": true, "GetPublicKeys": false, "GetUser": false, "GetUsers": false, @@ -183,6 +185,7 @@ func TestNewIdentityStore(t *testing.T) { operator.DeletePublicKey, operator.AddMfaToken, operator.GetMfaTokens, + operator.GetMfaToken, operator.DeleteMfaToken, operator.AddUser, operator.GetUser, diff --git a/pkg/tagging/tag.go b/pkg/tagging/tag.go index 8ee7a09..fa78649 100644 --- a/pkg/tagging/tag.go +++ b/pkg/tagging/tag.go @@ -14,6 +14,10 @@ package tagging +import ( + "fmt" +) + // Tag represents key-value tag. type Tag struct { Key string `json:"key,omitempty" xml:"key,omitempty" yaml:"key,omitempty"` @@ -24,3 +28,104 @@ type Tag struct { func NewTag(key, value string) *Tag { return &Tag{Key: key, Value: value} } + +func extractStringFromInteface(i interface{}) (string, error) { + switch v := i.(type) { + case string: + return v, nil + } + return "", fmt.Errorf("not string") +} + +func extractMapKeyFromInteface(k string, m map[string]interface{}) (string, error) { + if v, exists := m[k]; exists { + key, err := extractStringFromInteface(v) + if err != nil { + return "", fmt.Errorf("tag %s is malformed: %v", k, err) + } + return key, nil + } + + return "", fmt.Errorf("tag has no %s", k) +} + +func extractTagFromMap(m map[string]interface{}) (*Tag, error) { + if m == nil { + return nil, fmt.Errorf("tag is nil") + } + key, err := extractMapKeyFromInteface("key", m) + if err != nil { + return nil, err + } + value, err := extractMapKeyFromInteface("value", m) + if err != nil { + return nil, err + } + tag := &Tag{ + Key: key, + Value: value, + } + return tag, nil +} + +// ExtractTags extracts tags fom a map. +func ExtractTags(m map[string]interface{}) ([]*Tag, error) { + tags := []*Tag{} + if m == nil { + return tags, fmt.Errorf("input data is nil") + } + + extractedTags, tagExists := m["tags"] + if !tagExists { + return tags, nil + } + + switch vs := extractedTags.(type) { + case []interface{}: + for _, extractedTag := range vs { + + switch v := extractedTag.(type) { + case map[string]interface{}: + tag, err := extractTagFromMap(v) + if err != nil { + return tags, fmt.Errorf("malformed extracted tags: %v", err) + } + tags = append(tags, tag) + default: + return tags, fmt.Errorf("extracted tag is %T", extractedTag) + } + } + default: + return tags, fmt.Errorf("extracted tags are %T", vs) + } + return tags, nil +} + +// ExtractLabels extracts labels fom a map. +func ExtractLabels(m map[string]interface{}) ([]string, error) { + labels := []string{} + if m == nil { + return labels, fmt.Errorf("input data is nil") + } + + extractedLabels, labelsExists := m["labels"] + if !labelsExists { + return labels, nil + } + + switch vs := extractedLabels.(type) { + case []interface{}: + for _, extractedLabel := range vs { + + switch label := extractedLabel.(type) { + case string: + labels = append(labels, label) + default: + return labels, fmt.Errorf("extracted label is %T", label) + } + } + default: + return labels, fmt.Errorf("extracted labels are %T", vs) + } + return labels, nil +} diff --git a/pkg/tagging/tag_test.go b/pkg/tagging/tag_test.go index ee1632d..74f818e 100644 --- a/pkg/tagging/tag_test.go +++ b/pkg/tagging/tag_test.go @@ -15,68 +15,181 @@ package tagging import ( + "fmt" "testing" + + "github.com/greenpau/go-authcrunch/internal/tests" ) -func TestNewTag(t *testing.T) { - // testFailed := 0 - // tests := []struct { - // addr string - // hname string - // hvalue string - // result string - // }{ - // { - // addr: "192.168.99.40:23467", - // result: "192.168.99.40", - // }, - // { - // addr: "192.168.99.40:23467", - // hname: "x-real-ip", - // hvalue: "10.10.10.10", - // result: "10.10.10.10", - // }, - // { - // addr: "192.168.99.40:23467", - // hname: "X-real-IP", - // hvalue: "10.10.10.10", - // result: "10.10.10.10", - // }, - // { - // addr: "192.168.99.40:23467", - // hname: "X-Forwarded-For", - // hvalue: "100.100.2.2, 192.168.0.10", - // result: "100.100.2.2", - // }, - // { - // addr: "192.168.99.40:23467", - // hname: "X-Forwarded-For", - // hvalue: "192.168.0.10", - // result: "192.168.0.10", - // }, - // } - // for i, test := range tests { - // r, err := http.NewRequest("GET", "127.0.0.1", nil) - // if err != nil { - // t.Fatalf("Failed creating HTTP request") - // } - // r.RemoteAddr = test.addr - // testDescr := fmt.Sprintf("Test %d, addr: %s, result: %s", i, test.addr, test.result) - // if test.hname != "" { - // testDescr += fmt.Sprintf(", header: %s, value, %s", test.hname, test.hvalue) - // r.Header.Add(test.hname, test.hvalue) - // } +func TestExtractTags(t *testing.T) { + + testcases := []struct { + name string + input string + want []*Tag + shouldErr bool + err error + disabled bool + }{ + { + name: "test extract tags with one tag", + disabled: false, + input: `{ + "tags": [ + { + "key": "foo", + "value": "bar" + } + ] + }`, + want: []*Tag{ + { + Key: "foo", + Value: "bar", + }, + }, + }, + { + name: "test extract tags with multiple tags", + disabled: false, + input: `{ + "tags": [ + { + "key": "foo", + "value": "bar" + }, + { + "key": "bar", + "value": "baz" + } + ] + }`, + want: []*Tag{ + { + Key: "foo", + Value: "bar", + }, + { + Key: "bar", + Value: "baz", + }, + }, + }, + { + name: "test extract tags without any tags", + disabled: false, + input: `{ + "tags": [ + ] + }`, + want: []*Tag{}, + }, + { + name: "test map without tags field", + disabled: false, + input: `{}`, + want: []*Tag{}, + }, + { + name: "test tag without key field", + disabled: false, + input: `{ + "tags": [ + { + "foo": "foo", + "value": "bar" + } + ] + }`, + shouldErr: true, + err: fmt.Errorf("malformed extracted tags: %s", "tag has no key"), + }, + { + name: "test tag without value field", + disabled: false, + input: `{ + "tags": [ + { + "key": "foo", + "foo": "bar" + } + ] + }`, + shouldErr: true, + err: fmt.Errorf("malformed extracted tags: %s", "tag has no value"), + }, + } + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + if tc.disabled { + return + } + msgs := []string{fmt.Sprintf("test name: %s", tc.name)} + msgs = append(msgs, fmt.Sprintf("input:\n%v", tc.input)) + input, err := tests.UnpackDict(tc.input) + if err != nil { + t.Fatalf("prereq failed: %v", err) + } + got, err := ExtractTags(input) + if tests.EvalErrWithLog(t, err, "ExtractTags", tc.shouldErr, tc.err, msgs) { + return + } + tests.EvalObjectsWithLog(t, "ExtractTags", tc.want, got, msgs) + }) + } +} - // addr := GetSourceAddress(r) - // if addr != test.result { - // t.Logf("FAIL: %s, received: %s", testDescr, addr) - // testFailed++ - // continue - // } - // t.Logf("PASS: %s", testDescr) - // } +func TestExtractLabels(t *testing.T) { - // if testFailed > 0 { - // t.Fatalf("Failed %d tests", testFailed) - // } + testcases := []struct { + name string + input string + want []string + shouldErr bool + err error + disabled bool + }{ + { + name: "test extract labels with one label", + disabled: false, + input: `{ + "labels": ["foo"] + }`, + want: []string{"foo"}, + }, + { + name: "test extract labels with multiple labels", + disabled: false, + input: `{ + "labels": ["foo", "bar"] + }`, + want: []string{"foo", "bar"}, + }, + { + name: "test extract labels without any labels", + disabled: false, + input: `{ + "labels": [] + }`, + want: []string{}, + }, + } + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + if tc.disabled { + return + } + msgs := []string{fmt.Sprintf("test name: %s", tc.name)} + msgs = append(msgs, fmt.Sprintf("input:\n%v", tc.input)) + input, err := tests.UnpackDict(tc.input) + if err != nil { + t.Fatalf("prereq failed: %v", err) + } + got, err := ExtractLabels(input) + if tests.EvalErrWithLog(t, err, "ExtractLabels", tc.shouldErr, tc.err, msgs) { + return + } + tests.EvalObjectsWithLog(t, "ExtractLabels", tc.want, got, msgs) + }) + } }