Merge pull request #11 from Nucrea/feature/email-verification

Feature/email verification
This commit is contained in:
Sergey Chubaryan 2025-02-16 09:23:02 +03:00 committed by GitHub
commit 9e87566c5d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 213 additions and 48 deletions

View File

@ -1,5 +1,5 @@
port: 8080 port: 8080
postgres_url: "postgres://postgres:postgres@localhost:5432/postgres" 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_url: "localhost:9092"
kafka_topic: "backend_events" kafka_topic: "backend_events"

View File

@ -0,0 +1,71 @@
package handlers
import (
"backend/internal/core/services"
"backend/pkg/logger"
"html/template"
"github.com/gin-gonic/gin"
)
type HtmlTemplate struct {
TabTitle string
Title string
Text string
Link string
LinkText string
}
const htmlTemplate = `
<html>
<head>
<title>{{.TabTitle}}</title>
</head>
<body>
{{if .Title}}
<h1>{{.Title}}</h1>
{{end}}
<h3>{{.Text}}</h3>
{{if .Link}}
<a href="{{.Link}}">{{.LinkText}}</a>
{{end}}
</body>
</html>
`
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 == "" {
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")
template.Execute(c.Writer, tmp)
c.Status(400)
return
}
tmp.Text = "Email successfully verified"
template.Execute(c.Writer, tmp)
c.Status(200)
}
}

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

@ -39,21 +39,25 @@ func NewServer(opts NewServerOpts) *httpserver.Server {
r.Use(httpserver.NewRequestLogMiddleware(opts.Logger, opts.Tracer, prometheus)) r.Use(httpserver.NewRequestLogMiddleware(opts.Logger, opts.Tracer, prometheus))
r.Use(httpserver.NewTracingMiddleware(opts.Tracer)) 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 := v1.Group("/user")
{ {
userGroup.POST("/create", handlers.NewUserCreateHandler(opts.Logger, opts.UserService)) userGroup.POST("/create", handlers.NewUserCreateHandler(opts.Logger, opts.UserService))
userGroup.POST("/login", handlers.NewUserLoginHandler(opts.Logger, opts.UserService)) userGroup.POST("/login", handlers.NewUserLoginHandler(opts.Logger, opts.UserService))
} }
dummyGroup := v1.Group("/dummy") dummyGroup := v1.Group("/dummy")
dummyGroup.Use(middleware.NewAuthMiddleware(opts.UserService))
{ {
dummyGroup.Use(middleware.NewAuthMiddleware(opts.UserService))
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

@ -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

@ -1,10 +1,11 @@
package models package models
type UserDTO struct { type UserDTO struct {
Id string Id string
Email string Email string
Secret string EmailVerified bool
Name string Secret string
Name string
} }
type UserUpdateDTO struct { type UserUpdateDTO struct {

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=$1 and target=$2
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, id); 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

@ -13,11 +13,12 @@ import (
) )
var ( var (
ErrUserNotExists = fmt.Errorf("no such user") ErrUserNotExists = fmt.Errorf("no such user")
ErrUserExists = fmt.Errorf("user with this login already exists") ErrUserExists = fmt.Errorf("user with this login already exists")
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)
); );