diff --git a/cmd/backend/config_defaults/config.yaml b/cmd/backend/config.yaml
similarity index 72%
rename from cmd/backend/config_defaults/config.yaml
rename to cmd/backend/config.yaml
index f2e060c..d5df5f4 100644
--- a/cmd/backend/config_defaults/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/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/handlers/user_verify_handler.go b/cmd/backend/server/handlers/user_verify_handler.go
new file mode 100644
index 0000000..b6a6163
--- /dev/null
+++ b/cmd/backend/server/handlers/user_verify_handler.go
@@ -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 = `
+
+
+ {{.TabTitle}}
+
+
+ {{if .Title}}
+ {{.Title}}
+ {{end}}
+
+ {{.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 == "" {
+ 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)
+ }
+}
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 50f7877..68e0ec0 100644
--- a/cmd/backend/server/server.go
+++ b/cmd/backend/server/server.go
@@ -39,21 +39,25 @@ 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)
- opts.UserService.ForgotPassword(c, user.Id)
+ opts.UserService.SendEmailForgotPassword(c, user.Id)
})
}
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..7eda5f0 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=$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 {
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, id); 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)
);