From 32dce58c28cda1b3266416eb0fac557b8b365a39 Mon Sep 17 00:00:00 2001 From: Sergey Chubaryan Date: Sat, 15 Feb 2025 09:31:32 +0300 Subject: [PATCH 1/3] 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) ); From 20aa4a3d7b6c12f2b85d75e2ce5dfa0bf162f483 Mon Sep 17 00:00:00 2001 From: Sergey Chubaryan Date: Sat, 15 Feb 2025 12:45:57 +0300 Subject: [PATCH 2/3] add verify-email handler --- cmd/backend/config.yaml | 2 +- .../server/handlers/user_verify_handler.go | 51 +++++++++++++++++++ cmd/backend/server/server.go | 8 ++- internal/core/repos/action_token.go | 4 +- 4 files changed, 60 insertions(+), 5 deletions(-) create mode 100644 cmd/backend/server/handlers/user_verify_handler.go diff --git a/cmd/backend/config.yaml b/cmd/backend/config.yaml index f2e060c..d5df5f4 100644 --- a/cmd/backend/config.yaml +++ b/cmd/backend/config.yaml @@ -1,5 +1,5 @@ port: 8080 postgres_url: "postgres://postgres:postgres@localhost:5432/postgres" -jwt_signing_key: "./config_defaults/jwt_signing_key" +jwt_signing_key: "./jwt_signing_key" kafka_url: "localhost:9092" kafka_topic: "backend_events" \ No newline at end of file diff --git a/cmd/backend/server/handlers/user_verify_handler.go b/cmd/backend/server/handlers/user_verify_handler.go new file mode 100644 index 0000000..926ff83 --- /dev/null +++ b/cmd/backend/server/handlers/user_verify_handler.go @@ -0,0 +1,51 @@ +package handlers + +import ( + "backend/internal/core/services" + "backend/pkg/logger" + + "github.com/gin-gonic/gin" +) + +type A struct { + Title string + Text string + Link string + LinkText string +} + +func NewUserVerifyEmailHandler(log logger.Logger, userService services.UserService) gin.HandlerFunc { + htmlOk := ` + + + Verify Email + + +

Email successfuly verified

+ + + ` + + htmlNotOk := ` + Verify Email +

Email was not verified

+ + ` + + return func(c *gin.Context) { + token, ok := c.GetQuery("token") + if !ok || token == "" { + c.Data(400, "text/html", []byte(htmlNotOk)) + return + } + + err := userService.VerifyEmail(c, token) + if err != nil { + log.Error().Err(err).Msg("Error verifying email") + c.Data(400, "text/html", []byte(htmlNotOk)) + return + } + + c.Data(200, "text/html", []byte(htmlOk)) + } +} diff --git a/cmd/backend/server/server.go b/cmd/backend/server/server.go index 6e13757..68e0ec0 100644 --- a/cmd/backend/server/server.go +++ b/cmd/backend/server/server.go @@ -39,17 +39,21 @@ func NewServer(opts NewServerOpts) *httpserver.Server { r.Use(httpserver.NewRequestLogMiddleware(opts.Logger, opts.Tracer, prometheus)) r.Use(httpserver.NewTracingMiddleware(opts.Tracer)) - v1 := r.Group("/v1") + r.GET("/verify-user", handlers.NewUserVerifyEmailHandler(opts.Logger, opts.UserService)) + api := r.Group("/api") + + v1 := api.Group("/v1") userGroup := v1.Group("/user") { userGroup.POST("/create", handlers.NewUserCreateHandler(opts.Logger, opts.UserService)) userGroup.POST("/login", handlers.NewUserLoginHandler(opts.Logger, opts.UserService)) + } dummyGroup := v1.Group("/dummy") + dummyGroup.Use(middleware.NewAuthMiddleware(opts.UserService)) { - dummyGroup.Use(middleware.NewAuthMiddleware(opts.UserService)) dummyGroup.GET("", handlers.NewDummyHandler()) dummyGroup.POST("/forgot-password", func(c *gin.Context) { user := utils.GetUserFromRequest(c) diff --git a/internal/core/repos/action_token.go b/internal/core/repos/action_token.go index 9b4c24a..7eda5f0 100644 --- a/internal/core/repos/action_token.go +++ b/internal/core/repos/action_token.go @@ -50,7 +50,7 @@ func (a *actionTokenRepo) GetActionToken(ctx context.Context, value string, targ query := ` select id, user_id from action_tokens where - value=$2 and target=$3 + value=$1 and target=$2 and CURRENT_TIMESTAMP < expiration;` row := a.db.QueryRowContext(ctx, query, value, target) @@ -67,7 +67,7 @@ func (a *actionTokenRepo) GetActionToken(ctx context.Context, value string, targ 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 { + if _, err := a.db.ExecContext(ctx, query, id); err != nil { return err } return nil From 8c1ced8e5a9618e7003394cc63fb19bbc9604500 Mon Sep 17 00:00:00 2001 From: Sergey Chubaryan Date: Sun, 16 Feb 2025 03:02:50 +0300 Subject: [PATCH 3/3] add html template --- .../server/handlers/user_verify_handler.go | 60 ++++++++++++------- 1 file changed, 40 insertions(+), 20 deletions(-) diff --git a/cmd/backend/server/handlers/user_verify_handler.go b/cmd/backend/server/handlers/user_verify_handler.go index 926ff83..b6a6163 100644 --- a/cmd/backend/server/handlers/user_verify_handler.go +++ b/cmd/backend/server/handlers/user_verify_handler.go @@ -4,48 +4,68 @@ import ( "backend/internal/core/services" "backend/pkg/logger" + "html/template" + "github.com/gin-gonic/gin" ) -type A struct { +type HtmlTemplate struct { + TabTitle string Title string Text string Link string LinkText string } -func NewUserVerifyEmailHandler(log logger.Logger, userService services.UserService) gin.HandlerFunc { - htmlOk := ` - - - Verify Email - - -

Email successfuly verified

- - - ` +const htmlTemplate = ` + + + {{.TabTitle}} + + + {{if .Title}} +

{{.Title}}

+ {{end}} - htmlNotOk := ` - Verify Email -

Email was not verified

- - ` +

{{.Text}}

+ + {{if .Link}} + {{.LinkText}} + {{end}} + + +` + +func NewUserVerifyEmailHandler(log logger.Logger, userService services.UserService) gin.HandlerFunc { + template, err := template.New("verify-email").Parse(htmlTemplate) + if err != nil { + log.Fatal().Err(err).Msg("Error parsing template") + } return func(c *gin.Context) { + tmp := HtmlTemplate{ + TabTitle: "Verify Email", + Text: "Error verifying email", + } + token, ok := c.GetQuery("token") if !ok || token == "" { - c.Data(400, "text/html", []byte(htmlNotOk)) + log.Error().Err(err).Msg("No token in query param") + template.Execute(c.Writer, tmp) + c.Status(400) return } err := userService.VerifyEmail(c, token) if err != nil { log.Error().Err(err).Msg("Error verifying email") - c.Data(400, "text/html", []byte(htmlNotOk)) + template.Execute(c.Writer, tmp) + c.Status(400) return } - c.Data(200, "text/html", []byte(htmlOk)) + tmp.Text = "Email successfully verified" + template.Execute(c.Writer, tmp) + c.Status(200) } }