add email verification to user service

This commit is contained in:
Sergey Chubaryan 2025-02-15 09:31:32 +03:00
parent bddc6f2b42
commit 32dce58c28
12 changed files with 146 additions and 56 deletions

View File

@ -15,7 +15,7 @@ func NewAuthMiddleware(userService services.UserService) gin.HandlerFunc {
return return
} }
user, err := userService.ValidateToken(ctx, token) user, err := userService.ValidateAuthToken(ctx, token)
if err == services.ErrUserWrongToken || err == services.ErrUserNotExists { if err == services.ErrUserWrongToken || err == services.ErrUserNotExists {
ctx.AbortWithError(403, err) ctx.AbortWithError(403, err)
return return

View File

@ -56,7 +56,7 @@ func New(opts NewServerOpts) *Server {
dummyGroup.GET("", handlers.NewDummyHandler()) dummyGroup.GET("", handlers.NewDummyHandler())
dummyGroup.POST("/forgot-password", func(c *gin.Context) { dummyGroup.POST("/forgot-password", func(c *gin.Context) {
user := utils.GetUserFromRequest(c) user := utils.GetUserFromRequest(c)
opts.UserService.ForgotPassword(c, user.Id) opts.UserService.SendEmailForgotPassword(c, user.Id)
}) })
} }

View File

@ -54,17 +54,17 @@ services:
- prometheus-volume:/etc/prometheus - prometheus-volume:/etc/prometheus
- ./deploy/prometheus.yml:/etc/prometheus/prometheus.yml - ./deploy/prometheus.yml:/etc/prometheus/prometheus.yml
node_exporter: # node_exporter:
image: quay.io/prometheus/node-exporter:latest # image: quay.io/prometheus/node-exporter:latest
command: # command:
- '--path.rootfs=/host' # - '--path.rootfs=/host'
ports: # ports:
- 9100:9100 # - 9100:9100
extra_hosts: # extra_hosts:
- "host.docker.internal:host-gateway" # - "host.docker.internal:host-gateway"
pid: host # pid: host
volumes: # volumes:
- '/:/host:ro,rslave' # - '/:/host:ro,rslave'
otel-collector: otel-collector:
image: otel/opentelemetry-collector-contrib:0.108.0 image: otel/opentelemetry-collector-contrib:0.108.0

View File

@ -5,8 +5,10 @@ import "time"
type ActionTokenTarget int type ActionTokenTarget int
const ( const (
ActionTokenTargetForgotPassword ActionTokenTarget = iota _ ActionTokenTarget = iota
ActionTokenTargetForgotPassword
ActionTokenTargetLogin2FA ActionTokenTargetLogin2FA
ActionTokenVerifyEmail
) )
type ActionTokenDTO struct { type ActionTokenDTO struct {

View File

@ -3,6 +3,7 @@ package models
type UserDTO struct { type UserDTO struct {
Id string Id string
Email string Email string
EmailVerified bool
Secret string Secret string
Name string Name string
} }

View File

@ -9,7 +9,8 @@ import (
type ActionTokenRepo interface { type ActionTokenRepo interface {
CreateActionToken(ctx context.Context, dto models.ActionTokenDTO) (*models.ActionTokenDTO, error) 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 { func NewActionTokenRepo(db integrations.SqlDB) ActionTokenRepo {
@ -43,18 +44,17 @@ func (a *actionTokenRepo) CreateActionToken(ctx context.Context, dto models.Acti
}, nil }, nil
} }
func (a *actionTokenRepo) PopActionToken(ctx context.Context, userId, value string, target models.ActionTokenTarget) (*models.ActionTokenDTO, error) { func (a *actionTokenRepo) GetActionToken(ctx context.Context, value string, target models.ActionTokenTarget) (*models.ActionTokenDTO, error) {
query := ` dto := &models.ActionTokenDTO{Value: value, Target: target}
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)
id := "" query := `
err := row.Scan(&id) 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 { if err == sql.ErrNoRows {
return nil, nil return nil, nil
} }
@ -62,10 +62,13 @@ func (a *actionTokenRepo) PopActionToken(ctx context.Context, userId, value stri
return nil, err return nil, err
} }
return &models.ActionTokenDTO{ return dto, nil
Id: id, }
UserId: userId,
Value: value, func (a *actionTokenRepo) DeleteActionToken(ctx context.Context, id string) error {
Target: target, query := `delete from action_tokens where id=$1;`
}, nil if _, err := a.db.ExecContext(ctx, query); err != nil {
return err
}
return nil
} }

View File

@ -16,7 +16,7 @@ type EventRepo struct {
kafka *integrations.Kafka 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 { value := struct {
Email string `json:"email"` Email string `json:"email"`
Token string `json:"token"` Token string `json:"token"`
@ -29,5 +29,13 @@ func (e *EventRepo) SendEmailForgotPassword(ctx context.Context, email, actionTo
return err 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")
} }

View File

@ -20,6 +20,7 @@ import (
type UserRepo interface { type UserRepo interface {
CreateUser(ctx context.Context, dto models.UserDTO) (*models.UserDTO, error) CreateUser(ctx context.Context, dto models.UserDTO) (*models.UserDTO, error)
UpdateUser(ctx context.Context, userId string, dto models.UserUpdateDTO) 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) GetUserById(ctx context.Context, id string) (*models.UserDTO, error)
GetUserByEmail(ctx context.Context, login 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 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) { func (u *userRepo) GetUserById(ctx context.Context, id string) (*models.UserDTO, error) {
_, span := u.tracer.Start(ctx, "postgres::GetUserById") _, span := u.tracer.Start(ctx, "postgres::GetUserById")
defer span.End() 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) row := u.db.QueryRowContext(ctx, query, id)
dto := &models.UserDTO{} 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 { if err == nil {
return dto, 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") _, span := u.tracer.Start(ctx, "postgres::GetUserByEmail")
defer span.End() 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) row := u.db.QueryRowContext(ctx, query, login)
dto := &models.UserDTO{} 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 { if err == nil {
return dto, nil return dto, nil
} }

View File

@ -18,6 +18,7 @@ var (
ErrUserWrongPassword = fmt.Errorf("wrong password") ErrUserWrongPassword = fmt.Errorf("wrong password")
ErrUserWrongToken = fmt.Errorf("bad user token") ErrUserWrongToken = fmt.Errorf("bad user token")
ErrUserBadPassword = fmt.Errorf("password must contain at least 8 characters") 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") // ErrUserInternal = fmt.Errorf("unexpected error. contact tech support")
) )
@ -28,9 +29,12 @@ const (
type UserService interface { type UserService interface {
CreateUser(ctx context.Context, params UserCreateParams) (*models.UserDTO, error) CreateUser(ctx context.Context, params UserCreateParams) (*models.UserDTO, error)
AuthenticateUser(ctx context.Context, login, password string) (string, 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 ChangePassword(ctx context.Context, userId, oldPassword, newPassword string) error
ChangePasswordWithToken(ctx context.Context, userId, actionToken, 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 { if err != nil {
return nil, err return nil, err
} }
u.sendEmailVerifyEmail(ctx, result.Id, user.Email)
u.deps.UserCache.Set(result.Id, *result, cache.Expiration{Ttl: userCacheTtl}) 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 return "", ErrUserWrongPassword
} }
if !user.EmailVerified {
return "", ErrUserEmailUnverified
}
payload := utils.JwtPayload{UserId: user.Id} payload := utils.JwtPayload{UserId: user.Id}
jwt, err := u.deps.Jwt.Create(payload) jwt, err := u.deps.Jwt.Create(payload)
if err != nil { if err != nil {
@ -117,8 +126,27 @@ func (u *userService) AuthenticateUser(ctx context.Context, email, password stri
return jwt, nil return jwt, nil
} }
func (u *userService) ForgotPassword(ctx context.Context, userId string) error { func (u *userService) VerifyEmail(ctx context.Context, actionToken string) error {
user, err := u.getUserById(ctx, userId) 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 { if err != nil {
return err 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) 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 { func (u *userService) ChangePasswordWithToken(ctx context.Context, userId, actionToken, newPassword string) error {
user, err := u.getUserById(ctx, userId) user, err := u.getUserById(ctx, userId)
if err != nil { if err != nil {
return err 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 { if err != nil {
return err return err
} }
if code == nil { if token == nil {
return fmt.Errorf("wrong user access code") 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 { 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 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 { if userId, ok := u.deps.JwtCache.Get(tokenStr); ok {
return u.getUserById(ctx, userId) return u.getUserById(ctx, userId)
} }

View File

@ -3,6 +3,7 @@ create table if not exists users (
email text unique not null, email text unique not null,
secret text not null, secret text not null,
name text not null, name text not null,
email_verified boolean not null default false,
primary key (id) primary key (id)
); );