Merge pull request #13 from Nucrea/dev

User flow improvements
This commit is contained in:
Sergey Chubaryan 2025-02-22 15:54:16 +03:00 committed by GitHub
commit c4c8f0c6ea
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 252 additions and 151 deletions

View File

@ -123,6 +123,7 @@ func (a *App) Run(p RunParams) {
eventRepo = repos.NewEventRepo(kafka)
userCache = cache.NewCacheInmemSharded[models.UserDTO](cache.ShardingTypeInteger)
loginAttemptsCache = cache.NewCacheInmem[string, int]()
jwtCache = cache.NewCacheInmemSharded[string](cache.ShardingTypeJWT)
linksCache = cache.NewCacheInmem[string, string]()
)
@ -140,6 +141,7 @@ func (a *App) Run(p RunParams) {
userCache.CheckExpired()
jwtCache.CheckExpired()
linksCache.CheckExpired()
loginAttemptsCache.CheckExpired()
}
}
}()
@ -150,6 +152,7 @@ func (a *App) Run(p RunParams) {
Password: passwordUtil,
UserRepo: userRepo,
UserCache: userCache,
LoginAttemptsCache: loginAttemptsCache,
JwtCache: jwtCache,
EventRepo: *eventRepo,
ActionTokenRepo: actionTokenRepo,

View File

@ -18,7 +18,7 @@ type createUserInput struct {
type createUserOutput struct {
Id string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
FullName string `json:"fullName"`
}
func NewUserCreateHandler(log logger.Logger, userService services.UserService) gin.HandlerFunc {
@ -39,7 +39,7 @@ func NewUserCreateHandler(log logger.Logger, userService services.UserService) g
return createUserOutput{
Id: user.Id,
Email: user.Email,
Name: user.Name,
FullName: user.FullName,
}, nil
},
)

View File

@ -16,7 +16,7 @@ type inputSendRestorePassword struct {
func NewUserSendRestorePasswordHandler(log logger.Logger, userService services.UserService) gin.HandlerFunc {
return httpserver.WrapGin(log,
func(ctx context.Context, input inputSendRestorePassword) (interface{}, error) {
err := userService.SendEmailForgotPassword(ctx, input.Email)
err := userService.RequestRestorePassword(ctx, input.Email)
return nil, err
},
)

View File

@ -16,7 +16,7 @@ type inputSendVerify struct {
func NewUserSendVerifyEmailHandler(log logger.Logger, userService services.UserService) gin.HandlerFunc {
return httpserver.WrapGin(log,
func(ctx context.Context, input inputSendVerify) (interface{}, error) {
err := userService.SendEmailVerifyUser(ctx, input.Email)
err := userService.RequestVerifyUser(ctx, input.Email)
return nil, err
},
)

View File

@ -2,13 +2,11 @@ package models
import "time"
type ActionTokenTarget int
type ActionTokenTarget string
const (
_ ActionTokenTarget = iota
ActionTokenTargetForgotPassword
ActionTokenTargetLogin2FA
ActionTokenVerifyEmail
ActionTokenTargetRestorePassword ActionTokenTarget = "restore"
ActionTokenTargetVerifyEmail ActionTokenTarget = "verify"
)
type ActionTokenDTO struct {

View File

@ -5,10 +5,10 @@ type UserDTO struct {
Email string
EmailVerified bool
Secret string
Name string
FullName string
}
type UserUpdateDTO struct {
Secret string
Name string
FullName string
}

View File

@ -26,7 +26,7 @@ type actionTokenRepo struct {
func (a *actionTokenRepo) CreateActionToken(ctx context.Context, dto models.ActionTokenDTO) (*models.ActionTokenDTO, error) {
query := `
insert into
action_tokens (user_id, value, target, expiration)
action_tokens (user_id, value, target, expires_at)
values ($1, $2, $3, $4)
returning id;`
row := a.db.QueryRowContext(ctx, query, dto.UserId, dto.Value, dto.Target, dto.Expiration)
@ -51,7 +51,7 @@ func (a *actionTokenRepo) GetActionToken(ctx context.Context, value string, targ
select id, user_id from action_tokens
where
value=$1 and target=$2
and CURRENT_TIMESTAMP < expiration;`
and CURRENT_TIMESTAMP < expires_at;`
row := a.db.QueryRowContext(ctx, query, value, target)
err := row.Scan(&dto.Id, &dto.UserId)

View File

@ -13,7 +13,7 @@ import (
type ShortlinkDTO struct {
Id string
Url string
Expiration time.Time
ExpiresAt time.Time
}
type ShortlinkRepo interface {
@ -35,8 +35,8 @@ func (u *shortlinkRepo) AddShortlink(ctx context.Context, dto ShortlinkDTO) erro
_, span := u.tracer.Start(ctx, "postgres::AddShortlink")
defer span.End()
query := `insert into shortlinks (id, url, expiration) values ($1, $2, $3);`
_, err := u.db.ExecContext(ctx, query, dto.Id, dto.Url, dto.Expiration)
query := `insert into shortlinks (url, expires_at) values ($1, $2);`
_, err := u.db.ExecContext(ctx, query, dto.Url, dto.ExpiresAt)
return err
}
@ -44,14 +44,14 @@ func (u *shortlinkRepo) GetShortlink(ctx context.Context, id string) (*Shortlink
_, span := u.tracer.Start(ctx, "postgres::GetShortlink")
defer span.End()
query := `select url, expiration from shortlinks where id = $1;`
query := `select url, expires_at from shortlinks where id = $1;`
row := u.db.QueryRowContext(ctx, query, id)
if err := row.Err(); err != nil {
return nil, err
}
dto := &ShortlinkDTO{Id: id}
err := row.Scan(&dto.Url, &dto.Expiration)
err := row.Scan(&dto.Url, &dto.ExpiresAt)
if err == nil {
return dto, nil
}

View File

@ -10,16 +10,10 @@ import (
"go.opentelemetry.io/otel/trace"
)
// type userDAO struct {
// Id string `json:"id"`
// Login string `json:"login"`
// Secret string `json:"secret"`
// Name string `json:"name"`
// }
type UserRepo interface {
CreateUser(ctx context.Context, dto models.UserDTO) (*models.UserDTO, error)
UpdateUser(ctx context.Context, userId string, dto models.UserUpdateDTO) error
DeactivateUser(ctx context.Context, userId string) 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)
@ -38,8 +32,8 @@ func (u *userRepo) CreateUser(ctx context.Context, dto models.UserDTO) (*models.
_, span := u.tracer.Start(ctx, "postgres::CreateUser")
defer span.End()
query := `insert into users (email, secret, name) values ($1, $2, $3) returning id;`
row := u.db.QueryRowContext(ctx, query, dto.Email, dto.Secret, dto.Name)
query := `insert into users (email, secret, full_name) values ($1, $2, $3) returning id;`
row := u.db.QueryRowContext(ctx, query, dto.Email, dto.Secret, dto.FullName)
id := ""
if err := row.Scan(&id); err != nil {
@ -50,7 +44,7 @@ func (u *userRepo) CreateUser(ctx context.Context, dto models.UserDTO) (*models.
Id: id,
Email: dto.Email,
Secret: dto.Secret,
Name: dto.Name,
FullName: dto.FullName,
}, nil
}
@ -58,8 +52,21 @@ func (u *userRepo) UpdateUser(ctx context.Context, userId string, dto models.Use
_, span := u.tracer.Start(ctx, "postgres::UpdateUser")
defer span.End()
query := `update users set secret=$1, name=$2 where id = $3;`
_, err := u.db.ExecContext(ctx, query, dto.Secret, dto.Name, userId)
query := `update users set secret=$1, full_name=$2 where id = $3;`
_, err := u.db.ExecContext(ctx, query, dto.Secret, dto.FullName, userId)
if err != nil {
return err
}
return nil
}
func (u *userRepo) DeactivateUser(ctx context.Context, userId string) error {
_, span := u.tracer.Start(ctx, "postgres::DeactivateUser")
defer span.End()
query := `update users set active=false where id = $1;`
_, err := u.db.ExecContext(ctx, query, userId)
if err != nil {
return err
}
@ -84,11 +91,13 @@ func (u *userRepo) GetUserById(ctx context.Context, id string) (*models.UserDTO,
_, span := u.tracer.Start(ctx, "postgres::GetUserById")
defer span.End()
query := `select id, email, secret, name, email_verified from users where id = $1;`
query := `
select id, email, secret, full_name, email_verified
from users where id = $1 and active;`
row := u.db.QueryRowContext(ctx, query, id)
dto := &models.UserDTO{}
err := row.Scan(&dto.Id, &dto.Email, &dto.Secret, &dto.Name, &dto.EmailVerified)
err := row.Scan(&dto.Id, &dto.Email, &dto.Secret, &dto.FullName, &dto.EmailVerified)
if err == nil {
return dto, nil
}
@ -103,11 +112,12 @@ 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, email_verified from users where email = $1;`
query := `select id, email, secret, full_name, email_verified
from users where email = $1 and active;`
row := u.db.QueryRowContext(ctx, query, login)
dto := &models.UserDTO{}
err := row.Scan(&dto.Id, &dto.Email, &dto.Secret, &dto.Name, &dto.EmailVerified)
err := row.Scan(&dto.Id, &dto.Email, &dto.Secret, &dto.FullName, &dto.EmailVerified)
if err == nil {
return dto, nil
}

View File

@ -50,7 +50,7 @@ func (s *shortlinkService) CreateShortlink(ctx context.Context, url string) (str
dto := repos.ShortlinkDTO{
Id: id,
Url: url,
Expiration: expiration,
ExpiresAt: expiration,
}
if err := s.repo.AddShortlink(ctx, dto); err != nil {
return "", err
@ -73,7 +73,7 @@ func (s *shortlinkService) GetShortlink(ctx context.Context, id string) (string,
if link == nil {
return "", ErrShortlinkNotexist
}
if time.Now().After(link.Expiration) {
if time.Now().After(link.ExpiresAt) {
return "", ErrShortlinkExpired
}

View File

@ -8,6 +8,7 @@ import (
"backend/pkg/logger"
"context"
"fmt"
"strings"
"time"
"github.com/google/uuid"
@ -31,13 +32,16 @@ type UserService interface {
CreateUser(ctx context.Context, params UserCreateParams) (*models.UserDTO, error)
AuthenticateUser(ctx context.Context, login, password string) (string, error)
ValidateAuthToken(ctx context.Context, tokenStr string) (*models.UserDTO, error)
// TODO: implement user deactivation flow
// DeactivateUser(ctx context.Context, userId string) error
VerifyEmail(ctx context.Context, actionToken string) error
SendEmailForgotPassword(ctx context.Context, userId string) error
SendEmailVerifyUser(ctx context.Context, email string) error
ChangePassword(ctx context.Context, userId, oldPassword, newPassword string) error
ChangePasswordWithToken(ctx context.Context, actionToken, newPassword string) error
RequestRestorePassword(ctx context.Context, email string) error
RequestVerifyUser(ctx context.Context, email string) error
}
func NewUserService(deps UserServiceDeps) UserService {
@ -50,6 +54,7 @@ type UserServiceDeps struct {
UserRepo repos.UserRepo
UserCache cache.Cache[string, models.UserDTO]
JwtCache cache.Cache[string, string]
LoginAttemptsCache cache.Cache[string, int]
EventRepo repos.EventRepo
ActionTokenRepo repos.ActionTokenRepo
Logger logger.Logger
@ -84,9 +89,9 @@ func (u *userService) CreateUser(ctx context.Context, params UserCreateParams) (
}
user := models.UserDTO{
Email: params.Email,
Email: strings.ToLower(params.Email),
Secret: string(secret),
Name: params.Name,
FullName: params.Name,
}
result, err := u.deps.UserRepo.CreateUser(ctx, user)
@ -104,6 +109,22 @@ func (u *userService) CreateUser(ctx context.Context, params UserCreateParams) (
}
func (u *userService) AuthenticateUser(ctx context.Context, email, password string) (string, error) {
attempts, ok := u.deps.LoginAttemptsCache.Get(email)
if ok && attempts >= 4 {
return "", fmt.Errorf("too many bad login attempts")
}
token, err := u.authenticateUser(ctx, email, password)
if err != nil {
u.deps.LoginAttemptsCache.Set(email, attempts+1, cache.Expiration{Ttl: 30 * time.Second})
return "", err
}
u.deps.LoginAttemptsCache.Del(email)
return token, nil
}
func (u *userService) authenticateUser(ctx context.Context, email, password string) (string, error) {
user, err := u.deps.UserRepo.GetUserByEmail(ctx, email)
if err != nil {
return "", err
@ -131,8 +152,18 @@ func (u *userService) AuthenticateUser(ctx context.Context, email, password stri
return jwt, nil
}
func (u *userService) DeactivateUser(ctx context.Context, userId string) error {
err := u.deps.UserRepo.DeactivateUser(ctx, userId)
if err != nil {
return err
}
u.deps.UserCache.Del(userId)
return nil
}
func (u *userService) VerifyEmail(ctx context.Context, actionToken string) error {
token, err := u.deps.ActionTokenRepo.GetActionToken(ctx, actionToken, models.ActionTokenVerifyEmail)
token, err := u.deps.ActionTokenRepo.GetActionToken(ctx, actionToken, models.ActionTokenTargetVerifyEmail)
if err != nil {
return err
}
@ -149,64 +180,8 @@ func (u *userService) VerifyEmail(ctx context.Context, actionToken string) error
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
}
actionToken, err := u.deps.ActionTokenRepo.CreateActionToken(
ctx,
models.ActionTokenDTO{
UserId: user.Id,
Value: uuid.New().String(),
Target: models.ActionTokenTargetForgotPassword,
Expiration: time.Now().Add(15 * time.Minute),
},
)
if err != nil {
return err
}
return u.deps.EventRepo.SendEmailForgotPassword(ctx, user.Email, actionToken.Value)
}
func (u *userService) sendEmailVerifyUser(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.SendEmailVerifyUser(ctx, email, actionToken.Value)
}
func (u *userService) SendEmailVerifyUser(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
}
if user == nil {
return fmt.Errorf("no such user")
}
if user.EmailVerified {
return fmt.Errorf("user already verified")
}
return u.sendEmailVerifyUser(ctx, user.Id, user.Email)
}
func (u *userService) ChangePasswordWithToken(ctx context.Context, actionToken, newPassword string) error {
token, err := u.deps.ActionTokenRepo.GetActionToken(ctx, actionToken, models.ActionTokenTargetForgotPassword)
token, err := u.deps.ActionTokenRepo.GetActionToken(ctx, actionToken, models.ActionTokenTargetRestorePassword)
if err != nil {
return err
}
@ -258,7 +233,7 @@ func (u *userService) updatePassword(ctx context.Context, user models.UserDTO, n
if err = u.deps.UserRepo.UpdateUser(ctx, user.Id, models.UserUpdateDTO{
Secret: newSecret,
Name: user.Name,
FullName: user.FullName,
}); err != nil {
return err
}
@ -307,3 +282,57 @@ func (u *userService) ValidateAuthToken(ctx context.Context, tokenStr string) (*
return user, nil
}
func (u *userService) RequestRestorePassword(ctx context.Context, email string) error {
user, err := u.deps.UserRepo.GetUserByEmail(ctx, email)
if err != nil {
return err
}
actionToken, err := u.deps.ActionTokenRepo.CreateActionToken(
ctx,
models.ActionTokenDTO{
UserId: user.Id,
Value: uuid.New().String(),
Target: models.ActionTokenTargetRestorePassword,
Expiration: time.Now().Add(15 * time.Minute),
},
)
if err != nil {
return err
}
return u.deps.EventRepo.SendEmailForgotPassword(ctx, user.Email, actionToken.Value)
}
func (u *userService) RequestVerifyUser(ctx context.Context, email string) error {
user, err := u.deps.UserRepo.GetUserByEmail(ctx, email)
if err != nil {
return err
}
if user == nil {
return fmt.Errorf("no such user")
}
if user.EmailVerified {
return fmt.Errorf("user already verified")
}
return u.sendEmailVerifyUser(ctx, user.Id, user.Email)
}
func (u *userService) sendEmailVerifyUser(ctx context.Context, userId, email string) error {
actionToken, err := u.deps.ActionTokenRepo.CreateActionToken(
ctx,
models.ActionTokenDTO{
UserId: userId,
Value: uuid.New().String(),
Target: models.ActionTokenTargetVerifyEmail,
Expiration: time.Now().Add(1 * time.Hour),
},
)
if err != nil {
return err
}
return u.deps.EventRepo.SendEmailVerifyUser(ctx, email, actionToken.Value)
}

18
sql/00_common.sql Normal file
View File

@ -0,0 +1,18 @@
create or replace function trg_proc_row_updated()
returns trigger as $$
begin
if new is distinct from old then
new.updated_at = now();
end if;
return new;
end;
$$ language plpgsql;
create or replace function trg_proc_row_created()
returns trigger as $$
begin
new.created_at = now();
new.updated_at = now();
return new;
end;
$$ language plpgsql;

View File

@ -1,11 +1,25 @@
create table if not exists users (
id int generated always as identity,
email text unique not null,
secret text not null,
name text not null,
id integer primary key generated always as identity,
email varchar(256) unique not null,
secret varchar(256) not null,
full_name varchar(256) not null,
email_verified boolean not null default false,
primary key (id)
active boolean default true,
created_at timestamp,
updated_at timestamp
);
create index if not exists users_email_idx on users(email);
alter table users alter column active set default true;
create index if not exists idx_users_email on users(email);
create or replace trigger trg_user_created
before insert on users
for each row
execute function trg_proc_row_created();
create or replace trigger trg_user_updated
before update on users
for each row
when(new is distinct from old)
execute function trg_proc_row_updated();

View File

@ -1,5 +1,18 @@
create table if not exists shortlinks (
id text primary key,
url text,
expiration date
id int generated always as identity,
url text not null,
expires_at timestamp not null,
created_at timestamp,
updated_at timestamp
);
create or replace trigger trg_shortlink_created
before insert on shortlinks
for each row
execute function trg_proc_row_created();
create or replace trigger trg_shortlink_updated
before update on shortlinks
for each row
when (new is distinct from old)
execute function trg_proc_row_updated();

View File

@ -1,9 +1,25 @@
create table if not exists action_tokens (
id int generated always as identity,
user_id int,
value text,
target int,
expiration timestamp,
user_id int references users(id),
value text not null,
target text not null,
expires_at timestamp not null,
created_at timestamp,
updated_at timestamp,
primary key(id)
constraint pk_action_tokens_id primary key(id),
constraint chk_action_tokens_target check(target in ('verify', 'restore'))
);
create index if not exists idx_action_tokens_value on action_tokens(value);
create or replace trigger trg_action_token_created
before insert on action_tokens
for each row
execute function trg_proc_row_created();
create or replace trigger trg_action_token_updated
before update on action_tokens
for each row
when (new is distinct from old)
execute function trg_proc_row_updated();