diff --git a/api/buf.yaml b/api/buf.yaml index 80cfd02d29..17febb211c 100644 --- a/api/buf.yaml +++ b/api/buf.yaml @@ -65,6 +65,7 @@ lint: - ttn/lorawan/v3/contact_info.proto - ttn/lorawan/v3/deviceclaimingserver.proto - ttn/lorawan/v3/devicerepository.proto + - ttn/lorawan/v3/email_validation.proto - ttn/lorawan/v3/end_device_services.proto - ttn/lorawan/v3/gateway_services.proto - ttn/lorawan/v3/gatewayserver.proto @@ -88,6 +89,7 @@ lint: - ttn/lorawan/v3/contact_info.proto - ttn/lorawan/v3/deviceclaimingserver.proto - ttn/lorawan/v3/devicerepository.proto + - ttn/lorawan/v3/email_validation.proto - ttn/lorawan/v3/end_device_services.proto - ttn/lorawan/v3/events.proto - ttn/lorawan/v3/gateway_services.proto @@ -112,6 +114,7 @@ lint: - ttn/lorawan/v3/contact_info.proto - ttn/lorawan/v3/deviceclaimingserver.proto - ttn/lorawan/v3/devicerepository.proto + - ttn/lorawan/v3/email_validation.proto - ttn/lorawan/v3/end_device_services.proto - ttn/lorawan/v3/events.proto - ttn/lorawan/v3/gateway_services.proto @@ -138,6 +141,7 @@ lint: - ttn/lorawan/v3/contact_info.proto - ttn/lorawan/v3/deviceclaimingserver.proto - ttn/lorawan/v3/devicerepository.proto + - ttn/lorawan/v3/email_validation.proto - ttn/lorawan/v3/end_device_services.proto - ttn/lorawan/v3/events.proto - ttn/lorawan/v3/gateway_services.proto diff --git a/api/ttn/lorawan/v3/api.md b/api/ttn/lorawan/v3/api.md index 3858c6d751..61cbca85b2 100644 --- a/api/ttn/lorawan/v3/api.md +++ b/api/ttn/lorawan/v3/api.md @@ -228,6 +228,10 @@ - [Service `DeviceRepository`](#ttn.lorawan.v3.DeviceRepository) - [File `ttn/lorawan/v3/email_messages.proto`](#ttn/lorawan/v3/email_messages.proto) - [Message `CreateClientEmailMessage`](#ttn.lorawan.v3.CreateClientEmailMessage) +- [File `ttn/lorawan/v3/email_validation.proto`](#ttn/lorawan/v3/email_validation.proto) + - [Message `EmailValidation`](#ttn.lorawan.v3.EmailValidation) + - [Message `ValidateEmailRequest`](#ttn.lorawan.v3.ValidateEmailRequest) + - [Service `EmailValidationRegistry`](#ttn.lorawan.v3.EmailValidationRegistry) - [File `ttn/lorawan/v3/end_device.proto`](#ttn/lorawan/v3/end_device.proto) - [Message `ADRSettings`](#ttn.lorawan.v3.ADRSettings) - [Message `ADRSettings.DisabledMode`](#ttn.lorawan.v3.ADRSettings.DisabledMode) @@ -3624,6 +3628,57 @@ CreateClientEmailMessage is used as a wrapper for handling the email regarding t | `create_client_request` | [`CreateClientRequest`](#ttn.lorawan.v3.CreateClientRequest) | | | | `api_key` | [`APIKey`](#ttn.lorawan.v3.APIKey) | | | +## File `ttn/lorawan/v3/email_validation.proto` + +### Message `EmailValidation` + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| `id` | [`string`](#string) | | | +| `token` | [`string`](#string) | | | +| `address` | [`string`](#string) | | | +| `created_at` | [`google.protobuf.Timestamp`](#google.protobuf.Timestamp) | | | +| `expires_at` | [`google.protobuf.Timestamp`](#google.protobuf.Timestamp) | | | +| `updated_at` | [`google.protobuf.Timestamp`](#google.protobuf.Timestamp) | | | + +#### Field Rules + +| Field | Validations | +| ----- | ----------- | +| `id` |

`string.min_len`: `1`

`string.max_len`: `64`

| +| `token` |

`string.min_len`: `1`

`string.max_len`: `64`

| +| `address` |

`string.email`: `true`

| + +### Message `ValidateEmailRequest` + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| `id` | [`string`](#string) | | | +| `token` | [`string`](#string) | | | + +#### Field Rules + +| Field | Validations | +| ----- | ----------- | +| `id` |

`string.min_len`: `1`

`string.max_len`: `64`

| +| `token` |

`string.min_len`: `1`

`string.max_len`: `64`

| + +### Service `EmailValidationRegistry` + +The EmailValidationRegistry service, exposed by the Identity Server, is used for validating an user's primary email. + +| Method Name | Request Type | Response Type | Description | +| ----------- | ------------ | ------------- | ------------| +| `RequestValidation` | [`UserIdentifiers`](#ttn.lorawan.v3.UserIdentifiers) | [`EmailValidation`](#ttn.lorawan.v3.EmailValidation) | Request validation for the non-validated contact info for the given entity. | +| `Validate` | [`ValidateEmailRequest`](#ttn.lorawan.v3.ValidateEmailRequest) | [`.google.protobuf.Empty`](#google.protobuf.Empty) | Validate confirms a contact info validation. | + +#### HTTP bindings + +| Method Name | Method | Pattern | Body | +| ----------- | ------ | ------- | ---- | +| `RequestValidation` | `POST` | `/api/v3/email/validation` | `*` | +| `Validate` | `PATCH` | `/api/v3/email/validation` | `*` | + ## File `ttn/lorawan/v3/end_device.proto` ### Message `ADRSettings` diff --git a/api/ttn/lorawan/v3/api.swagger.json b/api/ttn/lorawan/v3/api.swagger.json index 3e0c55824a..c8032a6909 100644 --- a/api/ttn/lorawan/v3/api.swagger.json +++ b/api/ttn/lorawan/v3/api.swagger.json @@ -62,6 +62,9 @@ { "name": "DeviceRepository" }, + { + "name": "EmailValidationRegistry" + }, { "name": "EndDeviceRegistry" }, @@ -6470,6 +6473,71 @@ ] } }, + "/email/validation": { + "post": { + "summary": "Request validation for the non-validated contact info for the given entity.", + "operationId": "EmailValidationRegistry_RequestValidation", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/v3EmailValidation" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/googlerpcStatus" + } + } + }, + "parameters": [ + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/v3UserIdentifiers" + } + } + ], + "tags": [ + "EmailValidationRegistry" + ] + }, + "patch": { + "summary": "Validate confirms a contact info validation.", + "operationId": "EmailValidationRegistry_Validate", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "type": "object", + "properties": {} + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/googlerpcStatus" + } + } + }, + "parameters": [ + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/v3ValidateEmailRequest" + } + } + ], + "tags": [ + "EmailValidationRegistry" + ] + } + }, "/events": { "post": { "summary": "Stream live events, optionally with a tail of historical events (depending on server support and retention policy).\nEvents may arrive out-of-order.", @@ -20094,6 +20162,32 @@ "default": "DOWNLINK_PATH_CONSTRAINT_NONE", "description": " - DOWNLINK_PATH_CONSTRAINT_NONE: Indicates that the gateway can be selected for downlink without constraints by the Network Server.\n - DOWNLINK_PATH_CONSTRAINT_PREFER_OTHER: Indicates that the gateway can be selected for downlink only if no other or better gateway can be selected.\n - DOWNLINK_PATH_CONSTRAINT_NEVER: Indicates that this gateway will never be selected for downlink, even if that results in no available downlink path." }, + "v3EmailValidation": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "token": { + "type": "string" + }, + "address": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "expires_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, "v3EncodeDownlinkResponse": { "type": "object", "properties": { @@ -26225,6 +26319,17 @@ } } }, + "v3ValidateEmailRequest": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "token": { + "type": "string" + } + } + }, "v3ZeroableFrequencyValue": { "type": "object", "properties": { diff --git a/api/ttn/lorawan/v3/email_validation.proto b/api/ttn/lorawan/v3/email_validation.proto new file mode 100644 index 0000000000..659b7af87c --- /dev/null +++ b/api/ttn/lorawan/v3/email_validation.proto @@ -0,0 +1,69 @@ +// Copyright © 2023 The Things Network Foundation, The Things Industries B.V. +// +// 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. + +syntax = "proto3"; + +package ttn.lorawan.v3; + +import "google/api/annotations.proto"; +import "google/protobuf/empty.proto"; +import "google/protobuf/timestamp.proto"; +import "ttn/lorawan/v3/identifiers.proto"; +import "validate/validate.proto"; + +option go_package = "go.thethings.network/lorawan-stack/v3/pkg/ttnpb"; + +message EmailValidation { + string id = 1 [(validate.rules).string = { + max_len: 64, + min_len: 1 + }]; + string token = 2 [(validate.rules).string = { + max_len: 64, + min_len: 1 + }]; + string address = 3 [(validate.rules).string.email = true]; + google.protobuf.Timestamp created_at = 4; + google.protobuf.Timestamp expires_at = 5; + google.protobuf.Timestamp updated_at = 6; +} + +message ValidateEmailRequest { + string id = 1 [(validate.rules).string = { + max_len: 64, + min_len: 1 + }]; + string token = 2 [(validate.rules).string = { + max_len: 64, + min_len: 1 + }]; +} + +// The EmailValidationRegistry service, exposed by the Identity Server, is used for validating an user's primary email. +service EmailValidationRegistry { + // Request validation for the non-validated contact info for the given entity. + rpc RequestValidation(UserIdentifiers) returns (EmailValidation) { + option (google.api.http) = { + post: "/email/validation" + body: "*" + }; + } + // Validate confirms a contact info validation. + rpc Validate(ValidateEmailRequest) returns (google.protobuf.Empty) { + option (google.api.http) = { + patch: "/email/validation" + body: "*" + }; + } +} diff --git a/cmd/ttn-lw-cli/commands/contact_info.go b/cmd/ttn-lw-cli/commands/contact_info.go index adfe9fe018..6743a02c43 100644 --- a/cmd/ttn-lw-cli/commands/contact_info.go +++ b/cmd/ttn-lw-cli/commands/contact_info.go @@ -136,8 +136,6 @@ func updateContactInfo(entityID *ttnpb.EntityIdentifiers, updater func([]*ttnpb. var ( errContactInfoExists = errors.DefineAlreadyExists("contact_info_exists", "contact info already exists") errMatchingContactInfoNotFound = errors.DefineAlreadyExists("contact_info_not_found", "matching contact info not found") - errNoValidationReference = errors.DefineInvalidArgument("no_validation_reference", "no validation reference set") - errNoValidationToken = errors.DefineInvalidArgument("no_validation_token", "no validation token set") ) func contactInfoCommands(entity string, getID func(cmd *cobra.Command, args []string) (*ttnpb.EntityIdentifiers, error)) *cobra.Command { @@ -228,8 +226,9 @@ func contactInfoCommands(entity string, getID func(cmd *cobra.Command, args []st }, } requestValidation := &cobra.Command{ - Use: fmt.Sprintf("request-validation [%s-id]", entity), - Short: "Request validation for entity contact info", + Use: fmt.Sprintf("request-validation [%s-id]", entity), + Short: "Request validation for entity contact info (DEPRECATED. Use `user email-validation request` instead.", + Hidden: true, RunE: func(cmd *cobra.Command, args []string) error { id, err := getID(cmd, args) if err != nil { @@ -247,9 +246,9 @@ func contactInfoCommands(entity string, getID func(cmd *cobra.Command, args []st }, } validate := &cobra.Command{ - Use: "validate [reference] [token]", - Short: "Validate contact info", - Long: "Validate contact info by providing the reference and the validation token that you received", + Use: "validate [reference] [token]", + Short: "Validate contact info (DEPRECATED. Use `user email-validation validate` instead.", + Hidden: true, RunE: func(cmd *cobra.Command, args []string) error { reference, _ := cmd.Flags().GetString("reference") token, _ := cmd.Flags().GetString("token") diff --git a/cmd/ttn-lw-cli/commands/users_email_validation.go b/cmd/ttn-lw-cli/commands/users_email_validation.go new file mode 100644 index 0000000000..f60e31f36e --- /dev/null +++ b/cmd/ttn-lw-cli/commands/users_email_validation.go @@ -0,0 +1,104 @@ +// Copyright © 2023 The Things Network Foundation, The Things Industries B.V. +// +// 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 commands + +import ( + "os" + + "github.com/spf13/cobra" + "go.thethings.network/lorawan-stack/v3/cmd/internal/io" + "go.thethings.network/lorawan-stack/v3/cmd/ttn-lw-cli/internal/api" + "go.thethings.network/lorawan-stack/v3/pkg/errors" + "go.thethings.network/lorawan-stack/v3/pkg/ttnpb" +) + +var ( + errNoValidationReference = errors.DefineInvalidArgument("no_validation_reference", "no validation reference set") + errNoValidationToken = errors.DefineInvalidArgument("no_validation_token", "no validation token set") +) + +var ( + emailValidations = &cobra.Command{ + Use: "email-validations", + Aliases: []string{"ev", "email-validation", "email-validations"}, + Short: "Email validations commands", + } + + emailValidationsValidate = &cobra.Command{ + Use: "validate [reference] [token]", + Aliases: []string{"v"}, + Short: "Validate an user's email address", + RunE: func(cmd *cobra.Command, args []string) error { + reference, _ := cmd.Flags().GetString("reference") + token, _ := cmd.Flags().GetString("token") + switch len(args) { + case 1: + reference = args[0] + case 2: + reference = args[0] + token = args[1] + default: + } + if reference == "" { + return errNoValidationReference.New() + } + if token == "" { + return errNoValidationToken.New() + } + is, err := api.Dial(ctx, config.IdentityServerGRPCAddress) + if err != nil { + return err + } + res, err := ttnpb.NewEmailValidationRegistryClient(is).Validate(ctx, &ttnpb.ValidateEmailRequest{ + Id: reference, + Token: token, + }) + if err != nil { + return err + } + return io.Write(os.Stdout, config.OutputFormat, res) + }, + } + + emailValidationsRequest = &cobra.Command{ + Use: "request [user-id]", + Aliases: []string{"r"}, + Short: "Request validation for an user's email address", + RunE: func(cmd *cobra.Command, args []string) error { + usrID := getUserID(cmd.Flags(), args) + if usrID == nil { + return errNoUserID.New() + } + is, err := api.Dial(ctx, config.IdentityServerGRPCAddress) + if err != nil { + return err + } + res, err := ttnpb.NewEmailValidationRegistryClient(is).RequestValidation(ctx, usrID) + if err != nil { + return err + } + return io.Write(os.Stdout, config.OutputFormat, res) + }, + } +) + +func init() { + emailValidationsValidate.Flags().String("reference", "", "Reference of the requested validation") + emailValidationsValidate.Flags().String("token", "", "Token that you received") + emailValidationsRequest.PersistentFlags().AddFlagSet(userIDFlags()) + emailValidations.AddCommand(emailValidationsValidate) + emailValidations.AddCommand(emailValidationsRequest) + usersCommand.AddCommand(emailValidations) +} diff --git a/config/messages.json b/config/messages.json index eb08d2739b..476702020d 100644 --- a/config/messages.json +++ b/config/messages.json @@ -1895,7 +1895,7 @@ }, "description": { "package": "cmd/ttn-lw-cli/commands", - "file": "contact_info.go" + "file": "users_email_validation.go" } }, "error:cmd/ttn-lw-cli/commands:no_validation_token": { @@ -1904,7 +1904,7 @@ }, "description": { "package": "cmd/ttn-lw-cli/commands", - "file": "contact_info.go" + "file": "users_email_validation.go" } }, "error:cmd/ttn-lw-cli/commands:no_webhook_id": { @@ -6362,6 +6362,15 @@ "file": "entity_access.go" } }, + "error:pkg/identityserver:validation_request_forbidden": { + "translations": { + "en": "validation request forbidden because it was already sent, wait `{retry_interval}` before retrying" + }, + "description": { + "package": "pkg/identityserver", + "file": "email_validation_registry.go" + } + }, "error:pkg/identityserver:validations_already_sent": { "translations": { "en": "validations for this contact info already sent, wait `{retry_interval}` before retrying" diff --git a/pkg/identityserver/bunstore/email_validations.go b/pkg/identityserver/bunstore/email_validations.go new file mode 100644 index 0000000000..40f93f8e25 --- /dev/null +++ b/pkg/identityserver/bunstore/email_validations.go @@ -0,0 +1,266 @@ +// Copyright © 2023 The Things Network Foundation, The Things Industries B.V. +// +// 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 store + +import ( + "context" + "time" + + "github.com/uptrace/bun" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" + "go.thethings.network/lorawan-stack/v3/pkg/errors" + "go.thethings.network/lorawan-stack/v3/pkg/identityserver/store" + "go.thethings.network/lorawan-stack/v3/pkg/telemetry/tracing/tracer" + "go.thethings.network/lorawan-stack/v3/pkg/ttnpb" + storeutil "go.thethings.network/lorawan-stack/v3/pkg/util/store" + "google.golang.org/protobuf/types/known/timestamppb" +) + +// EmailValidation is the contact info validation model in the database. +type EmailValidation struct { + bun.BaseModel `bun:"table:email_validations,alias:ev"` + Model + + ExpiresAt *time.Time `bun:"expires_at"` + + Reference string `bun:"reference,nullzero,notnull"` + Token string `bun:"token,nullzero,notnull"` + Used bool `bun:"used"` + + UserUUID string `bun:"user_uuid,nullzero,notnull"` + EmailAddress string `bun:"email_address,nullzero,notnull"` +} + +// BeforeAppendModel is a hook that modifies the model on SELECT and UPDATE queries. +func (m *EmailValidation) BeforeAppendModel(ctx context.Context, query bun.Query) error { + if err := m.Model.BeforeAppendModel(ctx, query); err != nil { + return err + } + return nil +} + +func (m *EmailValidation) toPB() *ttnpb.EmailValidation { + val := &ttnpb.EmailValidation{ + Id: m.Reference, + Token: m.Token, + CreatedAt: ttnpb.ProtoTime(&m.CreatedAt), + ExpiresAt: ttnpb.ProtoTime(m.ExpiresAt), + UpdatedAt: ttnpb.ProtoTime(&m.UpdatedAt), + Address: m.EmailAddress, + } + return val +} + +type emailValidationStore struct{ *entityStore } + +func newEmailValidationStore(baseStore *baseStore) *emailValidationStore { + return &emailValidationStore{entityStore: newEntityStore(baseStore)} +} + +func (s *emailValidationStore) getEmailValidationModelBy( + ctx context.Context, + by func(*bun.SelectQuery) *bun.SelectQuery, +) (*EmailValidation, error) { + model := &EmailValidation{} + selectQuery := newSelectModel(ctx, s.DB, model).Apply(by) + err := selectQuery.Scan(ctx) + if err != nil { + return nil, storeutil.WrapDriverError(err) + } + return model, nil +} + +func (s *emailValidationStore) CreateEmailValidation( + ctx context.Context, pb *ttnpb.EmailValidation, +) (*ttnpb.EmailValidation, error) { + ctx, span := tracer.StartFromContext(ctx, "CreateEmailValidation") + defer span.End() + + usrModel, err := s.getUserModelBy(ctx, s.userStore.selectWithPrimaryEmailAddress(ctx, pb.Address), nil) + if err != nil { + return nil, err + } + + n, err := s.newSelectModel(ctx, &EmailValidation{}). + Where("?TableAlias.user_uuid = ?", usrModel.ID). + Where("LOWER(?TableAlias.email_address) = LOWER(?)", pb.Address). + Where("?TableAlias.used = false"). + Where("?TableAlias.expires_at IS NULL OR ?TableAlias.expires_at > NOW()"). + Count(ctx) + if err != nil { + return nil, storeutil.WrapDriverError(err) + } + if n > 0 { + return nil, store.ErrValidationAlreadySent.New() + } + + model := &EmailValidation{ + Reference: pb.Id, + Token: pb.Token, + UserUUID: usrModel.ID, + EmailAddress: pb.Address, + ExpiresAt: cleanTimePtr(ttnpb.StdTime(pb.ExpiresAt)), + } + + _, err = s.DB.NewInsert().Model(model).Exec(ctx) + if err != nil { + return nil, storeutil.WrapDriverError(err) + } + + return &ttnpb.EmailValidation{ + Id: model.Reference, + Token: model.Token, + Address: pb.Address, + CreatedAt: timestamppb.New(model.CreatedAt), + ExpiresAt: ttnpb.ProtoTime(model.ExpiresAt), + }, nil +} + +func (s *emailValidationStore) GetEmailValidation( + ctx context.Context, pb *ttnpb.EmailValidation, +) (*ttnpb.EmailValidation, error) { + ctx, span := tracer.StartFromContext(ctx, "GetEmailValidation") + defer span.End() + + model, err := s.getEmailValidationModelBy(ctx, func(q *bun.SelectQuery) *bun.SelectQuery { + return q.Where("?TableAlias.reference = ? AND ?TableAlias.token = ?", pb.Id, pb.Token) + }) + if err != nil { + if errors.IsNotFound(err) { + return nil, store.ErrValidationTokenNotFound.WithAttributes("validation_id", pb.Id) + } + return nil, err + } + + if model.Used { + return nil, store.ErrValidationTokenAlreadyUsed.WithAttributes("validation_id", pb.Id) + } + + if model.ExpiresAt != nil && model.ExpiresAt.Before(s.now()) { + return nil, store.ErrValidationTokenExpired.WithAttributes("validation_id", pb.Id) + } + + return model.toPB(), nil +} + +func (s *emailValidationStore) ExpireEmailValidation(ctx context.Context, pb *ttnpb.EmailValidation) error { + ctx, span := tracer.StartFromContext(ctx, "ExpireEmailValidation") + defer span.End() + + model, err := s.getEmailValidationModelBy(ctx, func(q *bun.SelectQuery) *bun.SelectQuery { + return q.Where("?TableAlias.reference = ? AND ?TableAlias.token = ?", pb.Id, pb.Token) + }) + if err != nil { + return err + } + + usrModel, err := s.getUserModelBy(ctx, s.userStore.selectWithPrimaryEmailAddress(ctx, model.EmailAddress), nil) + if err != nil { + return err + } + + if time.Now().After(*model.ExpiresAt) { + return store.ErrValidationTokenExpired + } + if model.Used { + return store.ErrValidationTokenAlreadyUsed + } + + validatedAt := now() + err = s.transact(ctx, func(ctx context.Context, tx bun.IDB) error { + _, err = tx.NewUpdate(). + Model(usrModel). + WherePK(). + Set("primary_email_address_validated_at = ?", validatedAt). + Exec(ctx) + if err != nil { + return err + } + + _, err = tx.NewUpdate(). + Model(model). + WherePK(). + Set("expires_at = ?, used = true", validatedAt). + Exec(ctx) + if err != nil { + return err + } + return nil + }) + if err != nil { + return storeutil.WrapDriverError(err) + } + + return nil +} + +// GetRefreshableEmailValidation returns a not used validation for a given user. +func (s *emailValidationStore) GetRefreshableEmailValidation( + ctx context.Context, id *ttnpb.UserIdentifiers, refreshInterval time.Duration, +) (*ttnpb.EmailValidation, error) { + ctx, span := tracer.StartFromContext(ctx, "GetRefreshableEmailValidation", trace.WithAttributes( + attribute.String("refresh_interval", refreshInterval.String()), + )) + defer span.End() + + usrModel, err := s.getUserModelBy( + ctx, s.userStore.selectWithID(ctx, id.GetUserId()), []string{"primary_email_address"}, + ) + if err != nil { + return nil, err + } + + model, err := s.getEmailValidationModelBy(ctx, func(q *bun.SelectQuery) *bun.SelectQuery { + return q. + Where("?TableAlias.user_uuid = ?", usrModel.ID). + Where("LOWER(?TableAlias.email_address) = LOWER(?)", usrModel.PrimaryEmailAddress). + Where("?TableAlias.used = false"). + Where("?TableAlias.expires_at IS NULL OR ?TableAlias.expires_at > NOW()"). + Where("?TableAlias.updated_at <= (NOW() - interval ?)", refreshInterval.String()) + }) + if err != nil { + return nil, err + } + + return model.toPB(), nil +} + +// RefreshEmailValidation refreshes a email validation for an user. +func (s *emailValidationStore) RefreshEmailValidation(ctx context.Context, pb *ttnpb.EmailValidation) error { + ctx, span := tracer.StartFromContext(ctx, "RefreshEmailValidation") + defer span.End() + + model, err := s.getEmailValidationModelBy(ctx, func(q *bun.SelectQuery) *bun.SelectQuery { + return q. + Where("reference = ? AND token = ?", pb.Id, pb.Token). + Where("?TableAlias.used = false"). + // Done in order to avoid concurrent updates from happening in the same validation. + Where("updated_at <= ?", pb.UpdatedAt.AsTime()) + }) + if err != nil { + if errors.IsNotFound(err) { + return store.ErrValidationTokenNotFound.WithAttributes("validation_id", pb.Id) + } + return err + } + + _, err = s.DB.NewUpdate().Model(model).WherePK().Exec(ctx) + if err != nil { + return storeutil.WrapDriverError(err) + } + + return nil +} diff --git a/pkg/identityserver/bunstore/registry.go b/pkg/identityserver/bunstore/registry.go index 3502950417..e25c0b497b 100644 --- a/pkg/identityserver/bunstore/registry.go +++ b/pkg/identityserver/bunstore/registry.go @@ -28,30 +28,31 @@ func registerModels(m ...any) { func init() { registerModels( + &AccessToken{}, &Account{}, &APIKey{}, &Application{}, &Attribute{}, + &AuthorizationCode{}, + &ClientAuthorization{}, &Client{}, - &ContactInfo{}, &ContactInfoValidation{}, - &EndDevice{}, + &ContactInfo{}, + &EmailValidation{}, &EndDeviceLocation{}, + &EndDevice{}, &EUIBlock{}, - &Gateway{}, &GatewayAntenna{}, + &Gateway{}, &Invitation{}, &LoginToken{}, &Membership{}, - &Notification{}, &NotificationReceiver{}, - &ClientAuthorization{}, - &AuthorizationCode{}, - &AccessToken{}, + &Notification{}, &Organization{}, &Picture{}, - &User{}, &UserSession{}, + &User{}, ) } diff --git a/pkg/identityserver/bunstore/store.go b/pkg/identityserver/bunstore/store.go index ed7c116c1d..6f0e937b76 100644 --- a/pkg/identityserver/bunstore/store.go +++ b/pkg/identityserver/bunstore/store.go @@ -117,22 +117,23 @@ func newStore(baseStore *baseStore) *Store { return &Store{ baseStore: baseStore, - applicationStore: newApplicationStore(baseStore), - clientStore: newClientStore(baseStore), - endDeviceStore: newEndDeviceStore(baseStore), - gatewayStore: newGatewayStore(baseStore), - organizationStore: newOrganizationStore(baseStore), - userStore: newUserStore(baseStore), - userSessionStore: newUserSessionStore(baseStore), - apiKeyStore: newAPIKeyStore(baseStore), - membershipStore: newMembershipStore(baseStore), - contactInfoStore: newContactInfoStore(baseStore), - invitationStore: newInvitationStore(baseStore), - loginTokenStore: newLoginTokenStore(baseStore), - oauthStore: newOAuthStore(baseStore), - euiStore: newEUIStore(baseStore), - entitySearch: newEntitySearch(baseStore), - notificationStore: newNotificationStore(baseStore), + apiKeyStore: newAPIKeyStore(baseStore), + applicationStore: newApplicationStore(baseStore), + clientStore: newClientStore(baseStore), + contactInfoStore: newContactInfoStore(baseStore), + emailValidationStore: newEmailValidationStore(baseStore), + endDeviceStore: newEndDeviceStore(baseStore), + entitySearch: newEntitySearch(baseStore), + euiStore: newEUIStore(baseStore), + gatewayStore: newGatewayStore(baseStore), + invitationStore: newInvitationStore(baseStore), + loginTokenStore: newLoginTokenStore(baseStore), + membershipStore: newMembershipStore(baseStore), + notificationStore: newNotificationStore(baseStore), + oauthStore: newOAuthStore(baseStore), + organizationStore: newOrganizationStore(baseStore), + userSessionStore: newUserSessionStore(baseStore), + userStore: newUserStore(baseStore), } } @@ -151,22 +152,23 @@ type Store struct { *baseStore + *apiKeyStore *applicationStore *clientStore + *contactInfoStore + *emailValidationStore *endDeviceStore + *entitySearch + *euiStore *gatewayStore - *organizationStore - *userStore - *userSessionStore - *apiKeyStore - *membershipStore - *contactInfoStore *invitationStore *loginTokenStore - *oauthStore - *euiStore - *entitySearch + *membershipStore *notificationStore + *oauthStore + *organizationStore + *userSessionStore + *userStore } const ( diff --git a/pkg/identityserver/bunstore/store_test.go b/pkg/identityserver/bunstore/store_test.go index 8e8ce96358..a9e22e08e2 100644 --- a/pkg/identityserver/bunstore/store_test.go +++ b/pkg/identityserver/bunstore/store_test.go @@ -149,6 +149,13 @@ func TestContactInfoStore(t *testing.T) { st.TestContactInfoStoreCRUD(t) } +func TestEmailValidationStore(t *testing.T) { + t.Parallel() + + st := storetest.New(t, newTestStore) + st.TestEmailValidationStore(t) +} + func TestInvitationStore(t *testing.T) { t.Parallel() diff --git a/pkg/identityserver/email_validation_registry.go b/pkg/identityserver/email_validation_registry.go new file mode 100644 index 0000000000..eb1d11f013 --- /dev/null +++ b/pkg/identityserver/email_validation_registry.go @@ -0,0 +1,177 @@ +// Copyright © 2023 The Things Network Foundation, The Things Industries B.V. +// +// 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 identityserver + +import ( + "context" + "time" + + "go.thethings.network/lorawan-stack/v3/pkg/auth" + "go.thethings.network/lorawan-stack/v3/pkg/auth/rights" + "go.thethings.network/lorawan-stack/v3/pkg/email" + "go.thethings.network/lorawan-stack/v3/pkg/email/templates" + "go.thethings.network/lorawan-stack/v3/pkg/errors" + "go.thethings.network/lorawan-stack/v3/pkg/identityserver/store" + "go.thethings.network/lorawan-stack/v3/pkg/log" + "go.thethings.network/lorawan-stack/v3/pkg/ttnpb" + "google.golang.org/protobuf/types/known/emptypb" + "google.golang.org/protobuf/types/known/timestamppb" +) + +var errValidationRequestForbidden = errors.DefinePermissionDenied( + "validation_request_forbidden", + "validation request forbidden because it was already sent, wait `{retry_interval}` before retrying", +) + +type emailValidationRegistry struct { + ttnpb.UnimplementedEmailValidationRegistryServer + + *IdentityServer +} + +func (is *IdentityServer) refreshEmailValidation( + ctx context.Context, usrID *ttnpb.UserIdentifiers, +) (*ttnpb.EmailValidation, error) { + ttl := is.configFromContext(ctx).UserRegistration.ContactInfoValidation.TokenTTL + expires := time.Now().Add(ttl) + retryInterval := is.configFromContext(ctx).UserRegistration.ContactInfoValidation.RetryInterval + + var validation *ttnpb.EmailValidation + err := is.store.Transact(ctx, func(ctx context.Context, st store.Store) (err error) { + validation, err = st.GetRefreshableEmailValidation(ctx, usrID, retryInterval) + if err != nil { + return err + } + validation.ExpiresAt = timestamppb.New(expires) + if err = st.RefreshEmailValidation(ctx, validation); err != nil { + return err + } + return nil + }) + if err != nil { + return nil, err + } + return validation, err +} + +func (is *IdentityServer) requestEmailValidation( + ctx context.Context, usrID *ttnpb.UserIdentifiers, +) (*ttnpb.EmailValidation, error) { + var validation *ttnpb.EmailValidation + err := is.store.Transact(ctx, func(ctx context.Context, st store.Store) (err error) { + // Attempts to find and refresh an existing validation. If it doesn't exist, validation is `nil`. + validation, err = is.refreshEmailValidation(ctx, usrID) + if err != nil && !errors.IsNotFound(err) { + return err + } + + if validation == nil { + id, err := auth.GenerateID(ctx) + if err != nil { + return err + } + token, err := auth.GenerateKey(ctx) + if err != nil { + return err + } + usr, err := st.GetUser(ctx, usrID, []string{"primary_email_address"}) + if err != nil { + return err + } + validation = &ttnpb.EmailValidation{ + Id: id, + Token: token, + Address: usr.PrimaryEmailAddress, + ExpiresAt: timestamppb.New( + time.Now().Add(is.configFromContext(ctx).UserRegistration.ContactInfoValidation.TokenTTL), + ), + } + validation, err = st.CreateEmailValidation(ctx, validation) + if err != nil { + // Only one validation can exist at a time, so if it already exists, return a forbidden error. + if errors.IsAlreadyExists(err) { + return errValidationRequestForbidden.WithAttributes( + "retry_interval", + is.configFromContext(ctx).UserRegistration.ContactInfoValidation.RetryInterval, + ) + } + return err + } + } + + return nil + }) + if err != nil { + return nil, err + } + + // Prepare validateData outside of goroutine to avoid issues with range variable or races with unsetting the Token. + validateData := &templates.ValidateData{ + EntityIdentifiers: usrID.GetEntityIdentifiers(), + ID: validation.Id, + Token: validation.Token, + TTL: time.Until(validation.ExpiresAt.AsTime()), + } + log.FromContext(ctx).WithFields(log.Fields( + "email", validation.Address, + "Id", validation.Id, + )).Info("Sending validation email") + go is.SendTemplateEmailToUsers( // nolint:errcheck + is.Component.FromRequestContext(ctx), + "validate", + func(_ context.Context, data email.TemplateData) (email.TemplateData, error) { + validateData.TemplateData = data + return validateData, nil + }, + &ttnpb.User{PrimaryEmailAddress: validation.Address}, + ) + validation.Token = "" // Unset tokens after sending emails + return validation, nil +} + +func (evr *emailValidationRegistry) validateEmail( + ctx context.Context, + req *ttnpb.ValidateEmailRequest, +) (*emptypb.Empty, error) { + err := evr.store.Transact(ctx, func(ctx context.Context, st store.Store) error { + validation, err := st.GetEmailValidation(ctx, &ttnpb.EmailValidation{Id: req.Id, Token: req.Token}) + if err != nil { + return err + } + return st.ExpireEmailValidation(ctx, validation) + }) + if err != nil { + return nil, err + } + return ttnpb.Empty, nil +} + +func (evr *emailValidationRegistry) RequestValidation( + ctx context.Context, + usrID *ttnpb.UserIdentifiers, +) (*ttnpb.EmailValidation, error) { + err := rights.RequireUser(ctx, usrID, ttnpb.Right_RIGHT_USER_SETTINGS_BASIC) + if err != nil { + return nil, err + } + return evr.requestEmailValidation(ctx, usrID) +} + +func (evr *emailValidationRegistry) Validate( + ctx context.Context, + req *ttnpb.ValidateEmailRequest, +) (*emptypb.Empty, error) { + return evr.validateEmail(ctx, req) +} diff --git a/pkg/identityserver/email_validation_registry_test.go b/pkg/identityserver/email_validation_registry_test.go new file mode 100644 index 0000000000..f5a0ce4ffd --- /dev/null +++ b/pkg/identityserver/email_validation_registry_test.go @@ -0,0 +1,126 @@ +// Copyright © 2023 The Things Network Foundation, The Things Industries B.V. +// +// 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 identityserver + +import ( + "testing" + "time" + + "go.thethings.network/lorawan-stack/v3/pkg/errors" + "go.thethings.network/lorawan-stack/v3/pkg/identityserver/storetest" + "go.thethings.network/lorawan-stack/v3/pkg/ttnpb" + "go.thethings.network/lorawan-stack/v3/pkg/util/test" + "go.thethings.network/lorawan-stack/v3/pkg/util/test/assertions/should" + "google.golang.org/grpc" +) + +func TestEmailValidation(t *testing.T) { + p := &storetest.Population{} + + usr1 := p.NewUser() + usr1.PrimaryEmailAddress = "usr1@email.com" + usr1Key, _ := p.NewAPIKey(usr1.GetEntityIdentifiers(), ttnpb.Right_RIGHT_ALL) + usr1Creds := rpcCreds(usr1Key) + + usr2 := p.NewUser() + usr2.PrimaryEmailAddress = "usr2@email.com" + usr2Key, _ := p.NewAPIKey(usr2.GetEntityIdentifiers(), ttnpb.Right_RIGHT_ALL) + usr2Creds := rpcCreds(usr2Key) + + testWithIdentityServer(t, func(is *IdentityServer, cc *grpc.ClientConn) { + // Configuration necessary for testing refresh of validation email. + retryInterval := test.Delay << 6 + tokenTTL := test.Delay << 8 + is.config.UserRegistration.ContactInfoValidation.Required = true + is.config.UserRegistration.ContactInfoValidation.TokenTTL = tokenTTL + is.config.UserRegistration.ContactInfoValidation.RetryInterval = retryInterval + + reg := ttnpb.NewEmailValidationRegistryClient(cc) + usrReg := ttnpb.NewUserRegistryClient(cc) + + t.Run("Request validation", func(t *testing.T) { // nolint:paralleltest + a, ctx := test.New(t) + + // No rights. + _, err := reg.RequestValidation(ctx, usr1.Ids) + a.So(err, should.NotBeNil) + a.So(errors.IsPermissionDenied(err), should.BeTrue) + + // Proper Request. + validation, err := reg.RequestValidation(ctx, usr1.Ids, usr1Creds) + a.So(err, should.BeNil) + a.So(validation.Address, should.Equal, usr1.PrimaryEmailAddress) + a.So(len(validation.Id), should.BeGreaterThan, 0) + a.So(len(validation.Id), should.BeLessThanOrEqualTo, 64) + a.So(len(validation.Token), should.Equal, 0) // Token is emptied before sending to the response. + + // Request again before retry interval is passed, expect already exists error for this user's email address. + _, err = reg.RequestValidation(ctx, usr1.Ids, usr1Creds) + a.So(err, should.NotBeNil) + a.So(errors.IsPermissionDenied(err), should.BeTrue) + + time.Sleep(retryInterval) + + // This should trigger the refresh of the validation email. + newValidation, err := reg.RequestValidation(ctx, usr1.Ids, usr1Creds) + a.So(err, should.BeNil) + a.So(newValidation.Address, should.Equal, validation.Address) + a.So(newValidation.Id, should.Equal, validation.Id) + }) + + t.Run("Validate", func(t *testing.T) { // nolint:paralleltest + a, ctx := test.New(t) + + // Token's value is a secret which is only known to the user and the DB. For testing purposes, we fetch the + // validation information based on the email address and then validate the token. + val, err := is.store.GetRefreshableEmailValidation(ctx, usr1.Ids, 0) + if !a.So(err, should.BeNil) { + t.FailNow() + } + + _, err = reg.Validate(ctx, &ttnpb.ValidateEmailRequest{Id: val.Id, Token: val.Token}) + a.So(err, should.BeNil) + + usr, err := usrReg.Get( + ctx, + &ttnpb.GetUserRequest{ + UserIds: usr1.Ids, + FieldMask: ttnpb.FieldMask("primary_email_address_validated_at"), + }, + usr1Creds, + ) + a.So(err, should.BeNil) + a.So(usr.PrimaryEmailAddressValidatedAt, should.NotBeNil) + }) + + t.Run("Use expired token", func(t *testing.T) { // nolint:paralleltest + a, ctx := test.New(t) + validation, err := reg.RequestValidation(ctx, usr2.Ids, usr2Creds) + a.So(err, should.BeNil) + a.So(validation.Address, should.Equal, usr2.PrimaryEmailAddress) + + // Token's value is a secret which is only known to the user and the DB. For testing purposes, we fetch the + // validation information based on the email address and then validate the token. + val, err := is.store.GetRefreshableEmailValidation(ctx, usr2.Ids, 0) + if !a.So(err, should.BeNil) { + t.FailNow() + } + time.Sleep(tokenTTL) + + _, err = reg.Validate(ctx, &ttnpb.ValidateEmailRequest{Id: val.Id, Token: val.Token}) + a.So(errors.IsFailedPrecondition(err), should.BeTrue) + }) + }, withPrivateTestDatabase(p)) +} diff --git a/pkg/identityserver/identityserver.go b/pkg/identityserver/identityserver.go index c068ae877b..365ed0b078 100644 --- a/pkg/identityserver/identityserver.go +++ b/pkg/identityserver/identityserver.go @@ -224,6 +224,7 @@ func New(c *component.Component, config *Config) (is *IdentityServer, err error) "/ttn.lorawan.v3.EntityRegistrySearch", "/ttn.lorawan.v3.EndDeviceRegistrySearch", "/ttn.lorawan.v3.ContactInfoRegistry", + "/ttn.lorawan.v3.EmailValidationRegistry", "/ttn.lorawan.v3.OAuthAuthorizationRegistry", } { c.GRPC.RegisterUnaryHook(filter, hook.name, hook.middleware) @@ -261,6 +262,7 @@ func (is *IdentityServer) RegisterServices(s *grpc.Server) { ttnpb.RegisterEndDeviceRegistrySearchServer(s, ®istrySearch{IdentityServer: is}) ttnpb.RegisterOAuthAuthorizationRegistryServer(s, &oauthRegistry{IdentityServer: is}) ttnpb.RegisterContactInfoRegistryServer(s, &contactInfoRegistry{IdentityServer: is}) + ttnpb.RegisterEmailValidationRegistryServer(s, &emailValidationRegistry{IdentityServer: is}) ttnpb.RegisterNotificationServiceServer(s, ¬ificationRegistry{IdentityServer: is}) ttnpb.RegisterEndDeviceBatchRegistryServer(s, &endDeviceBatchRegistry{IdentityServer: is}) } @@ -288,6 +290,7 @@ func (is *IdentityServer) RegisterHandlers(s *runtime.ServeMux, conn *grpc.Clien ttnpb.RegisterEndDeviceRegistrySearchHandler(is.Context(), s, conn) ttnpb.RegisterOAuthAuthorizationRegistryHandler(is.Context(), s, conn) ttnpb.RegisterContactInfoRegistryHandler(is.Context(), s, conn) + ttnpb.RegisterEmailValidationRegistryHandler(is.Context(), s, conn) ttnpb.RegisterNotificationServiceHandler(is.Context(), s, conn) ttnpb.RegisterEndDeviceBatchRegistryHandler(is.Context(), s, conn) // nolint:errcheck } diff --git a/pkg/identityserver/store/migrations/20231206000000_email_validations.down.sql b/pkg/identityserver/store/migrations/20231206000000_email_validations.down.sql new file mode 100644 index 0000000000..293df23ad7 --- /dev/null +++ b/pkg/identityserver/store/migrations/20231206000000_email_validations.down.sql @@ -0,0 +1,8 @@ +INSERT INTO contact_info_validations +(created_at, updated_at, reference, token, entity_id, entity_type, contact_method, value, used, expires_at) +SELECT created_at, updated_at, reference, token, user_uuid, 'user', 1, email_address, false, expires_at +FROM email_validations +where (expires_at > now() or expires_at is null) and used = false; + +--bun:split +DROP TABLE IF EXISTS email_validations; diff --git a/pkg/identityserver/store/migrations/20231206000000_email_validations.up.sql b/pkg/identityserver/store/migrations/20231206000000_email_validations.up.sql new file mode 100644 index 0000000000..38e54e06b5 --- /dev/null +++ b/pkg/identityserver/store/migrations/20231206000000_email_validations.up.sql @@ -0,0 +1,28 @@ +CREATE TABLE email_validations ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone NOT NULL, + expires_at timestamp with time zone, + + user_uuid uuid NOT NULL, + email_address character varying NOT NULL, + reference character varying NOT NULL, + token character varying NOT NULL, + used boolean DEFAULT FALSE NOT NULL +); + +CREATE UNIQUE INDEX email_validation_index ON email_validations USING btree (reference, token); + +--bun:split +INSERT INTO email_validations (created_at, updated_at, expires_at, user_uuid, email_address, reference, token, used) +SELECT created_at, updated_at, expires_at, entity_id, value, reference, token, false +FROM contact_info_validations +WHERE contact_method=1 + AND entity_type='user' + AND (used = false OR used IS NULL) + AND (expires_at > now() OR expires_at IS null); + +--bun:split +delete from contact_info_validations +where contact_method = 1 and entity_type = 'user' +; diff --git a/pkg/identityserver/store/store_interfaces.go b/pkg/identityserver/store/store_interfaces.go index 170164d2f1..a99d33039f 100644 --- a/pkg/identityserver/store/store_interfaces.go +++ b/pkg/identityserver/store/store_interfaces.go @@ -355,6 +355,19 @@ type ContactInfoStore interface { ) ([]*ttnpb.ContactInfoValidation, error) } +// EmailValidationStore interface for email validation. +type EmailValidationStore interface { + CreateEmailValidation(ctx context.Context, validation *ttnpb.EmailValidation) (*ttnpb.EmailValidation, error) + GetEmailValidation(ctx context.Context, validation *ttnpb.EmailValidation) (*ttnpb.EmailValidation, error) + ExpireEmailValidation(ctx context.Context, validation *ttnpb.EmailValidation) error + // GetRefreshableEmailValidation returns a not used validation for a given user. + GetRefreshableEmailValidation( + ctx context.Context, id *ttnpb.UserIdentifiers, refreshInterval time.Duration, + ) (*ttnpb.EmailValidation, error) + // RefreshEmailValidation refreshes a email validation for an user. + RefreshEmailValidation(ctx context.Context, validation *ttnpb.EmailValidation) error +} + // EUIStore interface for assigning DevEUI blocks and addresses. type EUIStore interface { CreateEUIBlock( @@ -399,6 +412,7 @@ type Store interface { EUIStore NotificationStore EntitySearch + EmailValidationStore } // TransactionalStore is Store, but with a method that uses a transaction. diff --git a/pkg/identityserver/storetest/contact_info_store.go b/pkg/identityserver/storetest/contact_info_store.go index 38c24bfd6f..687a08d073 100644 --- a/pkg/identityserver/storetest/contact_info_store.go +++ b/pkg/identityserver/storetest/contact_info_store.go @@ -139,7 +139,6 @@ func (st *StoreTest) TestContactInfoStoreCRUD(t *T) { t.Run("ListRefreshableValidations", func(t *T) { // nolint:paralleltest t.Run("valid refresh interval", func(t *T) { // nolint:paralleltest a, ctx := test.New(t) - // Any validation from now validations, err := s.ListRefreshableValidations(ctx, ids, 0) if a.So(err, should.BeNil) && a.So(validations, should.NotBeNil) { a.So(validations, should.HaveLength, 1) @@ -159,8 +158,8 @@ func (st *StoreTest) TestContactInfoStoreCRUD(t *T) { t.Run("invalid refresh interval", func(t *T) { // nolint:paralleltest a, ctx := test.New(t) - invalidRefreshInterval := 2 * time.Since(createAt) - validations, err := s.ListRefreshableValidations(ctx, ids, invalidRefreshInterval) + bigRefreshInterval := 10 * time.Since(createAt) + validations, err := s.ListRefreshableValidations(ctx, ids, bigRefreshInterval) if !a.So(err, should.BeNil) || !a.So(validations, should.HaveLength, 0) { t.Fail() } diff --git a/pkg/identityserver/storetest/email_validations.go b/pkg/identityserver/storetest/email_validations.go new file mode 100644 index 0000000000..bad13977fe --- /dev/null +++ b/pkg/identityserver/storetest/email_validations.go @@ -0,0 +1,121 @@ +// Copyright © 2023 The Things Network Foundation, The Things Industries B.V. +// +// 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 storetest + +import ( + "fmt" + . "testing" + "time" + + "go.thethings.network/lorawan-stack/v3/pkg/errors" + "go.thethings.network/lorawan-stack/v3/pkg/identityserver/store" + "go.thethings.network/lorawan-stack/v3/pkg/ttnpb" + "go.thethings.network/lorawan-stack/v3/pkg/util/test" + "go.thethings.network/lorawan-stack/v3/pkg/util/test/assertions/should" + "google.golang.org/protobuf/types/known/timestamppb" +) + +// TestEmailValidationStore tests the email validation store operations. +func (st *StoreTest) TestEmailValidationStore(t *T) { + usr1 := st.population.NewUser() + usr1.PrimaryEmailAddress = "usr1@email.com" + usr1.PrimaryEmailAddressValidatedAt = nil + + s, ok := st.PrepareDB(t).(interface { + Store + + store.EmailValidationStore + store.UserStore + }) + defer st.DestroyDB(t, false) + if !ok { + t.Skip("Store does not implement ContactInfoStore") + } + defer s.Close() + + start := time.Now().Truncate(time.Second) + validation := &ttnpb.EmailValidation{ + Id: fmt.Sprintf("%s_%s_validation", usr1.EntityType(), usr1.IDString()), + Token: fmt.Sprintf("%s_%s_token", usr1.EntityType(), usr1.IDString()), + Address: usr1.PrimaryEmailAddress, + ExpiresAt: timestamppb.New(start.Add(test.Delay << 10)), + } + + t.Run("Create", func(t *T) { + a, ctx := test.New(t) + v, err := s.CreateEmailValidation(ctx, validation) + a.So(err, should.BeNil) + a.So(v.Id, should.Equal, validation.Id) + a.So(v.Token, should.Equal, validation.Token) + a.So(v.Address, should.Equal, validation.Address) + + _, err = s.CreateEmailValidation(ctx, validation) + a.So(errors.IsAlreadyExists(err), should.BeTrue) + }) + + t.Run("Get", func(t *T) { + a, ctx := test.New(t) + v, err := s.GetEmailValidation(ctx, validation) + a.So(err, should.BeNil) + a.So(v.Id, should.Equal, validation.Id) + a.So(v.Token, should.Equal, validation.Token) + a.So(v.Address, should.Equal, validation.Address) + + // Taking the updateAt timestamp to compare it on the next test. + validation.UpdatedAt = v.UpdatedAt + }) + + t.Run("Get Refreshable", func(t *T) { + a, ctx := test.New(t) + + // Getting a email validation that was updated before (now() - test.Delay << 5). + _, err := s.GetRefreshableEmailValidation(ctx, usr1.Ids, test.Delay<<5) + a.So(errors.IsNotFound(err), should.BeTrue) + + time.Sleep(test.Delay) + + // Getting a email validation that was updated before (now() - test.Delay). + v, err := s.GetRefreshableEmailValidation(ctx, usr1.Ids, test.Delay) + a.So(err, should.BeNil) + a.So(v.Id, should.Equal, validation.Id) + a.So(v.Token, should.Equal, validation.Token) + a.So(v.Address, should.Equal, validation.Address) + a.So(v.UpdatedAt, should.Resemble, validation.UpdatedAt) + }) + + t.Run("Refresh", func(t *T) { + a, ctx := test.New(t) + + err := s.RefreshEmailValidation(ctx, validation) + a.So(err, should.BeNil) + + v, err := s.GetEmailValidation(ctx, validation) + a.So(err, should.BeNil) + a.So(v.Id, should.Equal, validation.Id) + a.So(v.Token, should.Equal, validation.Token) + a.So(v.Address, should.Equal, validation.Address) + a.So(v.UpdatedAt.AsTime().After(validation.UpdatedAt.AsTime()), should.BeTrue) + }) + + t.Run("Expire", func(t *T) { + a, ctx := test.New(t) + + err := s.ExpireEmailValidation(ctx, validation) + a.So(err, should.BeNil) + + _, err = s.GetEmailValidation(ctx, validation) + a.So(errors.IsFailedPrecondition(err), should.BeTrue) + }) +} diff --git a/pkg/identityserver/user_registry.go b/pkg/identityserver/user_registry.go index 2400f2e83e..6ff9c03179 100644 --- a/pkg/identityserver/user_registry.go +++ b/pkg/identityserver/user_registry.go @@ -297,8 +297,8 @@ func (is *IdentityServer) createUser(ctx context.Context, req *ttnpb.CreateUserR }) } - if _, err := is.requestContactInfoValidation(ctx, req.User.GetIds().GetEntityIdentifiers()); err != nil { - log.FromContext(ctx).WithError(err).Error("Could not send contact info validations") + if _, err := is.requestEmailValidation(ctx, usr.GetIds()); err != nil { + log.FromContext(ctx).WithError(err).Error("Could not send user's email validation") } usr.Password = "" // Create doesn't have a FieldMask, so we need to manually remove the password. @@ -578,8 +578,8 @@ func (is *IdentityServer) updateUser(ctx context.Context, req *ttnpb.UpdateUserR // in a indirect changed to the contact info list. And if not validated the same is reflected on the contact info // and a new validation should be requested. if updatePrimaryEmailAddress && usr.PrimaryEmailAddressValidatedAt == nil { - if _, err := is.requestContactInfoValidation(ctx, req.User.GetIds().GetEntityIdentifiers()); err != nil { - log.FromContext(ctx).WithError(err).Error("Could not send contact info validations") + if _, err := is.requestEmailValidation(ctx, usr.GetIds()); err != nil { + log.FromContext(ctx).WithError(err).Error("Could not send user's email validation") } } diff --git a/pkg/ttnpb/email_validation.pb.go b/pkg/ttnpb/email_validation.pb.go new file mode 100644 index 0000000000..3f13fa0ced --- /dev/null +++ b/pkg/ttnpb/email_validation.pb.go @@ -0,0 +1,329 @@ +// Copyright © 2023 The Things Network Foundation, The Things Industries B.V. +// +// 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. + +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.32.0 +// protoc v4.25.1 +// source: ttn/lorawan/v3/email_validation.proto + +package ttnpb + +import ( + _ "github.com/envoyproxy/protoc-gen-validate/validate" + _ "google.golang.org/genproto/googleapis/api/annotations" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + emptypb "google.golang.org/protobuf/types/known/emptypb" + timestamppb "google.golang.org/protobuf/types/known/timestamppb" + reflect "reflect" + sync "sync" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type EmailValidation struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Token string `protobuf:"bytes,2,opt,name=token,proto3" json:"token,omitempty"` + Address string `protobuf:"bytes,3,opt,name=address,proto3" json:"address,omitempty"` + CreatedAt *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` + ExpiresAt *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=expires_at,json=expiresAt,proto3" json:"expires_at,omitempty"` + UpdatedAt *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"` +} + +func (x *EmailValidation) Reset() { + *x = EmailValidation{} + if protoimpl.UnsafeEnabled { + mi := &file_ttn_lorawan_v3_email_validation_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *EmailValidation) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EmailValidation) ProtoMessage() {} + +func (x *EmailValidation) ProtoReflect() protoreflect.Message { + mi := &file_ttn_lorawan_v3_email_validation_proto_msgTypes[0] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use EmailValidation.ProtoReflect.Descriptor instead. +func (*EmailValidation) Descriptor() ([]byte, []int) { + return file_ttn_lorawan_v3_email_validation_proto_rawDescGZIP(), []int{0} +} + +func (x *EmailValidation) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *EmailValidation) GetToken() string { + if x != nil { + return x.Token + } + return "" +} + +func (x *EmailValidation) GetAddress() string { + if x != nil { + return x.Address + } + return "" +} + +func (x *EmailValidation) GetCreatedAt() *timestamppb.Timestamp { + if x != nil { + return x.CreatedAt + } + return nil +} + +func (x *EmailValidation) GetExpiresAt() *timestamppb.Timestamp { + if x != nil { + return x.ExpiresAt + } + return nil +} + +func (x *EmailValidation) GetUpdatedAt() *timestamppb.Timestamp { + if x != nil { + return x.UpdatedAt + } + return nil +} + +type ValidateEmailRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Token string `protobuf:"bytes,2,opt,name=token,proto3" json:"token,omitempty"` +} + +func (x *ValidateEmailRequest) Reset() { + *x = ValidateEmailRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_ttn_lorawan_v3_email_validation_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ValidateEmailRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ValidateEmailRequest) ProtoMessage() {} + +func (x *ValidateEmailRequest) ProtoReflect() protoreflect.Message { + mi := &file_ttn_lorawan_v3_email_validation_proto_msgTypes[1] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ValidateEmailRequest.ProtoReflect.Descriptor instead. +func (*ValidateEmailRequest) Descriptor() ([]byte, []int) { + return file_ttn_lorawan_v3_email_validation_proto_rawDescGZIP(), []int{1} +} + +func (x *ValidateEmailRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *ValidateEmailRequest) GetToken() string { + if x != nil { + return x.Token + } + return "" +} + +var File_ttn_lorawan_v3_email_validation_proto protoreflect.FileDescriptor + +var file_ttn_lorawan_v3_email_validation_proto_rawDesc = []byte{ + 0x0a, 0x25, 0x74, 0x74, 0x6e, 0x2f, 0x6c, 0x6f, 0x72, 0x61, 0x77, 0x61, 0x6e, 0x2f, 0x76, 0x33, + 0x2f, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x5f, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x69, 0x6f, + 0x6e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x0e, 0x74, 0x74, 0x6e, 0x2e, 0x6c, 0x6f, 0x72, + 0x61, 0x77, 0x61, 0x6e, 0x2e, 0x76, 0x33, 0x1a, 0x1c, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, + 0x61, 0x70, 0x69, 0x2f, 0x61, 0x6e, 0x6e, 0x6f, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x2e, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x1b, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, + 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x65, 0x6d, 0x70, 0x74, 0x79, 0x2e, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x62, 0x75, 0x66, 0x2f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x2e, 0x70, 0x72, + 0x6f, 0x74, 0x6f, 0x1a, 0x20, 0x74, 0x74, 0x6e, 0x2f, 0x6c, 0x6f, 0x72, 0x61, 0x77, 0x61, 0x6e, + 0x2f, 0x76, 0x33, 0x2f, 0x69, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x66, 0x69, 0x65, 0x72, 0x73, 0x2e, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x17, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x2f, + 0x76, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0xa1, + 0x02, 0x0a, 0x0f, 0x45, 0x6d, 0x61, 0x69, 0x6c, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x69, + 0x6f, 0x6e, 0x12, 0x19, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x09, + 0xfa, 0x42, 0x06, 0x72, 0x04, 0x10, 0x01, 0x18, 0x40, 0x52, 0x02, 0x69, 0x64, 0x12, 0x1f, 0x0a, + 0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x42, 0x09, 0xfa, 0x42, + 0x06, 0x72, 0x04, 0x10, 0x01, 0x18, 0x40, 0x52, 0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x21, + 0x0a, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x42, + 0x07, 0xfa, 0x42, 0x04, 0x72, 0x02, 0x60, 0x01, 0x52, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, + 0x73, 0x12, 0x39, 0x0a, 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, + 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, + 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, + 0x70, 0x52, 0x09, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x12, 0x39, 0x0a, 0x0a, + 0x65, 0x78, 0x70, 0x69, 0x72, 0x65, 0x73, 0x5f, 0x61, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, + 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, + 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x65, 0x78, + 0x70, 0x69, 0x72, 0x65, 0x73, 0x41, 0x74, 0x12, 0x39, 0x0a, 0x0a, 0x75, 0x70, 0x64, 0x61, 0x74, + 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, + 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, + 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, + 0x41, 0x74, 0x22, 0x52, 0x0a, 0x14, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x45, 0x6d, + 0x61, 0x69, 0x6c, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x19, 0x0a, 0x02, 0x69, 0x64, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x09, 0xfa, 0x42, 0x06, 0x72, 0x04, 0x10, 0x01, 0x18, + 0x40, 0x52, 0x02, 0x69, 0x64, 0x12, 0x1f, 0x0a, 0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x09, 0x42, 0x09, 0xfa, 0x42, 0x06, 0x72, 0x04, 0x10, 0x01, 0x18, 0x40, 0x52, + 0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x32, 0xf6, 0x01, 0x0a, 0x17, 0x45, 0x6d, 0x61, 0x69, 0x6c, + 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, + 0x72, 0x79, 0x12, 0x73, 0x0a, 0x11, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x56, 0x61, 0x6c, + 0x69, 0x64, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x1f, 0x2e, 0x74, 0x74, 0x6e, 0x2e, 0x6c, 0x6f, + 0x72, 0x61, 0x77, 0x61, 0x6e, 0x2e, 0x76, 0x33, 0x2e, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x65, + 0x6e, 0x74, 0x69, 0x66, 0x69, 0x65, 0x72, 0x73, 0x1a, 0x1f, 0x2e, 0x74, 0x74, 0x6e, 0x2e, 0x6c, + 0x6f, 0x72, 0x61, 0x77, 0x61, 0x6e, 0x2e, 0x76, 0x33, 0x2e, 0x45, 0x6d, 0x61, 0x69, 0x6c, 0x56, + 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x1c, 0x82, 0xd3, 0xe4, 0x93, 0x02, + 0x16, 0x3a, 0x01, 0x2a, 0x22, 0x11, 0x2f, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x2f, 0x76, 0x61, 0x6c, + 0x69, 0x64, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x66, 0x0a, 0x08, 0x56, 0x61, 0x6c, 0x69, 0x64, + 0x61, 0x74, 0x65, 0x12, 0x24, 0x2e, 0x74, 0x74, 0x6e, 0x2e, 0x6c, 0x6f, 0x72, 0x61, 0x77, 0x61, + 0x6e, 0x2e, 0x76, 0x33, 0x2e, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x45, 0x6d, 0x61, + 0x69, 0x6c, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, + 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, + 0x79, 0x22, 0x1c, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x16, 0x3a, 0x01, 0x2a, 0x32, 0x11, 0x2f, 0x65, + 0x6d, 0x61, 0x69, 0x6c, 0x2f, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x42, + 0x31, 0x5a, 0x2f, 0x67, 0x6f, 0x2e, 0x74, 0x68, 0x65, 0x74, 0x68, 0x69, 0x6e, 0x67, 0x73, 0x2e, + 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x2f, 0x6c, 0x6f, 0x72, 0x61, 0x77, 0x61, 0x6e, 0x2d, + 0x73, 0x74, 0x61, 0x63, 0x6b, 0x2f, 0x76, 0x33, 0x2f, 0x70, 0x6b, 0x67, 0x2f, 0x74, 0x74, 0x6e, + 0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, +} + +var ( + file_ttn_lorawan_v3_email_validation_proto_rawDescOnce sync.Once + file_ttn_lorawan_v3_email_validation_proto_rawDescData = file_ttn_lorawan_v3_email_validation_proto_rawDesc +) + +func file_ttn_lorawan_v3_email_validation_proto_rawDescGZIP() []byte { + file_ttn_lorawan_v3_email_validation_proto_rawDescOnce.Do(func() { + file_ttn_lorawan_v3_email_validation_proto_rawDescData = protoimpl.X.CompressGZIP(file_ttn_lorawan_v3_email_validation_proto_rawDescData) + }) + return file_ttn_lorawan_v3_email_validation_proto_rawDescData +} + +var file_ttn_lorawan_v3_email_validation_proto_msgTypes = make([]protoimpl.MessageInfo, 2) +var file_ttn_lorawan_v3_email_validation_proto_goTypes = []interface{}{ + (*EmailValidation)(nil), // 0: ttn.lorawan.v3.EmailValidation + (*ValidateEmailRequest)(nil), // 1: ttn.lorawan.v3.ValidateEmailRequest + (*timestamppb.Timestamp)(nil), // 2: google.protobuf.Timestamp + (*UserIdentifiers)(nil), // 3: ttn.lorawan.v3.UserIdentifiers + (*emptypb.Empty)(nil), // 4: google.protobuf.Empty +} +var file_ttn_lorawan_v3_email_validation_proto_depIdxs = []int32{ + 2, // 0: ttn.lorawan.v3.EmailValidation.created_at:type_name -> google.protobuf.Timestamp + 2, // 1: ttn.lorawan.v3.EmailValidation.expires_at:type_name -> google.protobuf.Timestamp + 2, // 2: ttn.lorawan.v3.EmailValidation.updated_at:type_name -> google.protobuf.Timestamp + 3, // 3: ttn.lorawan.v3.EmailValidationRegistry.RequestValidation:input_type -> ttn.lorawan.v3.UserIdentifiers + 1, // 4: ttn.lorawan.v3.EmailValidationRegistry.Validate:input_type -> ttn.lorawan.v3.ValidateEmailRequest + 0, // 5: ttn.lorawan.v3.EmailValidationRegistry.RequestValidation:output_type -> ttn.lorawan.v3.EmailValidation + 4, // 6: ttn.lorawan.v3.EmailValidationRegistry.Validate:output_type -> google.protobuf.Empty + 5, // [5:7] is the sub-list for method output_type + 3, // [3:5] is the sub-list for method input_type + 3, // [3:3] is the sub-list for extension type_name + 3, // [3:3] is the sub-list for extension extendee + 0, // [0:3] is the sub-list for field type_name +} + +func init() { file_ttn_lorawan_v3_email_validation_proto_init() } +func file_ttn_lorawan_v3_email_validation_proto_init() { + if File_ttn_lorawan_v3_email_validation_proto != nil { + return + } + file_ttn_lorawan_v3_identifiers_proto_init() + if !protoimpl.UnsafeEnabled { + file_ttn_lorawan_v3_email_validation_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*EmailValidation); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_ttn_lorawan_v3_email_validation_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ValidateEmailRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_ttn_lorawan_v3_email_validation_proto_rawDesc, + NumEnums: 0, + NumMessages: 2, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_ttn_lorawan_v3_email_validation_proto_goTypes, + DependencyIndexes: file_ttn_lorawan_v3_email_validation_proto_depIdxs, + MessageInfos: file_ttn_lorawan_v3_email_validation_proto_msgTypes, + }.Build() + File_ttn_lorawan_v3_email_validation_proto = out.File + file_ttn_lorawan_v3_email_validation_proto_rawDesc = nil + file_ttn_lorawan_v3_email_validation_proto_goTypes = nil + file_ttn_lorawan_v3_email_validation_proto_depIdxs = nil +} diff --git a/pkg/ttnpb/email_validation.pb.gw.go b/pkg/ttnpb/email_validation.pb.gw.go new file mode 100644 index 0000000000..3259c7a317 --- /dev/null +++ b/pkg/ttnpb/email_validation.pb.gw.go @@ -0,0 +1,240 @@ +// Code generated by protoc-gen-grpc-gateway. DO NOT EDIT. +// source: ttn/lorawan/v3/email_validation.proto + +/* +Package ttnpb is a reverse proxy. + +It translates gRPC into RESTful JSON APIs. +*/ +package ttnpb + +import ( + "context" + "io" + "net/http" + + "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" + "github.com/grpc-ecosystem/grpc-gateway/v2/utilities" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/grpclog" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/proto" +) + +// Suppress "imported and not used" errors +var _ codes.Code +var _ io.Reader +var _ status.Status +var _ = runtime.String +var _ = utilities.NewDoubleArray +var _ = metadata.Join + +func request_EmailValidationRegistry_RequestValidation_0(ctx context.Context, marshaler runtime.Marshaler, client EmailValidationRegistryClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var protoReq UserIdentifiers + var metadata runtime.ServerMetadata + + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && err != io.EOF { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + + msg, err := client.RequestValidation(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err + +} + +func local_request_EmailValidationRegistry_RequestValidation_0(ctx context.Context, marshaler runtime.Marshaler, server EmailValidationRegistryServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var protoReq UserIdentifiers + var metadata runtime.ServerMetadata + + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && err != io.EOF { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + + msg, err := server.RequestValidation(ctx, &protoReq) + return msg, metadata, err + +} + +func request_EmailValidationRegistry_Validate_0(ctx context.Context, marshaler runtime.Marshaler, client EmailValidationRegistryClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var protoReq ValidateEmailRequest + var metadata runtime.ServerMetadata + + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && err != io.EOF { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + + msg, err := client.Validate(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err + +} + +func local_request_EmailValidationRegistry_Validate_0(ctx context.Context, marshaler runtime.Marshaler, server EmailValidationRegistryServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var protoReq ValidateEmailRequest + var metadata runtime.ServerMetadata + + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && err != io.EOF { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + + msg, err := server.Validate(ctx, &protoReq) + return msg, metadata, err + +} + +// RegisterEmailValidationRegistryHandlerServer registers the http handlers for service EmailValidationRegistry to "mux". +// UnaryRPC :call EmailValidationRegistryServer directly. +// StreamingRPC :currently unsupported pending https://github.com/grpc/grpc-go/issues/906. +// Note that using this registration option will cause many gRPC library features to stop working. Consider using RegisterEmailValidationRegistryHandlerFromEndpoint instead. +func RegisterEmailValidationRegistryHandlerServer(ctx context.Context, mux *runtime.ServeMux, server EmailValidationRegistryServer) error { + + mux.Handle("POST", pattern_EmailValidationRegistry_RequestValidation_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + var err error + var annotatedContext context.Context + annotatedContext, err = runtime.AnnotateIncomingContext(ctx, mux, req, "/ttn.lorawan.v3.EmailValidationRegistry/RequestValidation", runtime.WithHTTPPathPattern("/email/validation")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_EmailValidationRegistry_RequestValidation_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + + forward_EmailValidationRegistry_RequestValidation_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + + }) + + mux.Handle("PATCH", pattern_EmailValidationRegistry_Validate_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + var err error + var annotatedContext context.Context + annotatedContext, err = runtime.AnnotateIncomingContext(ctx, mux, req, "/ttn.lorawan.v3.EmailValidationRegistry/Validate", runtime.WithHTTPPathPattern("/email/validation")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_EmailValidationRegistry_Validate_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + + forward_EmailValidationRegistry_Validate_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + + }) + + return nil +} + +// RegisterEmailValidationRegistryHandlerFromEndpoint is same as RegisterEmailValidationRegistryHandler but +// automatically dials to "endpoint" and closes the connection when "ctx" gets done. +func RegisterEmailValidationRegistryHandlerFromEndpoint(ctx context.Context, mux *runtime.ServeMux, endpoint string, opts []grpc.DialOption) (err error) { + conn, err := grpc.DialContext(ctx, endpoint, opts...) + if err != nil { + return err + } + defer func() { + if err != nil { + if cerr := conn.Close(); cerr != nil { + grpclog.Infof("Failed to close conn to %s: %v", endpoint, cerr) + } + return + } + go func() { + <-ctx.Done() + if cerr := conn.Close(); cerr != nil { + grpclog.Infof("Failed to close conn to %s: %v", endpoint, cerr) + } + }() + }() + + return RegisterEmailValidationRegistryHandler(ctx, mux, conn) +} + +// RegisterEmailValidationRegistryHandler registers the http handlers for service EmailValidationRegistry to "mux". +// The handlers forward requests to the grpc endpoint over "conn". +func RegisterEmailValidationRegistryHandler(ctx context.Context, mux *runtime.ServeMux, conn *grpc.ClientConn) error { + return RegisterEmailValidationRegistryHandlerClient(ctx, mux, NewEmailValidationRegistryClient(conn)) +} + +// RegisterEmailValidationRegistryHandlerClient registers the http handlers for service EmailValidationRegistry +// to "mux". The handlers forward requests to the grpc endpoint over the given implementation of "EmailValidationRegistryClient". +// Note: the gRPC framework executes interceptors within the gRPC handler. If the passed in "EmailValidationRegistryClient" +// doesn't go through the normal gRPC flow (creating a gRPC client etc.) then it will be up to the passed in +// "EmailValidationRegistryClient" to call the correct interceptors. +func RegisterEmailValidationRegistryHandlerClient(ctx context.Context, mux *runtime.ServeMux, client EmailValidationRegistryClient) error { + + mux.Handle("POST", pattern_EmailValidationRegistry_RequestValidation_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + var err error + var annotatedContext context.Context + annotatedContext, err = runtime.AnnotateContext(ctx, mux, req, "/ttn.lorawan.v3.EmailValidationRegistry/RequestValidation", runtime.WithHTTPPathPattern("/email/validation")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_EmailValidationRegistry_RequestValidation_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + + forward_EmailValidationRegistry_RequestValidation_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + + }) + + mux.Handle("PATCH", pattern_EmailValidationRegistry_Validate_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + var err error + var annotatedContext context.Context + annotatedContext, err = runtime.AnnotateContext(ctx, mux, req, "/ttn.lorawan.v3.EmailValidationRegistry/Validate", runtime.WithHTTPPathPattern("/email/validation")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_EmailValidationRegistry_Validate_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + + forward_EmailValidationRegistry_Validate_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + + }) + + return nil +} + +var ( + pattern_EmailValidationRegistry_RequestValidation_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"email", "validation"}, "")) + + pattern_EmailValidationRegistry_Validate_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"email", "validation"}, "")) +) + +var ( + forward_EmailValidationRegistry_RequestValidation_0 = runtime.ForwardResponseMessage + + forward_EmailValidationRegistry_Validate_0 = runtime.ForwardResponseMessage +) diff --git a/pkg/ttnpb/email_validation.pb.paths.fm.go b/pkg/ttnpb/email_validation.pb.paths.fm.go new file mode 100644 index 0000000000..fb7192229b --- /dev/null +++ b/pkg/ttnpb/email_validation.pb.paths.fm.go @@ -0,0 +1,30 @@ +// Code generated by protoc-gen-fieldmask. DO NOT EDIT. + +package ttnpb + +var EmailValidationFieldPathsNested = []string{ + "address", + "created_at", + "expires_at", + "id", + "token", + "updated_at", +} + +var EmailValidationFieldPathsTopLevel = []string{ + "address", + "created_at", + "expires_at", + "id", + "token", + "updated_at", +} +var ValidateEmailRequestFieldPathsNested = []string{ + "id", + "token", +} + +var ValidateEmailRequestFieldPathsTopLevel = []string{ + "id", + "token", +} diff --git a/pkg/ttnpb/email_validation.pb.setters.fm.go b/pkg/ttnpb/email_validation.pb.setters.fm.go new file mode 100644 index 0000000000..061ed6d565 --- /dev/null +++ b/pkg/ttnpb/email_validation.pb.setters.fm.go @@ -0,0 +1,104 @@ +// Code generated by protoc-gen-fieldmask. DO NOT EDIT. + +package ttnpb + +import fmt "fmt" + +func (dst *EmailValidation) SetFields(src *EmailValidation, paths ...string) error { + for name, subs := range _processPaths(paths) { + switch name { + case "id": + if len(subs) > 0 { + return fmt.Errorf("'id' has no subfields, but %s were specified", subs) + } + if src != nil { + dst.Id = src.Id + } else { + var zero string + dst.Id = zero + } + case "token": + if len(subs) > 0 { + return fmt.Errorf("'token' has no subfields, but %s were specified", subs) + } + if src != nil { + dst.Token = src.Token + } else { + var zero string + dst.Token = zero + } + case "address": + if len(subs) > 0 { + return fmt.Errorf("'address' has no subfields, but %s were specified", subs) + } + if src != nil { + dst.Address = src.Address + } else { + var zero string + dst.Address = zero + } + case "created_at": + if len(subs) > 0 { + return fmt.Errorf("'created_at' has no subfields, but %s were specified", subs) + } + if src != nil { + dst.CreatedAt = src.CreatedAt + } else { + dst.CreatedAt = nil + } + case "expires_at": + if len(subs) > 0 { + return fmt.Errorf("'expires_at' has no subfields, but %s were specified", subs) + } + if src != nil { + dst.ExpiresAt = src.ExpiresAt + } else { + dst.ExpiresAt = nil + } + case "updated_at": + if len(subs) > 0 { + return fmt.Errorf("'updated_at' has no subfields, but %s were specified", subs) + } + if src != nil { + dst.UpdatedAt = src.UpdatedAt + } else { + dst.UpdatedAt = nil + } + + default: + return fmt.Errorf("invalid field: '%s'", name) + } + } + return nil +} + +func (dst *ValidateEmailRequest) SetFields(src *ValidateEmailRequest, paths ...string) error { + for name, subs := range _processPaths(paths) { + switch name { + case "id": + if len(subs) > 0 { + return fmt.Errorf("'id' has no subfields, but %s were specified", subs) + } + if src != nil { + dst.Id = src.Id + } else { + var zero string + dst.Id = zero + } + case "token": + if len(subs) > 0 { + return fmt.Errorf("'token' has no subfields, but %s were specified", subs) + } + if src != nil { + dst.Token = src.Token + } else { + var zero string + dst.Token = zero + } + + default: + return fmt.Errorf("invalid field: '%s'", name) + } + } + return nil +} diff --git a/pkg/ttnpb/email_validation.pb.validate.go b/pkg/ttnpb/email_validation.pb.validate.go new file mode 100644 index 0000000000..b1d924c040 --- /dev/null +++ b/pkg/ttnpb/email_validation.pb.validate.go @@ -0,0 +1,325 @@ +// Code generated by protoc-gen-fieldmask. DO NOT EDIT. + +package ttnpb + +import ( + "bytes" + "errors" + "fmt" + "net" + "net/mail" + "net/url" + "regexp" + "strings" + "time" + "unicode/utf8" + + "google.golang.org/protobuf/types/known/anypb" +) + +// ensure the imports are used +var ( + _ = bytes.MinRead + _ = errors.New("") + _ = fmt.Print + _ = utf8.UTFMax + _ = (*regexp.Regexp)(nil) + _ = (*strings.Reader)(nil) + _ = net.IPv4len + _ = time.Duration(0) + _ = (*url.URL)(nil) + _ = (*mail.Address)(nil) + _ = anypb.Any{} +) + +// ValidateFields checks the field values on EmailValidation with the rules +// defined in the proto definition for this message. If any rules are +// violated, an error is returned. +func (m *EmailValidation) ValidateFields(paths ...string) error { + if m == nil { + return nil + } + + if len(paths) == 0 { + paths = EmailValidationFieldPathsNested + } + + for name, subs := range _processPaths(append(paths[:0:0], paths...)) { + _ = subs + switch name { + case "id": + + if l := utf8.RuneCountInString(m.GetId()); l < 1 || l > 64 { + return EmailValidationValidationError{ + field: "id", + reason: "value length must be between 1 and 64 runes, inclusive", + } + } + + case "token": + + if l := utf8.RuneCountInString(m.GetToken()); l < 1 || l > 64 { + return EmailValidationValidationError{ + field: "token", + reason: "value length must be between 1 and 64 runes, inclusive", + } + } + + case "address": + + if err := m._validateEmail(m.GetAddress()); err != nil { + return EmailValidationValidationError{ + field: "address", + reason: "value must be a valid email address", + cause: err, + } + } + + case "created_at": + + if v, ok := interface{}(m.GetCreatedAt()).(interface{ ValidateFields(...string) error }); ok { + if err := v.ValidateFields(subs...); err != nil { + return EmailValidationValidationError{ + field: "created_at", + reason: "embedded message failed validation", + cause: err, + } + } + } + + case "expires_at": + + if v, ok := interface{}(m.GetExpiresAt()).(interface{ ValidateFields(...string) error }); ok { + if err := v.ValidateFields(subs...); err != nil { + return EmailValidationValidationError{ + field: "expires_at", + reason: "embedded message failed validation", + cause: err, + } + } + } + + case "updated_at": + + if v, ok := interface{}(m.GetUpdatedAt()).(interface{ ValidateFields(...string) error }); ok { + if err := v.ValidateFields(subs...); err != nil { + return EmailValidationValidationError{ + field: "updated_at", + reason: "embedded message failed validation", + cause: err, + } + } + } + + default: + return EmailValidationValidationError{ + field: name, + reason: "invalid field path", + } + } + } + return nil +} + +func (m *EmailValidation) _validateHostname(host string) error { + s := strings.ToLower(strings.TrimSuffix(host, ".")) + + if len(host) > 253 { + return errors.New("hostname cannot exceed 253 characters") + } + + for _, part := range strings.Split(s, ".") { + if l := len(part); l == 0 || l > 63 { + return errors.New("hostname part must be non-empty and cannot exceed 63 characters") + } + + if part[0] == '-' { + return errors.New("hostname parts cannot begin with hyphens") + } + + if part[len(part)-1] == '-' { + return errors.New("hostname parts cannot end with hyphens") + } + + for _, r := range part { + if (r < 'a' || r > 'z') && (r < '0' || r > '9') && r != '-' { + return fmt.Errorf("hostname parts can only contain alphanumeric characters or hyphens, got %q", string(r)) + } + } + } + + return nil +} + +func (m *EmailValidation) _validateEmail(addr string) error { + a, err := mail.ParseAddress(addr) + if err != nil { + return err + } + addr = a.Address + + if len(addr) > 254 { + return errors.New("email addresses cannot exceed 254 characters") + } + + parts := strings.SplitN(addr, "@", 2) + + if len(parts[0]) > 64 { + return errors.New("email address local phrase cannot exceed 64 characters") + } + + return m._validateHostname(parts[1]) +} + +// EmailValidationValidationError is the validation error returned by +// EmailValidation.ValidateFields if the designated constraints aren't met. +type EmailValidationValidationError struct { + field string + reason string + cause error + key bool +} + +// Field function returns field value. +func (e EmailValidationValidationError) Field() string { return e.field } + +// Reason function returns reason value. +func (e EmailValidationValidationError) Reason() string { return e.reason } + +// Cause function returns cause value. +func (e EmailValidationValidationError) Cause() error { return e.cause } + +// Key function returns key value. +func (e EmailValidationValidationError) Key() bool { return e.key } + +// ErrorName returns error name. +func (e EmailValidationValidationError) ErrorName() string { return "EmailValidationValidationError" } + +// Error satisfies the builtin error interface +func (e EmailValidationValidationError) Error() string { + cause := "" + if e.cause != nil { + cause = fmt.Sprintf(" | caused by: %v", e.cause) + } + + key := "" + if e.key { + key = "key for " + } + + return fmt.Sprintf( + "invalid %sEmailValidation.%s: %s%s", + key, + e.field, + e.reason, + cause) +} + +var _ error = EmailValidationValidationError{} + +var _ interface { + Field() string + Reason() string + Key() bool + Cause() error + ErrorName() string +} = EmailValidationValidationError{} + +// ValidateFields checks the field values on ValidateEmailRequest with the +// rules defined in the proto definition for this message. If any rules are +// violated, an error is returned. +func (m *ValidateEmailRequest) ValidateFields(paths ...string) error { + if m == nil { + return nil + } + + if len(paths) == 0 { + paths = ValidateEmailRequestFieldPathsNested + } + + for name, subs := range _processPaths(append(paths[:0:0], paths...)) { + _ = subs + switch name { + case "id": + + if l := utf8.RuneCountInString(m.GetId()); l < 1 || l > 64 { + return ValidateEmailRequestValidationError{ + field: "id", + reason: "value length must be between 1 and 64 runes, inclusive", + } + } + + case "token": + + if l := utf8.RuneCountInString(m.GetToken()); l < 1 || l > 64 { + return ValidateEmailRequestValidationError{ + field: "token", + reason: "value length must be between 1 and 64 runes, inclusive", + } + } + + default: + return ValidateEmailRequestValidationError{ + field: name, + reason: "invalid field path", + } + } + } + return nil +} + +// ValidateEmailRequestValidationError is the validation error returned by +// ValidateEmailRequest.ValidateFields if the designated constraints aren't met. +type ValidateEmailRequestValidationError struct { + field string + reason string + cause error + key bool +} + +// Field function returns field value. +func (e ValidateEmailRequestValidationError) Field() string { return e.field } + +// Reason function returns reason value. +func (e ValidateEmailRequestValidationError) Reason() string { return e.reason } + +// Cause function returns cause value. +func (e ValidateEmailRequestValidationError) Cause() error { return e.cause } + +// Key function returns key value. +func (e ValidateEmailRequestValidationError) Key() bool { return e.key } + +// ErrorName returns error name. +func (e ValidateEmailRequestValidationError) ErrorName() string { + return "ValidateEmailRequestValidationError" +} + +// Error satisfies the builtin error interface +func (e ValidateEmailRequestValidationError) Error() string { + cause := "" + if e.cause != nil { + cause = fmt.Sprintf(" | caused by: %v", e.cause) + } + + key := "" + if e.key { + key = "key for " + } + + return fmt.Sprintf( + "invalid %sValidateEmailRequest.%s: %s%s", + key, + e.field, + e.reason, + cause) +} + +var _ error = ValidateEmailRequestValidationError{} + +var _ interface { + Field() string + Reason() string + Key() bool + Cause() error + ErrorName() string +} = ValidateEmailRequestValidationError{} diff --git a/pkg/ttnpb/email_validation_grpc.pb.go b/pkg/ttnpb/email_validation_grpc.pb.go new file mode 100644 index 0000000000..023cb073ee --- /dev/null +++ b/pkg/ttnpb/email_validation_grpc.pb.go @@ -0,0 +1,166 @@ +// Copyright © 2023 The Things Network Foundation, The Things Industries B.V. +// +// 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. + +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.3.0 +// - protoc v4.25.1 +// source: ttn/lorawan/v3/email_validation.proto + +package ttnpb + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" + emptypb "google.golang.org/protobuf/types/known/emptypb" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.32.0 or later. +const _ = grpc.SupportPackageIsVersion7 + +const ( + EmailValidationRegistry_RequestValidation_FullMethodName = "/ttn.lorawan.v3.EmailValidationRegistry/RequestValidation" + EmailValidationRegistry_Validate_FullMethodName = "/ttn.lorawan.v3.EmailValidationRegistry/Validate" +) + +// EmailValidationRegistryClient is the client API for EmailValidationRegistry service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type EmailValidationRegistryClient interface { + // Request validation for the non-validated contact info for the given entity. + RequestValidation(ctx context.Context, in *UserIdentifiers, opts ...grpc.CallOption) (*EmailValidation, error) + // Validate confirms a contact info validation. + Validate(ctx context.Context, in *ValidateEmailRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) +} + +type emailValidationRegistryClient struct { + cc grpc.ClientConnInterface +} + +func NewEmailValidationRegistryClient(cc grpc.ClientConnInterface) EmailValidationRegistryClient { + return &emailValidationRegistryClient{cc} +} + +func (c *emailValidationRegistryClient) RequestValidation(ctx context.Context, in *UserIdentifiers, opts ...grpc.CallOption) (*EmailValidation, error) { + out := new(EmailValidation) + err := c.cc.Invoke(ctx, EmailValidationRegistry_RequestValidation_FullMethodName, in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *emailValidationRegistryClient) Validate(ctx context.Context, in *ValidateEmailRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, EmailValidationRegistry_Validate_FullMethodName, in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +// EmailValidationRegistryServer is the server API for EmailValidationRegistry service. +// All implementations must embed UnimplementedEmailValidationRegistryServer +// for forward compatibility +type EmailValidationRegistryServer interface { + // Request validation for the non-validated contact info for the given entity. + RequestValidation(context.Context, *UserIdentifiers) (*EmailValidation, error) + // Validate confirms a contact info validation. + Validate(context.Context, *ValidateEmailRequest) (*emptypb.Empty, error) + mustEmbedUnimplementedEmailValidationRegistryServer() +} + +// UnimplementedEmailValidationRegistryServer must be embedded to have forward compatible implementations. +type UnimplementedEmailValidationRegistryServer struct { +} + +func (UnimplementedEmailValidationRegistryServer) RequestValidation(context.Context, *UserIdentifiers) (*EmailValidation, error) { + return nil, status.Errorf(codes.Unimplemented, "method RequestValidation not implemented") +} +func (UnimplementedEmailValidationRegistryServer) Validate(context.Context, *ValidateEmailRequest) (*emptypb.Empty, error) { + return nil, status.Errorf(codes.Unimplemented, "method Validate not implemented") +} +func (UnimplementedEmailValidationRegistryServer) mustEmbedUnimplementedEmailValidationRegistryServer() { +} + +// UnsafeEmailValidationRegistryServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to EmailValidationRegistryServer will +// result in compilation errors. +type UnsafeEmailValidationRegistryServer interface { + mustEmbedUnimplementedEmailValidationRegistryServer() +} + +func RegisterEmailValidationRegistryServer(s grpc.ServiceRegistrar, srv EmailValidationRegistryServer) { + s.RegisterService(&EmailValidationRegistry_ServiceDesc, srv) +} + +func _EmailValidationRegistry_RequestValidation_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(UserIdentifiers) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(EmailValidationRegistryServer).RequestValidation(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: EmailValidationRegistry_RequestValidation_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(EmailValidationRegistryServer).RequestValidation(ctx, req.(*UserIdentifiers)) + } + return interceptor(ctx, in, info, handler) +} + +func _EmailValidationRegistry_Validate_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ValidateEmailRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(EmailValidationRegistryServer).Validate(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: EmailValidationRegistry_Validate_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(EmailValidationRegistryServer).Validate(ctx, req.(*ValidateEmailRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// EmailValidationRegistry_ServiceDesc is the grpc.ServiceDesc for EmailValidationRegistry service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var EmailValidationRegistry_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "ttn.lorawan.v3.EmailValidationRegistry", + HandlerType: (*EmailValidationRegistryServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "RequestValidation", + Handler: _EmailValidationRegistry_RequestValidation_Handler, + }, + { + MethodName: "Validate", + Handler: _EmailValidationRegistry_Validate_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "ttn/lorawan/v3/email_validation.proto", +} diff --git a/pkg/webui/account/views/validate/index.js b/pkg/webui/account/views/validate/index.js index 2d19140dfb..db8aaf3c2d 100644 --- a/pkg/webui/account/views/validate/index.js +++ b/pkg/webui/account/views/validate/index.js @@ -43,7 +43,7 @@ const m = defineMessages({ validatingAccount: 'Validating account…', tokenNotFoundTitle: 'Token not found', tokenNotFoundMessage: - 'The validation token was not found. This could mean that the contact info has already been validated. Otherwise, please contact an administrator.', + 'The validation token was not found. This could mean that the contact info has already been validated or the token has been invalidated. Re-request another validation and if the error persists, please contact an administrator.', }) const siteName = selectApplicationSiteName() @@ -72,7 +72,7 @@ const Validate = ({ hideTitle }) => { const makeRequest = useCallback(async () => { if (token && reference) { try { - await tts.ContactInfo.validate({ + await tts.EmailValidation.validate({ token, id: reference, }) diff --git a/pkg/webui/console/store/middleware/logics/users.js b/pkg/webui/console/store/middleware/logics/users.js index 4614199171..6a3c09431f 100644 --- a/pkg/webui/console/store/middleware/logics/users.js +++ b/pkg/webui/console/store/middleware/logics/users.js @@ -163,7 +163,7 @@ const requestEmailValidationLogic = createRequestLogic({ process: async ({ action }) => { const { userId } = action.payload try { - const result = await tts.ContactInfo.requestValidation({ user_ids: { user_id: userId } }) + const result = await tts.EmailValidation.requestValidation({ user_id: userId }) toast({ type: toast.types.SUCCESS, message: m.errEmailValidationActionSuccess, diff --git a/pkg/webui/locales/en.json b/pkg/webui/locales/en.json index 3b534171a9..142a6a31e5 100644 --- a/pkg/webui/locales/en.json +++ b/pkg/webui/locales/en.json @@ -121,7 +121,7 @@ "account.views.validate.index.validateFail": "There was an error and the contact info could not be validated", "account.views.validate.index.validatingAccount": "Validating account…", "account.views.validate.index.tokenNotFoundTitle": "Token not found", - "account.views.validate.index.tokenNotFoundMessage": "The validation token was not found. This could mean that the contact info has already been validated. Otherwise, please contact an administrator.", + "account.views.validate.index.tokenNotFoundMessage": "The validation token was not found. This could mean that the contact info has already been validated or the token has been invalidated. Re-request another validation and if the error persists, please contact an administrator.", "components.collapse.index.collapse": "Collapse", "components.collapse.index.expand": "Expand", "components.data-sheet.index.noData": "No data available", diff --git a/pkg/webui/locales/ja.json b/pkg/webui/locales/ja.json index e4d6d76638..35d83ffe15 100644 --- a/pkg/webui/locales/ja.json +++ b/pkg/webui/locales/ja.json @@ -2202,6 +2202,7 @@ "error:pkg/identityserver:user_rejected": "ユーザアカウントが拒否されました", "error:pkg/identityserver:user_requested": "ユーザアカウント承認が保留中です", "error:pkg/identityserver:user_suspended": "ユーザアカウントが凍結中です", + "error:pkg/identityserver:validation_request_forbidden": "", "error:pkg/identityserver:validations_already_sent": "この連絡先情報への検証は送信済みです", "error:pkg/internal/registry:end_device_euis_taken": "JoinEUI `{join_eui}`およびDevEUI `{dev_eui}`を持つエンドデバイスは、すでにアプリケーション`{application_id}`で`{device_id}`として登録されています", "error:pkg/interop:activation": "アクティベーションが許可されません", diff --git a/sdk/js/generated/api-definition.json b/sdk/js/generated/api-definition.json index 10a5de84a3..74162f9b84 100644 --- a/sdk/js/generated/api-definition.json +++ b/sdk/js/generated/api-definition.json @@ -2650,6 +2650,30 @@ ] } }, + "EmailValidationRegistry": { + "RequestValidation": { + "file": "ttn/lorawan/v3/email_validation.proto", + "http": [ + { + "method": "post", + "pattern": "/email/validation", + "body": "*", + "parameters": [] + } + ] + }, + "Validate": { + "file": "ttn/lorawan/v3/email_validation.proto", + "http": [ + { + "method": "patch", + "pattern": "/email/validation", + "body": "*", + "parameters": [] + } + ] + } + }, "EndDeviceBatchRegistry": { "Get": { "file": "ttn/lorawan/v3/end_device_services.proto", diff --git a/sdk/js/generated/api.json b/sdk/js/generated/api.json index 41cbb1b6b6..76ecf7947f 100644 --- a/sdk/js/generated/api.json +++ b/sdk/js/generated/api.json @@ -14607,6 +14607,251 @@ ], "services": [] }, + { + "name": "ttn/lorawan/v3/email_validation.proto", + "description": "", + "package": "ttn.lorawan.v3", + "hasEnums": false, + "hasExtensions": false, + "hasMessages": true, + "hasServices": true, + "enums": [], + "extensions": [], + "messages": [ + { + "name": "EmailValidation", + "longName": "EmailValidation", + "fullName": "ttn.lorawan.v3.EmailValidation", + "description": "", + "hasExtensions": false, + "hasFields": true, + "hasOneofs": false, + "extensions": [], + "fields": [ + { + "name": "id", + "description": "", + "label": "", + "type": "string", + "longType": "string", + "fullType": "string", + "ismap": false, + "isoneof": false, + "oneofdecl": "", + "defaultValue": "", + "options": { + "validate.rules": [ + { + "name": "string.min_len", + "value": 1 + }, + { + "name": "string.max_len", + "value": 64 + } + ] + } + }, + { + "name": "token", + "description": "", + "label": "", + "type": "string", + "longType": "string", + "fullType": "string", + "ismap": false, + "isoneof": false, + "oneofdecl": "", + "defaultValue": "", + "options": { + "validate.rules": [ + { + "name": "string.min_len", + "value": 1 + }, + { + "name": "string.max_len", + "value": 64 + } + ] + } + }, + { + "name": "address", + "description": "", + "label": "", + "type": "string", + "longType": "string", + "fullType": "string", + "ismap": false, + "isoneof": false, + "oneofdecl": "", + "defaultValue": "", + "options": { + "validate.rules": [ + { + "name": "string.email", + "value": true + } + ] + } + }, + { + "name": "created_at", + "description": "", + "label": "", + "type": "Timestamp", + "longType": "google.protobuf.Timestamp", + "fullType": "google.protobuf.Timestamp", + "ismap": false, + "isoneof": false, + "oneofdecl": "", + "defaultValue": "" + }, + { + "name": "expires_at", + "description": "", + "label": "", + "type": "Timestamp", + "longType": "google.protobuf.Timestamp", + "fullType": "google.protobuf.Timestamp", + "ismap": false, + "isoneof": false, + "oneofdecl": "", + "defaultValue": "" + }, + { + "name": "updated_at", + "description": "", + "label": "", + "type": "Timestamp", + "longType": "google.protobuf.Timestamp", + "fullType": "google.protobuf.Timestamp", + "ismap": false, + "isoneof": false, + "oneofdecl": "", + "defaultValue": "" + } + ] + }, + { + "name": "ValidateEmailRequest", + "longName": "ValidateEmailRequest", + "fullName": "ttn.lorawan.v3.ValidateEmailRequest", + "description": "", + "hasExtensions": false, + "hasFields": true, + "hasOneofs": false, + "extensions": [], + "fields": [ + { + "name": "id", + "description": "", + "label": "", + "type": "string", + "longType": "string", + "fullType": "string", + "ismap": false, + "isoneof": false, + "oneofdecl": "", + "defaultValue": "", + "options": { + "validate.rules": [ + { + "name": "string.min_len", + "value": 1 + }, + { + "name": "string.max_len", + "value": 64 + } + ] + } + }, + { + "name": "token", + "description": "", + "label": "", + "type": "string", + "longType": "string", + "fullType": "string", + "ismap": false, + "isoneof": false, + "oneofdecl": "", + "defaultValue": "", + "options": { + "validate.rules": [ + { + "name": "string.min_len", + "value": 1 + }, + { + "name": "string.max_len", + "value": 64 + } + ] + } + } + ] + } + ], + "services": [ + { + "name": "EmailValidationRegistry", + "longName": "EmailValidationRegistry", + "fullName": "ttn.lorawan.v3.EmailValidationRegistry", + "description": "The EmailValidationRegistry service, exposed by the Identity Server, is used for validating an user's primary email.", + "methods": [ + { + "name": "RequestValidation", + "description": "Request validation for the non-validated contact info for the given entity.", + "requestType": "UserIdentifiers", + "requestLongType": "UserIdentifiers", + "requestFullType": "ttn.lorawan.v3.UserIdentifiers", + "requestStreaming": false, + "responseType": "EmailValidation", + "responseLongType": "EmailValidation", + "responseFullType": "ttn.lorawan.v3.EmailValidation", + "responseStreaming": false, + "options": { + "google.api.http": { + "rules": [ + { + "method": "POST", + "pattern": "/email/validation", + "body": "*" + } + ] + } + } + }, + { + "name": "Validate", + "description": "Validate confirms a contact info validation.", + "requestType": "ValidateEmailRequest", + "requestLongType": "ValidateEmailRequest", + "requestFullType": "ttn.lorawan.v3.ValidateEmailRequest", + "requestStreaming": false, + "responseType": "Empty", + "responseLongType": ".google.protobuf.Empty", + "responseFullType": "google.protobuf.Empty", + "responseStreaming": false, + "options": { + "google.api.http": { + "rules": [ + { + "method": "PATCH", + "pattern": "/email/validation", + "body": "*" + } + ] + } + } + } + ] + } + ] + }, { "name": "ttn/lorawan/v3/end_device.proto", "description": "", diff --git a/sdk/js/src/index.js b/sdk/js/src/index.js index 248ab976ad..0e0e5ca7cb 100644 --- a/sdk/js/src/index.js +++ b/sdk/js/src/index.js @@ -25,6 +25,7 @@ import Users from './service/users' import Auth from './service/auth' import Sessions from './service/sessions' import ContactInfo from './service/contact-info' +import EmailValidation from './service/email-validation' import PacketBrokerAgent from './service/packet-broker-agent' import Clients from './service/clients' import Authorizations from './service/authorizations' @@ -63,6 +64,7 @@ class TTS { this.Auth = new Auth(this.api.EntityAccess) this.Sessions = new Sessions(this.api) this.ContactInfo = new ContactInfo(this.api.ContactInfoRegistry) + this.EmailValidation = new EmailValidation(this.api.EmailValidationRegistry) this.PacketBrokerAgent = new PacketBrokerAgent(this.api.Pba) this.Clients = new Clients(this.api) this.Authorizations = new Authorizations(this.api) diff --git a/sdk/js/src/service/email-validation.js b/sdk/js/src/service/email-validation.js new file mode 100644 index 0000000000..e8cf7c7b83 --- /dev/null +++ b/sdk/js/src/service/email-validation.js @@ -0,0 +1,38 @@ +// Copyright © 2023 The Things Network Foundation, The Things Industries B.V. +// +// 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. + +import autoBind from 'auto-bind' + +import Marshaler from '../util/marshaler' + +class EmailValidation { + constructor(service) { + this._api = service + autoBind(this) + } + + async validate(token, id) { + const result = await this._api.Validate(undefined, token, id) + + return Marshaler.payloadSingleResponse(result) + } + + async requestValidation(ids) { + const result = await this._api.RequestValidation(undefined, ids) + + return Marshaler.payloadSingleResponse(result) + } +} + +export default EmailValidation