From b9a1fcd02a91fcc089d01fa3061ed84822d3b728 Mon Sep 17 00:00:00 2001 From: Nikolai Rodionov Date: Thu, 21 Mar 2024 21:10:56 +0100 Subject: [PATCH] Verification emails --- api/v1/email.go | 57 ++++++++++++++ internal/controllers/accounts.go | 93 +++++++++-------------- internal/controllers/email.go | 116 +++++++++++++++++++++++++++++ internal/controllers/email.go.tmp | 13 ---- internal/helpers/hash/hash.go | 5 +- internal/helpers/hash/hash_test.go | 4 +- internal/helpers/kube/kube.go | 55 ++++++++++++++ internal/helpers/kube/kube_test.go | 12 +++ main.go | 16 ++-- 9 files changed, 292 insertions(+), 79 deletions(-) create mode 100644 api/v1/email.go create mode 100644 internal/controllers/email.go delete mode 100644 internal/controllers/email.go.tmp create mode 100644 internal/helpers/kube/kube.go create mode 100644 internal/helpers/kube/kube_test.go diff --git a/api/v1/email.go b/api/v1/email.go new file mode 100644 index 0000000..85001e4 --- /dev/null +++ b/api/v1/email.go @@ -0,0 +1,57 @@ +package v1 + +import ( + "context" + "fmt" + + "git.badhouseplants.net/softplayer/softplayer-backend/internal/controllers" + "git.badhouseplants.net/softplayer/softplayer-backend/internal/helpers/email" + proto_email "git.badhouseplants.net/softplayer/softplayer-go-proto/pkg/email" + "google.golang.org/protobuf/types/known/emptypb" + ctrl "sigs.k8s.io/controller-runtime" +) + +type EmailServer struct { + proto_email.UnimplementedEmailValidationServer + emailConfig email.EmailConf + controller ctrl.Manager + +} + +func InitEmailServer(controller ctrl.Manager, emailConfig *email.EmailConf) *EmailServer { + return &EmailServer{ + controller: controller, + emailConfig: *emailConfig, + } +} + +func (c *EmailServer) SendRequest(ctx context.Context, in *proto_email.RequestValidation) (*emptypb.Empty, error) { + emailSvc := controllers.EmailSvc { + Data: controllers.EmailData{ + UserID: in.GetUserId(), + }, + EmailConfig: c.emailConfig, + Controller: c.controller, + } + err := emailSvc.SendVerification(ctx) + if err != nil { + return nil, err + } + return &emptypb.Empty{}, nil +} + +func (c *EmailServer) ValidateEmail(ctx context.Context, in *proto_email.ConfirmValidation) (*emptypb.Empty, error) { + emailSvc := controllers.EmailSvc { + Data: controllers.EmailData{ + UserID: in.GetUserId(), + Code: fmt.Sprintf("%d", in.GetCode()), + }, + EmailConfig: c.emailConfig, + Controller: c.controller, + } + err := emailSvc.ConfirmVerification(ctx) + if err != nil { + return nil, err + } + return &emptypb.Empty{}, nil +} diff --git a/internal/controllers/accounts.go b/internal/controllers/accounts.go index d81f183..04e5212 100644 --- a/internal/controllers/accounts.go +++ b/internal/controllers/accounts.go @@ -2,12 +2,11 @@ package controllers import ( "context" - "errors" "fmt" - "log" "time" "git.badhouseplants.net/softplayer/softplayer-backend/internal/helpers/hash" + "git.badhouseplants.net/softplayer/softplayer-backend/internal/helpers/kube" "github.com/google/uuid" corev1 "k8s.io/api/core/v1" rbacv1 "k8s.io/api/rbac/v1" @@ -15,7 +14,6 @@ import ( "k8s.io/apimachinery/pkg/types" ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/client" ) type Account struct { @@ -36,67 +34,43 @@ type AccountData struct { UUID string } -func waitUntilCreated(ctx context.Context, client client.Client, obj client.Object, attemps int, timeout time.Duration) error { - log.Printf("Waiting %d", attemps) - if err := client.Get(ctx, types.NamespacedName{ - Namespace: obj.GetNamespace(), - Name: obj.GetName(), - }, obj); err != nil { - if attemps > 0 { - time.Sleep(timeout) - waitUntilCreated(ctx, client, obj, attemps-1, timeout) - } else { - return err - } - } - return nil -} - func (acc *Account) Create(ctx context.Context) error { client := acc.Controller.GetClient() - acc.Data.UUID = uuid.New().String() - log.Println(acc.Data.UUID) + passwordHash, err := hash.HashPassword(acc.Data.Password, int(acc.Params.HashCost)) if err != nil { return nil } - namespace := corev1.Namespace{ + namespace := &corev1.Namespace{ ObjectMeta: metav1.ObjectMeta{ Name: acc.Data.UUID, + Labels: map[string]string{ + "username": acc.Data.Username, + "email-verified": "false", + }, }, } - - if err := client.Create(ctx, &namespace); err != nil { - return err - } - - if err := waitUntilCreated(ctx, client, &namespace, 10, time.Millisecond*50); err != nil { + + if err := kube.Create(ctx,client, namespace, true); err != nil { return err } if err := client.Get(ctx, types.NamespacedName{ Name: acc.Data.UUID, - }, &namespace); err != nil { - if err := client.Delete(ctx, &namespace); err != nil { + }, namespace); err != nil { + if err := client.Delete(ctx, namespace); err != nil { return err } return err } + // Create a secret with the account data - secret := corev1.Secret{ + secret := &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ Name: acc.Data.Username, Namespace: "softplayer-accounts", - OwnerReferences: []metav1.OwnerReference{ - metav1.OwnerReference{ - APIVersion: "v1", - Kind: "Namespace", - Name: acc.Data.UUID, - UID: namespace.UID, - }, - }, }, StringData: map[string]string{ "uuid": acc.Data.UUID, @@ -104,19 +78,22 @@ func (acc *Account) Create(ctx context.Context) error { "password": passwordHash, }, } - if err := client.Create(ctx, &secret); err != nil { - if err := client.Delete(ctx, &namespace); err != nil { + + if err := client.Create(ctx, kube.SetOwnerRef(ctx, client, secret, namespace)); err != nil { + if err := client.Delete(ctx, namespace); err != nil { return err } return err } - // Create a namespace to be managed by the account + + // Prepare RBAC resources for the account role := &rbacv1.Role{ ObjectMeta: metav1.ObjectMeta{Name: acc.Data.Username, Namespace: acc.Data.UUID}, Rules: []rbacv1.PolicyRule{{Verbs: []string{"get", "watch", "list", "create", "patch", "delete"}, APIGroups: []string{""}, Resources: []string{"configmaps", "secrets"}}}, } + if err := client.Create(ctx, role); err != nil { - if err := client.Delete(ctx, &namespace); err != nil { + if err := client.Delete(ctx, namespace); err != nil { return err } return err @@ -128,6 +105,15 @@ func (acc *Account) Create(ctx context.Context) error { Namespace: acc.Data.UUID, }, } + + if err := client.Create(ctx, sa); err != nil { + if err := client.Delete(ctx, namespace); err != nil { + return err + } + return err + } + + rb := &rbacv1.RoleBinding{ ObjectMeta: metav1.ObjectMeta{ Name: acc.Data.UUID, @@ -148,14 +134,7 @@ func (acc *Account) Create(ctx context.Context) error { } if err := client.Create(ctx, rb); err != nil { - if err := client.Delete(ctx, &namespace); err != nil { - return err - } - return err - } - - if err := client.Create(ctx, sa); err != nil { - if err := client.Delete(ctx, &namespace); err != nil { + if err := client.Delete(ctx, namespace); err != nil { return err } return err @@ -174,18 +153,18 @@ func (acc *Account) Create(ctx context.Context) error { } if err := client.Create(ctx, saSec); err != nil { - if err := client.Delete(ctx, &namespace); err != nil { + if err := client.Delete(ctx, namespace); err != nil { return err } return err } - if err := waitUntilCreated(ctx, client, saSec, 10, time.Millisecond*50); err != nil { + if err := kube.WaitUntilCreated(ctx, client, saSec, 10, time.Millisecond*50); err != nil { return err } acc.Token, err = acc.getToken(ctx, saSec) if err != nil { - if err := client.Delete(ctx, &namespace); err != nil { + if err := client.Delete(ctx, namespace); err != nil { return err } return err @@ -196,16 +175,18 @@ func (acc *Account) Create(ctx context.Context) error { func (acc *Account) Login(ctx context.Context) error { client := acc.Controller.GetClient() sec := &corev1.Secret{} + if err := client.Get(ctx, types.NamespacedName{ Namespace: "softplayer-accounts", Name: acc.Data.Username, }, sec); err != nil { return err } - if !hash.CheckPasswordHash(acc.Data.Password, string(sec.Data["password"])) { - err := errors.New("wrong password") + + if err := hash.CheckPasswordHash(acc.Data.Password, string(sec.Data["password"])); err != nil { return err } + acc.Data.UUID = string(sec.Data["uuid"]) tokenName := fmt.Sprintf("sa-%s", acc.Data.UUID) saSec := &corev1.Secret{ diff --git a/internal/controllers/email.go b/internal/controllers/email.go new file mode 100644 index 0000000..b93c9c2 --- /dev/null +++ b/internal/controllers/email.go @@ -0,0 +1,116 @@ +package controllers + +import ( + "context" + "crypto/rand" + "errors" + "io" + "log" + + "git.badhouseplants.net/softplayer/softplayer-backend/internal/helpers/email" + "git.badhouseplants.net/softplayer/softplayer-backend/internal/helpers/kube" + ctrl "sigs.k8s.io/controller-runtime" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" +) + +type EmailSvc struct { + Controller ctrl.Manager + Data EmailData + EmailConfig email.EmailConf +} + +type EmailData struct { + UserID string + Code string +} + +func (svc *EmailSvc) SendVerification(ctx context.Context) error { + client := svc.Controller.GetClient() + userns := &corev1.Namespace{} + if err := client.Get(ctx, types.NamespacedName{ + Name: svc.Data.UserID, + }, userns); err != nil { + return err + } + + userName, ok := userns.Labels["username"] + if !ok { + return errors.New("user not found") + } + accountData := &corev1.Secret{} + if err := client.Get(ctx, types.NamespacedName{ + Namespace: "softplayer-accounts", + Name: userName, + }, accountData); err != nil { + return err + } + number := encodeToString(6) + email := string(accountData.Data["email"]) + if err := svc.EmailConfig.SendEmail(email, number); err != nil { + return err + } + + emailCode := corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "email-verification-code", + Namespace: svc.Data.UserID, + }, + Data: map[string]string{ + "code": number, + }, + } + + if err := kube.Create(ctx, client, &emailCode, true); err != nil { + return err + } + return nil +} + +func (svc *EmailSvc) ConfirmVerification(ctx context.Context) error { + client := svc.Controller.GetClient() + emailCode := &corev1.ConfigMap{} + if err := client.Get(ctx, types.NamespacedName{ + Namespace: svc.Data.UserID, + Name: "email-verification-code", + }, emailCode); err != nil { + return err + } + + if svc.Data.Code != emailCode.Data["code"] { + log.Println(svc.Data.Code) + log.Println(emailCode.Data["code"]) + return errors.New("wrong verification code") + } + if err := client.Delete(ctx, emailCode); err != nil { + return err + } + + userns := &corev1.Namespace{} + if err := client.Get(ctx, types.NamespacedName{ + Name: svc.Data.UserID, + }, userns); err != nil { + return err + } + userns.Labels["email-verified"] = "true" + if err := client.Update(ctx, userns); err != nil { + return err + } + return nil +} + +func encodeToString(max int) string { + b := make([]byte, max) + n, err := io.ReadAtLeast(rand.Reader, b, max) + if n != max { + panic(err) + } + for i := 0; i < len(b); i++ { + b[i] = table[int(b[i])%len(table)] + } + return string(b) +} + +var table = [...]byte{'1', '2', '3', '4', '5', '6', '7', '8', '9', '0'} diff --git a/internal/controllers/email.go.tmp b/internal/controllers/email.go.tmp deleted file mode 100644 index 5c5ec10..0000000 --- a/internal/controllers/email.go.tmp +++ /dev/null @@ -1,13 +0,0 @@ -# package controllers - -import "context" - -type EmailSvc struct {} - -type EmailData strict { - UserID string -} - -func (svc *EmailSvc) SendVerification(ctx context.Context) { - -} diff --git a/internal/helpers/hash/hash.go b/internal/helpers/hash/hash.go index d07a56c..2600575 100644 --- a/internal/helpers/hash/hash.go +++ b/internal/helpers/hash/hash.go @@ -7,7 +7,6 @@ func HashPassword(password string, cost int) (string, error) { return string(bytes), err } -func CheckPasswordHash(password, hash string) bool { - err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) - return err == nil +func CheckPasswordHash(password, hash string) error { + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) } diff --git a/internal/helpers/hash/hash_test.go b/internal/helpers/hash/hash_test.go index 53da91b..c4309dc 100644 --- a/internal/helpers/hash/hash_test.go +++ b/internal/helpers/hash/hash_test.go @@ -11,11 +11,11 @@ func TestHashValid(t *testing.T) { password := "qwertyu9" hpass, err := hash.HashPassword(password, 10) assert.NoError(t, err) - assert.True(t, hash.CheckPasswordHash(password, hpass)) + assert.NoError(t, hash.CheckPasswordHash(password, hpass)) } func TestHashInvalid(t *testing.T) { password := "qwertyu9" invhash := "qwertyu9" - assert.False(t, hash.CheckPasswordHash(password, invhash)) + assert.Error(t, hash.CheckPasswordHash(password, invhash)) } diff --git a/internal/helpers/kube/kube.go b/internal/helpers/kube/kube.go new file mode 100644 index 0000000..6e6b303 --- /dev/null +++ b/internal/helpers/kube/kube.go @@ -0,0 +1,55 @@ +package kube + +import ( + "context" + "fmt" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +func Create(ctx context.Context, client client.Client, obj client.Object, wait bool) error { + if err := client.Create(ctx, obj); err != nil { + return err + } + if wait{ + if err := WaitUntilCreated(ctx, client, obj, 10, time.Millisecond*50); err != nil { + return err + } + } + return nil +} + +func SetOwnerRef(ctx context.Context, client client.Client, obj client.Object, owner client.Object) client.Object { + apiVersion := fmt.Sprintf("%s/%s", owner.GetObjectKind().GroupVersionKind().Group, owner.GetObjectKind().GroupVersionKind().Version) + ownerReference := []metav1.OwnerReference{ + { + APIVersion: apiVersion, + Kind: owner.GetObjectKind().GroupVersionKind().GroupKind().Kind, + Name: owner.GetName(), + UID: owner.GetUID(), + }, + } + obj.SetOwnerReferences(ownerReference) + return obj +} + +func WaitUntilCreated(ctx context.Context, client client.Client, obj client.Object, attemps int, timeout time.Duration) error { + if err := client.Get(ctx, types.NamespacedName{ + Namespace: obj.GetNamespace(), + Name: obj.GetName(), + }, obj); err != nil { + if attemps > 0 { + time.Sleep(timeout) + if err := WaitUntilCreated(ctx, client, obj, attemps-1, timeout); err != nil { + return err + } + } else { + return err + } + } + return nil +} + diff --git a/internal/helpers/kube/kube_test.go b/internal/helpers/kube/kube_test.go new file mode 100644 index 0000000..6983d5d --- /dev/null +++ b/internal/helpers/kube/kube_test.go @@ -0,0 +1,12 @@ +package kube_test + + +import ( + "testing" + + "git.badhouseplants.net/softplayer/softplayer-backend/internal/helpers/kube" + "github.com/alecthomas/assert/v2" +) + + + diff --git a/main.go b/main.go index 25da25c..3aa8aa6 100644 --- a/main.go +++ b/main.go @@ -9,6 +9,7 @@ import ( "git.badhouseplants.net/softplayer/softplayer-backend/internal/helpers/email" "git.badhouseplants.net/softplayer/softplayer-go-proto/pkg/accounts" "git.badhouseplants.net/softplayer/softplayer-go-proto/pkg/environments" + email_proto "git.badhouseplants.net/softplayer/softplayer-go-proto/pkg/email" "github.com/alecthomas/kong" "google.golang.org/grpc" "google.golang.org/grpc/reflection" @@ -16,9 +17,9 @@ import ( ) type Serve struct { - Port int16 `short:"p" env:"SOFTPLAYER_PORT" default:"8080"` + Port int16 `short:"p" env:"SOFTPLAYER_PORT" default:"4020"` Host string `env:"SOFTPLAYER_HOST" default:"0.0.0.0"` - HashCost int16 `env:"SOFTPLAYER_HASH_COST" default:"10"` + HashCost int16 `env:"SOFTPLAYER_HASH_COST" default:"1"` Reflection bool `env:"SOFTPLAYER_REFLECTION" default:"false"` SmtpHost string `env:"SOFTPLAYER_SMTP_HOST"` SmtpPort string `env:"SOFTPLAYER_SMTP_PORT" default:"587"` @@ -48,11 +49,14 @@ func server(params Serve) error { return err } + // TODO: Handle the error go func() { - controller.Start(context.Background()) + if err := controller.Start(context.Background()); err != nil { + panic(err) + } }() - smtp := email.EmailConf{ + emailConfig := email.EmailConf{ From: params.SmtpFrom, Password: params.SmtpPassword, SmtpHost: params.SmtpHost, @@ -68,8 +72,10 @@ func server(params Serve) error { reflection.Register(grpcServer) } + environments.RegisterEnvironmentsServer(grpcServer, v1.NewapiGrpcImpl()) - accounts.RegisterAccountsServer(grpcServer, v1.NewAccountRPCImpl(controller)) + accounts.RegisterAccountsServer(grpcServer, v1.NewAccountRPCImpl(controller, params.HashCost)) + email_proto.RegisterEmailValidationServer(grpcServer, v1.InitEmailServer(controller,&emailConfig)) if err := grpcServer.Serve(lis); err != nil { return err }