From 32dce58c28cda1b3266416eb0fac557b8b365a39 Mon Sep 17 00:00:00 2001 From: Sergey Chubaryan Date: Sat, 15 Feb 2025 09:31:32 +0300 Subject: [PATCH] add email verification to user service --- cmd/backend/{config_defaults => }/config.yaml | 0 .../{config_defaults => }/jwt_signing_key | 0 cmd/backend/server/middleware/auth.go | 2 +- cmd/backend/server/server.go | 2 +- docker-compose.yaml | 22 ++--- internal/core/models/action_token.go | 4 +- internal/core/models/user.go | 9 +- internal/core/repos/action_token.go | 39 ++++---- internal/core/repos/event_repo.go | 12 ++- internal/core/repos/user_repo.go | 22 ++++- internal/core/services/user_service.go | 89 ++++++++++++++++--- sql/01_user.sql | 1 + 12 files changed, 146 insertions(+), 56 deletions(-) rename cmd/backend/{config_defaults => }/config.yaml (100%) rename cmd/backend/{config_defaults => }/jwt_signing_key (100%) diff --git a/cmd/backend/config_defaults/config.yaml b/cmd/backend/config.yaml similarity index 100% rename from cmd/backend/config_defaults/config.yaml rename to cmd/backend/config.yaml diff --git a/cmd/backend/config_defaults/jwt_signing_key b/cmd/backend/jwt_signing_key similarity index 100% rename from cmd/backend/config_defaults/jwt_signing_key rename to cmd/backend/jwt_signing_key diff --git a/cmd/backend/server/middleware/auth.go b/cmd/backend/server/middleware/auth.go index 6d315ac..a2f6a6b 100644 --- a/cmd/backend/server/middleware/auth.go +++ b/cmd/backend/server/middleware/auth.go @@ -15,7 +15,7 @@ func NewAuthMiddleware(userService services.UserService) gin.HandlerFunc { return } - user, err := userService.ValidateToken(ctx, token) + user, err := userService.ValidateAuthToken(ctx, token) if err == services.ErrUserWrongToken || err == services.ErrUserNotExists { ctx.AbortWithError(403, err) return diff --git a/cmd/backend/server/server.go b/cmd/backend/server/server.go index e968048..32c6a69 100644 --- a/cmd/backend/server/server.go +++ b/cmd/backend/server/server.go @@ -56,7 +56,7 @@ func New(opts NewServerOpts) *Server { dummyGroup.GET("", handlers.NewDummyHandler()) dummyGroup.POST("/forgot-password", func(c *gin.Context) { user := utils.GetUserFromRequest(c) - opts.UserService.ForgotPassword(c, user.Id) + opts.UserService.SendEmailForgotPassword(c, user.Id) }) } diff --git a/docker-compose.yaml b/docker-compose.yaml index cfbd7ea..76a7d57 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -54,17 +54,17 @@ services: - prometheus-volume:/etc/prometheus - ./deploy/prometheus.yml:/etc/prometheus/prometheus.yml - node_exporter: - image: quay.io/prometheus/node-exporter:latest - command: - - '--path.rootfs=/host' - ports: - - 9100:9100 - extra_hosts: - - "host.docker.internal:host-gateway" - pid: host - volumes: - - '/:/host:ro,rslave' + # node_exporter: + # image: quay.io/prometheus/node-exporter:latest + # command: + # - '--path.rootfs=/host' + # ports: + # - 9100:9100 + # extra_hosts: + # - "host.docker.internal:host-gateway" + # pid: host + # volumes: + # - '/:/host:ro,rslave' otel-collector: image: otel/opentelemetry-collector-contrib:0.108.0 diff --git a/internal/core/models/action_token.go b/internal/core/models/action_token.go index 15d5991..c86b106 100644 --- a/internal/core/models/action_token.go +++ b/internal/core/models/action_token.go @@ -5,8 +5,10 @@ import "time" type ActionTokenTarget int const ( - ActionTokenTargetForgotPassword ActionTokenTarget = iota + _ ActionTokenTarget = iota + ActionTokenTargetForgotPassword ActionTokenTargetLogin2FA + ActionTokenVerifyEmail ) type ActionTokenDTO struct { diff --git a/internal/core/models/user.go b/internal/core/models/user.go index ced8300..dbd6e65 100644 --- a/internal/core/models/user.go +++ b/internal/core/models/user.go @@ -1,10 +1,11 @@ package models type UserDTO struct { - Id string - Email string - Secret string - Name string + Id string + Email string + EmailVerified bool + Secret string + Name string } type UserUpdateDTO struct { diff --git a/internal/core/repos/action_token.go b/internal/core/repos/action_token.go index 4c3dcde..9b4c24a 100644 --- a/internal/core/repos/action_token.go +++ b/internal/core/repos/action_token.go @@ -9,7 +9,8 @@ import ( type ActionTokenRepo interface { CreateActionToken(ctx context.Context, dto models.ActionTokenDTO) (*models.ActionTokenDTO, error) - PopActionToken(ctx context.Context, userId, value string, target models.ActionTokenTarget) (*models.ActionTokenDTO, error) + GetActionToken(ctx context.Context, value string, target models.ActionTokenTarget) (*models.ActionTokenDTO, error) + DeleteActionToken(ctx context.Context, id string) error } func NewActionTokenRepo(db integrations.SqlDB) ActionTokenRepo { @@ -43,18 +44,17 @@ func (a *actionTokenRepo) CreateActionToken(ctx context.Context, dto models.Acti }, nil } -func (a *actionTokenRepo) PopActionToken(ctx context.Context, userId, value string, target models.ActionTokenTarget) (*models.ActionTokenDTO, error) { - query := ` - delete - from action_tokens - where - user_id=$1 and value=$2 and target=$3 - and CURRENT_TIMESTAMP < expiration - returning id;` - row := a.db.QueryRowContext(ctx, query, userId, value, target) +func (a *actionTokenRepo) GetActionToken(ctx context.Context, value string, target models.ActionTokenTarget) (*models.ActionTokenDTO, error) { + dto := &models.ActionTokenDTO{Value: value, Target: target} - id := "" - err := row.Scan(&id) + query := ` + select id, user_id from action_tokens + where + value=$2 and target=$3 + and CURRENT_TIMESTAMP < expiration;` + row := a.db.QueryRowContext(ctx, query, value, target) + + err := row.Scan(&dto.Id, &dto.UserId) if err == sql.ErrNoRows { return nil, nil } @@ -62,10 +62,13 @@ func (a *actionTokenRepo) PopActionToken(ctx context.Context, userId, value stri return nil, err } - return &models.ActionTokenDTO{ - Id: id, - UserId: userId, - Value: value, - Target: target, - }, nil + return dto, nil +} + +func (a *actionTokenRepo) DeleteActionToken(ctx context.Context, id string) error { + query := `delete from action_tokens where id=$1;` + if _, err := a.db.ExecContext(ctx, query); err != nil { + return err + } + return nil } diff --git a/internal/core/repos/event_repo.go b/internal/core/repos/event_repo.go index 57460b0..abba12e 100644 --- a/internal/core/repos/event_repo.go +++ b/internal/core/repos/event_repo.go @@ -16,7 +16,7 @@ type EventRepo struct { kafka *integrations.Kafka } -func (e *EventRepo) SendEmailForgotPassword(ctx context.Context, email, actionToken string) error { +func (e *EventRepo) sendEmail(ctx context.Context, email, actionToken, eventType string) error { value := struct { Email string `json:"email"` Token string `json:"token"` @@ -29,5 +29,13 @@ func (e *EventRepo) SendEmailForgotPassword(ctx context.Context, email, actionTo return err } - return e.kafka.SendMessage(ctx, "email_forgot_password", valueBytes) + return e.kafka.SendMessage(ctx, eventType, valueBytes) +} + +func (e *EventRepo) SendEmailForgotPassword(ctx context.Context, email, actionToken string) error { + return e.sendEmail(ctx, email, actionToken, "email_forgot_password") +} + +func (e *EventRepo) SendEmailVerifyEmail(ctx context.Context, email, actionToken string) error { + return e.sendEmail(ctx, email, actionToken, "email_verify_email") } diff --git a/internal/core/repos/user_repo.go b/internal/core/repos/user_repo.go index 2cc7461..fe4936e 100644 --- a/internal/core/repos/user_repo.go +++ b/internal/core/repos/user_repo.go @@ -20,6 +20,7 @@ import ( type UserRepo interface { CreateUser(ctx context.Context, dto models.UserDTO) (*models.UserDTO, error) UpdateUser(ctx context.Context, userId string, dto models.UserUpdateDTO) error + SetUserEmailVerified(ctx context.Context, userId string) error GetUserById(ctx context.Context, id string) (*models.UserDTO, error) GetUserByEmail(ctx context.Context, login string) (*models.UserDTO, error) } @@ -66,15 +67,28 @@ func (u *userRepo) UpdateUser(ctx context.Context, userId string, dto models.Use return nil } +func (u *userRepo) SetUserEmailVerified(ctx context.Context, userId string) error { + _, span := u.tracer.Start(ctx, "postgres::SetUserEmailVerified") + defer span.End() + + query := `update users set email_verified=true where id = $1;` + _, err := u.db.ExecContext(ctx, query, userId) + if err != nil { + return err + } + + return nil +} + func (u *userRepo) GetUserById(ctx context.Context, id string) (*models.UserDTO, error) { _, span := u.tracer.Start(ctx, "postgres::GetUserById") defer span.End() - query := `select id, email, secret, name from users where id = $1;` + query := `select id, email, secret, name, email_verified from users where id = $1;` row := u.db.QueryRowContext(ctx, query, id) dto := &models.UserDTO{} - err := row.Scan(&dto.Id, &dto.Email, &dto.Secret, &dto.Name) + err := row.Scan(&dto.Id, &dto.Email, &dto.Secret, &dto.Name, &dto.EmailVerified) if err == nil { return dto, nil } @@ -89,11 +103,11 @@ func (u *userRepo) GetUserByEmail(ctx context.Context, login string) (*models.Us _, span := u.tracer.Start(ctx, "postgres::GetUserByEmail") defer span.End() - query := `select id, email, secret, name from users where email = $1;` + query := `select id, email, secret, name, email_verified from users where email = $1;` row := u.db.QueryRowContext(ctx, query, login) dto := &models.UserDTO{} - err := row.Scan(&dto.Id, &dto.Email, &dto.Secret, &dto.Name) + err := row.Scan(&dto.Id, &dto.Email, &dto.Secret, &dto.Name, &dto.EmailVerified) if err == nil { return dto, nil } diff --git a/internal/core/services/user_service.go b/internal/core/services/user_service.go index baca777..64cb446 100644 --- a/internal/core/services/user_service.go +++ b/internal/core/services/user_service.go @@ -13,11 +13,12 @@ import ( ) var ( - ErrUserNotExists = fmt.Errorf("no such user") - ErrUserExists = fmt.Errorf("user with this login already exists") - ErrUserWrongPassword = fmt.Errorf("wrong password") - ErrUserWrongToken = fmt.Errorf("bad user token") - ErrUserBadPassword = fmt.Errorf("password must contain at least 8 characters") + ErrUserNotExists = fmt.Errorf("no such user") + ErrUserExists = fmt.Errorf("user with this login already exists") + ErrUserWrongPassword = fmt.Errorf("wrong password") + ErrUserWrongToken = fmt.Errorf("bad user token") + ErrUserBadPassword = fmt.Errorf("password must contain at least 8 characters") + ErrUserEmailUnverified = fmt.Errorf("user has not verified email yet") // ErrUserInternal = fmt.Errorf("unexpected error. contact tech support") ) @@ -28,9 +29,12 @@ const ( type UserService interface { CreateUser(ctx context.Context, params UserCreateParams) (*models.UserDTO, error) AuthenticateUser(ctx context.Context, login, password string) (string, error) - ValidateToken(ctx context.Context, tokenStr string) (*models.UserDTO, error) + ValidateAuthToken(ctx context.Context, tokenStr string) (*models.UserDTO, error) + VerifyEmail(ctx context.Context, actionToken string) error + + SendEmailForgotPassword(ctx context.Context, userId string) error + SendEmailVerifyEmail(ctx context.Context, userId string) error - ForgotPassword(ctx context.Context, userId string) error ChangePassword(ctx context.Context, userId, oldPassword, newPassword string) error ChangePasswordWithToken(ctx context.Context, userId, actionToken, newPassword string) error } @@ -87,6 +91,7 @@ func (u *userService) CreateUser(ctx context.Context, params UserCreateParams) ( if err != nil { return nil, err } + u.sendEmailVerifyEmail(ctx, result.Id, user.Email) u.deps.UserCache.Set(result.Id, *result, cache.Expiration{Ttl: userCacheTtl}) @@ -106,6 +111,10 @@ func (u *userService) AuthenticateUser(ctx context.Context, email, password stri return "", ErrUserWrongPassword } + if !user.EmailVerified { + return "", ErrUserEmailUnverified + } + payload := utils.JwtPayload{UserId: user.Id} jwt, err := u.deps.Jwt.Create(payload) if err != nil { @@ -117,8 +126,27 @@ func (u *userService) AuthenticateUser(ctx context.Context, email, password stri return jwt, nil } -func (u *userService) ForgotPassword(ctx context.Context, userId string) error { - user, err := u.getUserById(ctx, userId) +func (u *userService) VerifyEmail(ctx context.Context, actionToken string) error { + token, err := u.deps.ActionTokenRepo.GetActionToken(ctx, actionToken, models.ActionTokenVerifyEmail) + if err != nil { + return err + } + if token == nil { + return fmt.Errorf("wrong action token") + } + + if err := u.deps.UserRepo.SetUserEmailVerified(ctx, token.UserId); err != nil { + return nil + } + + //TODO: log warnings somehow + u.deps.ActionTokenRepo.DeleteActionToken(ctx, token.Id) + return nil +} + +func (u *userService) SendEmailForgotPassword(ctx context.Context, email string) error { + // user, err := u.getUserById(ctx, userId) + user, err := u.deps.UserRepo.GetUserByEmail(ctx, email) if err != nil { return err } @@ -139,21 +167,54 @@ func (u *userService) ForgotPassword(ctx context.Context, userId string) error { return u.deps.EventRepo.SendEmailForgotPassword(ctx, user.Email, actionToken.Value) } +func (u *userService) sendEmailVerifyEmail(ctx context.Context, userId, email string) error { + actionToken, err := u.deps.ActionTokenRepo.CreateActionToken( + ctx, + models.ActionTokenDTO{ + UserId: userId, + Value: uuid.New().String(), + Target: models.ActionTokenVerifyEmail, + Expiration: time.Now().Add(1 * time.Hour), + }, + ) + if err != nil { + return err + } + + return u.deps.EventRepo.SendEmailVerifyEmail(ctx, email, actionToken.Value) +} + +func (u *userService) SendEmailVerifyEmail(ctx context.Context, email string) error { + //user, err := u.getUserById(ctx, userId) + user, err := u.deps.UserRepo.GetUserByEmail(ctx, email) + if err != nil { + return err + } + + return u.sendEmailVerifyEmail(ctx, user.Id, user.Email) +} + func (u *userService) ChangePasswordWithToken(ctx context.Context, userId, actionToken, newPassword string) error { user, err := u.getUserById(ctx, userId) if err != nil { return err } - code, err := u.deps.ActionTokenRepo.PopActionToken(ctx, userId, actionToken, models.ActionTokenTargetForgotPassword) + token, err := u.deps.ActionTokenRepo.GetActionToken(ctx, actionToken, models.ActionTokenTargetForgotPassword) if err != nil { return err } - if code == nil { - return fmt.Errorf("wrong user access code") + if token == nil { + return fmt.Errorf("wrong action token") } - return u.updatePassword(ctx, *user, newPassword) + if err := u.updatePassword(ctx, *user, newPassword); err != nil { + return err + } + + //TODO: log warnings somehow + u.deps.ActionTokenRepo.DeleteActionToken(ctx, token.Id) + return nil } func (u *userService) ChangePassword(ctx context.Context, userId, oldPassword, newPassword string) error { @@ -205,7 +266,7 @@ func (u *userService) getUserById(ctx context.Context, userId string) (*models.U return user, nil } -func (u *userService) ValidateToken(ctx context.Context, tokenStr string) (*models.UserDTO, error) { +func (u *userService) ValidateAuthToken(ctx context.Context, tokenStr string) (*models.UserDTO, error) { if userId, ok := u.deps.JwtCache.Get(tokenStr); ok { return u.getUserById(ctx, userId) } diff --git a/sql/01_user.sql b/sql/01_user.sql index ad2bcb9..740201d 100644 --- a/sql/01_user.sql +++ b/sql/01_user.sql @@ -3,6 +3,7 @@ create table if not exists users ( email text unique not null, secret text not null, name text not null, + email_verified boolean not null default false, primary key (id) );