diff --git a/cmd/api/src/api/registration/v2.go b/cmd/api/src/api/registration/v2.go index c09eeb4aed..4d347c0e85 100644 --- a/cmd/api/src/api/registration/v2.go +++ b/cmd/api/src/api/registration/v2.go @@ -31,6 +31,7 @@ import ( "github.com/specterops/bloodhound/src/auth" "github.com/specterops/bloodhound/src/config" "github.com/specterops/bloodhound/src/database" + "github.com/specterops/bloodhound/src/model/appcfg" ) func samlWriteAPIErrorResponse(request *http.Request, response http.ResponseWriter, statusCode int, message string) { @@ -60,6 +61,9 @@ func registerV2Auth(cfg config.Configuration, db database.Database, permissions routerInst.GET(fmt.Sprintf("/api/v2/saml/providers/{%s}", api.URIPathVariableSAMLProviderID), managementResource.GetSAMLProvider).RequirePermissions(permissions.AuthManageProviders), routerInst.DELETE(fmt.Sprintf("/api/v2/saml/providers/{%s}", api.URIPathVariableSAMLProviderID), managementResource.DeleteSAMLProvider).RequirePermissions(permissions.AuthManageProviders), + // SSO + routerInst.POST("/api/v2/sso/providers/oidc", managementResource.CreateOIDCProvider).CheckFeatureFlag(db, appcfg.FeatureOIDCSupport).RequirePermissions(permissions.AuthManageProviders), + // Permissions routerInst.GET("/api/v2/permissions", managementResource.ListPermissions).RequirePermissions(permissions.AuthManageSelf), routerInst.GET(fmt.Sprintf("/api/v2/permissions/{%s}", api.URIPathVariablePermissionID), managementResource.GetPermission).RequirePermissions(permissions.AuthManageSelf), diff --git a/cmd/api/src/api/v2/auth/oidc.go b/cmd/api/src/api/v2/auth/oidc.go new file mode 100644 index 0000000000..c71d6c26e5 --- /dev/null +++ b/cmd/api/src/api/v2/auth/oidc.go @@ -0,0 +1,58 @@ +// Copyright 2024 Specter Ops, Inc. +// +// Licensed under the Apache License, Version 2.0 +// 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. +// +// SPDX-License-Identifier: Apache-2.0 + +package auth + +import ( + "net/http" + "strings" + + "github.com/specterops/bloodhound/src/utils/validation" + + "github.com/specterops/bloodhound/src/api" +) + +// CreateOIDCProviderRequest represents the body of the CreateOIDCProvider endpoint +type CreateOIDCProviderRequest struct { + Name string `json:"name" validate:"required"` + Issuer string `json:"issuer" validate:"url"` + ClientID string `json:"client_id" validate:"required"` +} + +// CreateOIDCProvider creates an OIDC provider entry given a valid request +func (s ManagementResource) CreateOIDCProvider(response http.ResponseWriter, request *http.Request) { + var ( + createRequest = CreateOIDCProviderRequest{} + ) + + if err := api.ReadJSONRequestPayloadLimited(&createRequest, request); err != nil { + api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusBadRequest, err.Error(), request), response) + } else if validated := validation.Validate(createRequest); validated != nil { + api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusBadRequest, validated.Error(), request), response) + } else if strings.Contains(createRequest.Name, " ") { + api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusBadRequest, "invalid name formatting, ensure there are no spaces in the provided name", request), response) + } else { + var ( + formattedName = strings.ToLower(createRequest.Name) + ) + + if provider, err := s.db.CreateOIDCProvider(request.Context(), formattedName, createRequest.Issuer, createRequest.ClientID); err != nil { + api.HandleDatabaseError(request, response, err) + } else { + api.WriteBasicResponse(request.Context(), provider, http.StatusCreated, response) + } + } +} diff --git a/cmd/api/src/api/v2/auth/oidc_test.go b/cmd/api/src/api/v2/auth/oidc_test.go new file mode 100644 index 0000000000..5bed75ba50 --- /dev/null +++ b/cmd/api/src/api/v2/auth/oidc_test.go @@ -0,0 +1,118 @@ +// Copyright 2024 Specter Ops, Inc. +// +// Licensed under the Apache License, Version 2.0 +// 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. +// +// SPDX-License-Identifier: Apache-2.0 + +package auth_test + +import ( + "fmt" + "net/http" + + "github.com/specterops/bloodhound/src/model" + + "github.com/specterops/bloodhound/src/api/v2/auth" + + "github.com/specterops/bloodhound/src/api/v2/apitest" + "github.com/specterops/bloodhound/src/utils/test" + + "testing" + + "go.uber.org/mock/gomock" +) + +func TestManagementResource_CreateOIDCProvider(t *testing.T) { + const ( + url = "/api/v2/sso/providers/oidc" + ) + var ( + mockCtrl = gomock.NewController(t) + resources, mockDB = apitest.NewAuthManagementResource(mockCtrl) + ) + defer mockCtrl.Finish() + + t.Run("successfully create a new OIDCProvider", func(t *testing.T) { + mockDB.EXPECT().CreateOIDCProvider(gomock.Any(), "test", "https://localhost/auth", "bloodhound").Return(model.OIDCProvider{ + Name: "", + ClientID: "", + Issuer: "", + }, nil) + + test.Request(t). + WithMethod(http.MethodPost). + WithURL(url). + WithBody(auth.CreateOIDCProviderRequest{ + Name: "test", + Issuer: "https://localhost/auth", + + ClientID: "bloodhound", + }). + OnHandlerFunc(resources.CreateOIDCProvider). + Require(). + ResponseStatusCode(http.StatusCreated) + }) + + t.Run("error parsing body request", func(t *testing.T) { + test.Request(t). + WithMethod(http.MethodPost). + WithURL(url). + WithBody(""). + OnHandlerFunc(resources.CreateOIDCProvider). + Require(). + ResponseStatusCode(http.StatusBadRequest) + }) + + t.Run("error validating request field", func(t *testing.T) { + test.Request(t). + WithMethod(http.MethodPost). + WithURL(url). + WithBody(auth.CreateOIDCProviderRequest{ + Name: "test", + Issuer: "", + }). + OnHandlerFunc(resources.CreateOIDCProvider). + Require(). + ResponseStatusCode(http.StatusBadRequest) + }) + + t.Run("error invalid Issuer", func(t *testing.T) { + request := auth.CreateOIDCProviderRequest{ + Issuer: "12345:bloodhound", + } + test.Request(t). + WithMethod(http.MethodPost). + WithURL(url). + WithBody(request). + OnHandlerFunc(resources.CreateOIDCProvider). + Require(). + ResponseStatusCode(http.StatusBadRequest) + }) + + t.Run("error creating oidc provider db entry", func(t *testing.T) { + mockDB.EXPECT().CreateOIDCProvider(gomock.Any(), "test", "https://localhost/auth", "bloodhound").Return(model.OIDCProvider{}, fmt.Errorf("error")) + + test.Request(t). + WithMethod(http.MethodPost). + WithURL(url). + WithBody(auth.CreateOIDCProviderRequest{ + Name: "test", + Issuer: "https://localhost/auth", + + ClientID: "bloodhound", + }). + OnHandlerFunc(resources.CreateOIDCProvider). + Require(). + ResponseStatusCode(http.StatusInternalServerError) + }) +} diff --git a/cmd/api/src/database/db.go b/cmd/api/src/database/db.go index a5965fdd97..5c00f9a08c 100644 --- a/cmd/api/src/database/db.go +++ b/cmd/api/src/database/db.go @@ -133,6 +133,9 @@ type Database interface { GetSAMLProviderUsers(ctx context.Context, id int32) (model.Users, error) DeleteSAMLProvider(ctx context.Context, samlProvider model.SAMLProvider) error + // SSO + OIDCProviderData + // Sessions CreateUserSession(ctx context.Context, userSession model.UserSession) (model.UserSession, error) SetUserSessionFlag(ctx context.Context, userSession *model.UserSession, key model.SessionFlagKey, state bool) error diff --git a/cmd/api/src/database/migration/migrations/v6.0.0.sql b/cmd/api/src/database/migration/migrations/v6.0.0.sql index 11bc76ba7d..1b48588722 100644 --- a/cmd/api/src/database/migration/migrations/v6.0.0.sql +++ b/cmd/api/src/database/migration/migrations/v6.0.0.sql @@ -16,41 +16,65 @@ -- Add Citrix RDP INSERT INTO parameters (key, name, description, value, created_at, updated_at) -VALUES ('analysis.citrix_rdp_support', 'Citrix RDP Support', 'This configuration parameter toggles Citrix support during post-processing. When enabled, computers identified with a ''Direct Access Users'' local group will assume that Citrix is installed and CanRDP edges will require membership of both ''Direct Access Users'' and ''Remote Desktop Users'' local groups on the computer.', '{"enabled": false}',current_timestamp,current_timestamp) ON CONFLICT DO NOTHING; +VALUES ('analysis.citrix_rdp_support', 'Citrix RDP Support', + 'This configuration parameter toggles Citrix support during post-processing. When enabled, computers identified with a ''Direct Access Users'' local group will assume that Citrix is installed and CanRDP edges will require membership of both ''Direct Access Users'' and ''Remote Desktop Users'' local groups on the computer.', + '{ + "enabled": false + }', current_timestamp, current_timestamp) +ON CONFLICT DO NOTHING; -- Add Prune TTLs -INSERT INTO parameters (key, name, description, value, created_at, updated_at) VALUES ('prune.ttl', 'Prune Retention TTL Configuration Parameters', 'This configuration parameter sets the retention TTLs during analysis pruning.', '{"base_ttl": "P7D", "has_session_edge_ttl": "P3D"}', current_timestamp, current_timestamp) ON CONFLICT DO NOTHING; +INSERT INTO parameters (key, name, description, value, created_at, updated_at) +VALUES ('prune.ttl', 'Prune Retention TTL Configuration Parameters', + 'This configuration parameter sets the retention TTLs during analysis pruning.', '{ + "base_ttl": "P7D", + "has_session_edge_ttl": "P3D" + }', current_timestamp, current_timestamp) +ON CONFLICT DO NOTHING; -- Add Reconciliation to parameters and remove from feature_flags -INSERT INTO parameters (key, name, description, value, created_at, updated_at) VALUES ('analysis.reconciliation', 'Reconciliation', 'This configuration parameter enables / disables reconciliation during analysis.', format('{"enabled": %s}', (SELECT COALESCE((SELECT enabled FROM feature_flags WHERE key = 'reconciliation'), TRUE))::text)::json, current_timestamp, current_timestamp) ON CONFLICT DO NOTHING; +INSERT INTO parameters (key, name, description, value, created_at, updated_at) +VALUES ('analysis.reconciliation', 'Reconciliation', + 'This configuration parameter enables / disables reconciliation during analysis.', format('{"enabled": %s}', + (SELECT COALESCE( + (SELECT enabled FROM feature_flags WHERE key = 'reconciliation'), + TRUE))::text)::json, + current_timestamp, current_timestamp) +ON CONFLICT DO NOTHING; -- must occur after insert to ensure reconciliation flag is set to whatever current value is -DELETE FROM feature_flags WHERE key = 'reconciliation'; +DELETE +FROM feature_flags +WHERE key = 'reconciliation'; -- Grant the Read-Only user SavedQueriesRead permissions -INSERT INTO roles_permissions (role_id, permission_id) VALUES ((SELECT id FROM roles WHERE roles.name = 'Read-Only'), (SELECT id FROM permissions WHERE permissions.authority = 'saved_queries' and permissions.name = 'Read')) ON CONFLICT DO NOTHING; +INSERT INTO roles_permissions (role_id, permission_id) +VALUES ((SELECT id FROM roles WHERE roles.name = 'Read-Only'), + (SELECT id FROM permissions WHERE permissions.authority = 'saved_queries' and permissions.name = 'Read')) +ON CONFLICT DO NOTHING; -- Add OIDC Support feature flag INSERT INTO feature_flags (created_at, updated_at, key, name, description, enabled, user_updatable) -VALUES ( - current_timestamp, - current_timestamp, - 'oidc_support', - 'OIDC Support', - 'Enables OpenID Connect authentication support for SSO Authentication.', - false, - false -) +VALUES (current_timestamp, + current_timestamp, + 'oidc_support', + 'OIDC Support', + 'Enables OpenID Connect authentication support for SSO Authentication.', + false, + false) ON CONFLICT DO NOTHING; do $$ begin -- Update existing Edge tables with an additional constraint to support ON CONFLICT upserts - alter table edge drop constraint if exists edge_graph_id_start_id_end_id_kind_id_key; - alter table edge add constraint edge_graph_id_start_id_end_id_kind_id_key unique (graph_id, start_id, end_id, kind_id); + alter table edge + drop constraint if exists edge_graph_id_start_id_end_id_kind_id_key; + alter table edge + add constraint edge_graph_id_start_id_end_id_kind_id_key unique (graph_id, start_id, end_id, kind_id); exception -- This guards against the possibility that the edge table doesn't exist, in which case there's no constraint to -- migrate when undefined_table then null; end $$; + diff --git a/cmd/api/src/database/migration/migrations/v6.1.0.sql b/cmd/api/src/database/migration/migrations/v6.1.0.sql new file mode 100644 index 0000000000..98807069aa --- /dev/null +++ b/cmd/api/src/database/migration/migrations/v6.1.0.sql @@ -0,0 +1,13 @@ +-- OIDC Provider +CREATE TABLE IF NOT EXISTS oidc_providers +( + id BIGSERIAL PRIMARY KEY, + name TEXT NOT NULL, + client_id TEXT NOT NULL, + issuer TEXT NOT NULL, + + updated_at TIMESTAMP WITH TIME ZONE DEFAULT now(), + created_at TIMESTAMP WITH TIME ZONE DEFAULT now(), + + UNIQUE (name) +) diff --git a/cmd/api/src/database/mocks/db.go b/cmd/api/src/database/mocks/db.go index 3e644c0091..801c641738 100644 --- a/cmd/api/src/database/mocks/db.go +++ b/cmd/api/src/database/mocks/db.go @@ -273,6 +273,21 @@ func (mr *MockDatabaseMockRecorder) CreateInstallation(arg0 interface{}) *gomock return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateInstallation", reflect.TypeOf((*MockDatabase)(nil).CreateInstallation), arg0) } +// CreateOIDCProvider mocks base method. +func (m *MockDatabase) CreateOIDCProvider(arg0 context.Context, arg1, arg2, arg3 string) (model.OIDCProvider, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateOIDCProvider", arg0, arg1, arg2, arg3) + ret0, _ := ret[0].(model.OIDCProvider) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateOIDCProvider indicates an expected call of CreateOIDCProvider. +func (mr *MockDatabaseMockRecorder) CreateOIDCProvider(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateOIDCProvider", reflect.TypeOf((*MockDatabase)(nil).CreateOIDCProvider), arg0, arg1, arg2, arg3) +} + // CreateSAMLIdentityProvider mocks base method. func (m *MockDatabase) CreateSAMLIdentityProvider(arg0 context.Context, arg1 model.SAMLProvider) (model.SAMLProvider, error) { m.ctrl.T.Helper() diff --git a/cmd/api/src/database/oidc.go b/cmd/api/src/database/oidc.go new file mode 100644 index 0000000000..0c8d776b94 --- /dev/null +++ b/cmd/api/src/database/oidc.go @@ -0,0 +1,39 @@ +// Copyright 2024 Specter Ops, Inc. +// +// Licensed under the Apache License, Version 2.0 +// 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. +// +// SPDX-License-Identifier: Apache-2.0 + +package database + +import ( + "context" + + "github.com/specterops/bloodhound/src/model" +) + +// OIDCProviderData defines the interface required to interact with the oidc_providers table +type OIDCProviderData interface { + CreateOIDCProvider(ctx context.Context, name, issuer, clientID string) (model.OIDCProvider, error) +} + +// CreateOIDCProvider creates a new entry for an OIDC provider +func (s *BloodhoundDB) CreateOIDCProvider(ctx context.Context, name, issuer, clientID string) (model.OIDCProvider, error) { + provider := model.OIDCProvider{ + Name: name, + ClientID: clientID, + Issuer: issuer, + } + + return provider, CheckError(s.db.WithContext(ctx).Table("oidc_providers").Create(&provider)) +} diff --git a/cmd/api/src/database/oidc_test.go b/cmd/api/src/database/oidc_test.go new file mode 100644 index 0000000000..12993dd563 --- /dev/null +++ b/cmd/api/src/database/oidc_test.go @@ -0,0 +1,47 @@ +// Copyright 2024 Specter Ops, Inc. +// +// Licensed under the Apache License, Version 2.0 +// 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. +// +// SPDX-License-Identifier: Apache-2.0 + +//go:build integration +// +build integration + +package database_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/specterops/bloodhound/src/test/integration" + "github.com/stretchr/testify/require" +) + +func TestBloodhoundDB_CreateOIDCProvider(t *testing.T) { + var ( + testCtx = context.Background() + dbInst = integration.SetupDB(t) + ) + defer dbInst.Close(testCtx) + + t.Run("successfully create an OIDC provider", func(t *testing.T) { + provider, err := dbInst.CreateOIDCProvider(testCtx, "test", "https://test.localhost.com/auth", "bloodhound") + require.NoError(t, err) + + assert.Equal(t, "test", provider.Name) + assert.Equal(t, "https://test.localhost.com/auth", provider.Issuer) + assert.Equal(t, "bloodhound", provider.ClientID) + }) +} diff --git a/cmd/api/src/model/oidc.go b/cmd/api/src/model/oidc.go new file mode 100644 index 0000000000..a461ea613f --- /dev/null +++ b/cmd/api/src/model/oidc.go @@ -0,0 +1,25 @@ +// Copyright 2024 Specter Ops, Inc. +// +// Licensed under the Apache License, Version 2.0 +// 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. +// +// SPDX-License-Identifier: Apache-2.0 + +package model + +type OIDCProvider struct { + Name string `json:"name"` + ClientID string `json:"client_id"` + Issuer string `json:"issuer"` + + BigSerial +} diff --git a/cmd/api/src/utils/validation/registry.go b/cmd/api/src/utils/validation/registry.go index d58d971eb7..0aa6e95a29 100644 --- a/cmd/api/src/utils/validation/registry.go +++ b/cmd/api/src/utils/validation/registry.go @@ -50,5 +50,6 @@ func init() { "password": NewPasswordValidator, "required": NewRequiredValidator, "duration": NewDurationValidator, + "url": NewUrlValidator, }} } diff --git a/cmd/api/src/utils/validation/url_validator.go b/cmd/api/src/utils/validation/url_validator.go new file mode 100644 index 0000000000..1a802d5cd8 --- /dev/null +++ b/cmd/api/src/utils/validation/url_validator.go @@ -0,0 +1,86 @@ +// Copyright 2024 Specter Ops, Inc. +// +// Licensed under the Apache License, Version 2.0 +// 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. +// +// SPDX-License-Identifier: Apache-2.0 + +package validation + +import ( + "errors" + "fmt" + "net/url" + "reflect" + "strconv" + "strings" + + "github.com/specterops/bloodhound/src/utils" +) + +const ( + ErrorUrlHttpsInvalid = "invalid https url format" + ErrorUrlInvalid = "invalid url format" +) + +// UrlValidator implements the Validator interface which allows the usage of the `url` struct tag +// to ensure a string is in a valid url format by calling `Validate` +type UrlValidator struct { + httpsOnly bool +} + +// NewUrlValidator returns a new Validator +func NewUrlValidator(params map[string]string) Validator { + validator := UrlValidator{} + + if val, ok := params["httpsOnly"]; ok { + validator.httpsOnly, _ = strconv.ParseBool(val) + } + + return validator +} + +// Validate validates that the associated struct fields are in the proper formatting +func (s UrlValidator) Validate(value any) utils.Errors { + var ( + errs = utils.Errors{} + inputUrl, ok = value.(string) + ) + + if !ok { + errs = append(errs, fmt.Errorf("expected a string value, got %s", reflect.TypeOf(value))) + } + + if s.httpsOnly { + if !strings.HasPrefix(inputUrl, "https://") { + errs = append(errs, errors.New(ErrorUrlHttpsInvalid)) + } + } + + if err := validUrl(inputUrl); err != nil { + errs = append(errs, errors.New(ErrorUrlInvalid)) + } + + if len(errs) > 0 { + return errs + } + + return nil +} + +func validUrl(inputUrl string) error { + if _, err := url.ParseRequestURI(inputUrl); err != nil { + return err + } + + return nil +} diff --git a/cmd/api/src/utils/validation/url_validator_test.go b/cmd/api/src/utils/validation/url_validator_test.go new file mode 100644 index 0000000000..7f66632a51 --- /dev/null +++ b/cmd/api/src/utils/validation/url_validator_test.go @@ -0,0 +1,56 @@ +// Copyright 2024 Specter Ops, Inc. +// +// Licensed under the Apache License, Version 2.0 +// 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. +// +// SPDX-License-Identifier: Apache-2.0 + +package validation_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/stretchr/testify/require" + + "github.com/specterops/bloodhound/src/utils/validation" +) + +func TestUrlValidator(t *testing.T) { + t.Run("Valid URL", func(t *testing.T) { + type testStruct struct { + URL string `validate:"url"` + } + + errs := validation.Validate(&testStruct{URL: "http://test.com"}) + require.Len(t, errs, 0) + }) + + t.Run("Invalid HTTP URL", func(t *testing.T) { + type testStruct struct { + URL string `validate:"url"` + } + errs := validation.Validate(&testStruct{URL: "bloodhound"}) + require.Len(t, errs, 1) + assert.ErrorContains(t, errs[0], validation.ErrorUrlInvalid) + }) + + t.Run("Invalid HTTPS URL", func(t *testing.T) { + type testStruct struct { + URL string `validate:"url,httpsOnly=true"` + } + errs := validation.Validate(&testStruct{URL: "http://bloodhound.com"}) + require.Len(t, errs, 1) + assert.ErrorContains(t, errs[0], validation.ErrorUrlHttpsInvalid) + }) +}