From e8f0e43836d6dc6e1e37bc5770acec8b1b73788c Mon Sep 17 00:00:00 2001 From: lafetz Date: Fri, 28 Mar 2025 01:30:55 +0300 Subject: [PATCH 1/5] impl service layer for auth --- db/migrations/000001_fortune.up.sql | 2 +- gen/db/models.go | 2 +- gen/db/user.sql.go | 4 +- go.mod | 10 +- go.sum | 11 ++ internal/domain/auth.go | 11 ++ internal/domain/user.go | 6 +- internal/repository/user.go | 8 +- internal/services/authentication/impl.go | 131 ++++++++++++++++++ internal/services/authentication/port.go | 16 +++ internal/services/authentication/service.go | 28 ++++ internal/services/user/port.go | 2 + internal/services/user/service.go | 11 +- internal/web_server/jwt/jwt.go | 53 +++++++ internal/web_server/jwt/jwt_test.go | 89 ++++++++++++ .../web_server/{app_routes.go => routes.go} | 0 16 files changed, 372 insertions(+), 12 deletions(-) create mode 100644 internal/services/authentication/impl.go create mode 100644 internal/services/authentication/port.go create mode 100644 internal/services/authentication/service.go create mode 100644 internal/web_server/jwt/jwt.go create mode 100644 internal/web_server/jwt/jwt_test.go rename internal/web_server/{app_routes.go => routes.go} (100%) diff --git a/db/migrations/000001_fortune.up.sql b/db/migrations/000001_fortune.up.sql index bfd54da..b1df3e1 100644 --- a/db/migrations/000001_fortune.up.sql +++ b/db/migrations/000001_fortune.up.sql @@ -4,7 +4,7 @@ CREATE TABLE users ( last_name VARCHAR(255) NOT NULL, email VARCHAR(255) UNIQUE NOT NULL, phone_number VARCHAR(20) UNIQUE NOT NULL, - password TEXT NOT NULL, + password BYTEA NOT NULL, role VARCHAR(50) NOT NULL, verified BOOLEAN DEFAULT FALSE, created_at TIMESTAMP , diff --git a/gen/db/models.go b/gen/db/models.go index 307326d..82db472 100644 --- a/gen/db/models.go +++ b/gen/db/models.go @@ -14,7 +14,7 @@ type User struct { LastName string Email string PhoneNumber string - Password string + Password []byte Role string Verified pgtype.Bool CreatedAt pgtype.Timestamp diff --git a/gen/db/user.sql.go b/gen/db/user.sql.go index 1608a32..c1b551e 100644 --- a/gen/db/user.sql.go +++ b/gen/db/user.sql.go @@ -22,7 +22,7 @@ type CreateUserParams struct { LastName string Email string PhoneNumber string - Password string + Password []byte Role string Verified pgtype.Bool } @@ -129,7 +129,7 @@ type UpdateUserParams struct { LastName string Email string PhoneNumber string - Password string + Password []byte Role string Verified pgtype.Bool } diff --git a/go.mod b/go.mod index 18620e4..73cdf76 100644 --- a/go.mod +++ b/go.mod @@ -5,32 +5,38 @@ go 1.24.1 require ( github.com/bytedance/sonic v1.13.2 github.com/gofiber/fiber/v2 v2.52.6 + github.com/golang-jwt/jwt/v5 v5.2.2 github.com/jackc/pgx/v5 v5.7.4 github.com/joho/godotenv v1.5.1 + github.com/stretchr/testify v1.8.4 + golang.org/x/crypto v0.32.0 ) require ( github.com/andybalholm/brotli v1.1.0 // indirect github.com/bytedance/sonic/loader v0.2.4 // indirect github.com/cloudwego/base64x v0.1.5 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/google/uuid v1.6.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/klauspost/compress v1.17.9 // indirect github.com/klauspost/cpuid/v2 v2.0.9 // indirect + github.com/kr/text v0.2.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.2.0 // indirect - github.com/stretchr/testify v1.8.4 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasthttp v1.51.0 // indirect github.com/valyala/tcplisten v1.0.0 // indirect golang.org/x/arch v0.0.0-20210923205945-b76863e36670 // indirect - golang.org/x/crypto v0.32.0 // indirect golang.org/x/sync v0.10.0 // indirect golang.org/x/sys v0.29.0 // indirect golang.org/x/text v0.21.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 9d6be49..d53d292 100644 --- a/go.sum +++ b/go.sum @@ -8,11 +8,14 @@ github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFos github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4= github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/gofiber/fiber/v2 v2.52.6 h1:Rfp+ILPiYSvvVuIPvxrBns+HJp8qGLDnLJawAu27XVI= github.com/gofiber/fiber/v2 v2.52.6/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= +github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= @@ -30,6 +33,10 @@ github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ib github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= @@ -41,6 +48,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -72,6 +81,8 @@ golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/domain/auth.go b/internal/domain/auth.go index 4188b5a..513ff8e 100644 --- a/internal/domain/auth.go +++ b/internal/domain/auth.go @@ -1 +1,12 @@ package domain + +import "time" + +type RefreshToken struct { + ID int64 + UserID int64 + Token string + ExpiresAt time.Time + CreatedAt time.Time + Revoked bool +} diff --git a/internal/domain/user.go b/internal/domain/user.go index 1e6b181..21d1a77 100644 --- a/internal/domain/user.go +++ b/internal/domain/user.go @@ -1,12 +1,16 @@ package domain +import "time" + type User struct { ID int64 FirstName string LastName string Email string PhoneNumber string - Password string + Password []byte Role string Verified bool + CreatedAt time.Time + UpdatedAt time.Time } diff --git a/internal/repository/user.go b/internal/repository/user.go index b19798f..d6f7a36 100644 --- a/internal/repository/user.go +++ b/internal/repository/user.go @@ -13,8 +13,8 @@ func (s *Store) CreateUser(ctx context.Context, firstName, lastName, email, phon LastName: lastName, Email: email, PhoneNumber: phoneNumber, - Password: password, - Role: role, + // Password: password, + Role: role, }) if err != nil { return domain.User{}, err @@ -71,8 +71,8 @@ func (s *Store) UpdateUser(ctx context.Context, id int64, firstName, lastName, e LastName: lastName, Email: email, PhoneNumber: phoneNumber, - Password: password, - Role: role, + // Password: password, + Role: role, }) return err } diff --git a/internal/services/authentication/impl.go b/internal/services/authentication/impl.go new file mode 100644 index 0000000..1bac295 --- /dev/null +++ b/internal/services/authentication/impl.go @@ -0,0 +1,131 @@ +package authentication + +import ( + "context" + "crypto/rand" + "encoding/base32" + "errors" + "time" + + "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" + "golang.org/x/crypto/bcrypt" +) + +var ( + ErrInvalidPassword = errors.New("incorrect password") + ErrUserNotFound = errors.New("user not found") + ErrExpiredToken = errors.New("token expired") + ErrRefreshTokenNotFound = errors.New("refresh token not found") // i.e login again +) + +func (s *Service) Login(ctx context.Context, emailPhone EmailPhone, password string) (string, error) { + user, err := s.userStore.GetUserByEmailPhone(ctx, emailPhone) + if err != nil { + return "", err + } + err = matchPassword(password, user.Password) + if err != nil { + return "", err + } + // //create session + // accessToken, err := CreateJwt(strconv.Itoa(int(user.ID)), s.jwtConfig.JwtAccessKey, s.jwtConfig.JwtAccessExpiry) + // if err != nil { + // return Tokens{}, err + // } + refreshToken, err := generateRefreshToken() + if err != nil { + return "", err + } + err = s.tokenStore.CreateRefreshToken(ctx, domain.RefreshToken{ + Token: refreshToken, + UserID: user.ID, + CreatedAt: time.Now(), + ExpiresAt: time.Now().Add(time.Duration(s.RefreshExpiry) * time.Second), + }) + if err != nil { + return "", err + } + return refreshToken, nil +} + +func (s *Service) RefreshToken(ctx context.Context, refToken string) (string, error) { + // us, err := ParseJwt(tokens.RefreshToken, s.jwtConfig.JwtAccessKey) + // if err == nil { + // return Tokens{}, err + // } + // if !errors.Is(err, ErrExpiredToken) { + // return Tokens{}, err + // } + token, err := s.tokenStore.GetRefreshToken(ctx, refToken) + if err != nil { + return "", err + } + if token.Revoked { + return "", ErrRefreshTokenNotFound + } + if token.ExpiresAt.Before(time.Now()) { + return "", ErrExpiredToken + } + // + // naccessToken, err := CreateJwt(token., s.jwtConfig.JwtAccessKey, s.jwtConfig.JwtAccessExpiry) + // if err != nil { + // return Tokens{}, err + // } + newRefToken, err := generateRefreshToken() + if err != nil { + return "", err + } + + // ntokens := Tokens{ + // AccessToken: naccessToken, + // RefreshToken: nrefreshToken, + // } + + err = s.tokenStore.CreateRefreshToken(ctx, domain.RefreshToken{ + Token: newRefToken, + UserID: token.UserID, + CreatedAt: time.Now(), + ExpiresAt: time.Now().Add(time.Duration(s.RefreshExpiry) * time.Second), + }) + if err != nil { + return "", err + } + return newRefToken, nil +} +func (s *Service) Logout(ctx context.Context, refToken string) error { + token, err := s.tokenStore.GetRefreshToken(ctx, refToken) + if err != nil { + return err + } + if token.Revoked { + return ErrRefreshTokenNotFound + } + if token.ExpiresAt.Before(time.Now()) { + return ErrExpiredToken + } + + return s.tokenStore.RevokeRefreshToken(ctx, refToken) +} + +func matchPassword(plaintextPassword string, hash []byte) error { + err := bcrypt.CompareHashAndPassword(hash, []byte(plaintextPassword)) + if err != nil { + switch { + case errors.Is(err, bcrypt.ErrMismatchedHashAndPassword): + return ErrInvalidPassword + default: + return err + } + } + + return err +} +func generateRefreshToken() (string, error) { + randomBytes := make([]byte, 32) + _, err := rand.Read(randomBytes) + if err != nil { + return "", err + } + plaintext := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(randomBytes) + return plaintext, nil +} diff --git a/internal/services/authentication/port.go b/internal/services/authentication/port.go new file mode 100644 index 0000000..a9f3136 --- /dev/null +++ b/internal/services/authentication/port.go @@ -0,0 +1,16 @@ +package authentication + +import ( + "context" + + "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" +) + +type UserStore interface { + GetUserByEmailPhone(ctx context.Context, emailPhone EmailPhone) (domain.User, error) +} +type TokenStore interface { + CreateRefreshToken(ctx context.Context, rt domain.RefreshToken) error + GetRefreshToken(ctx context.Context, token string) (domain.RefreshToken, error) + RevokeRefreshToken(ctx context.Context, token string) error +} diff --git a/internal/services/authentication/service.go b/internal/services/authentication/service.go new file mode 100644 index 0000000..e40b75b --- /dev/null +++ b/internal/services/authentication/service.go @@ -0,0 +1,28 @@ +package authentication + +type EmailPhone struct { + Email ValidString + PhoneNumber ValidString + Password ValidString +} +type ValidString struct { + Value string + Valid bool +} +type Tokens struct { + AccessToken string + RefreshToken string +} +type Service struct { + userStore UserStore + tokenStore TokenStore + RefreshExpiry int +} + +func NewService(userStore UserStore, tokenStore TokenStore, RefreshExpiry int) *Service { + return &Service{ + userStore: userStore, + tokenStore: tokenStore, + RefreshExpiry: RefreshExpiry, + } +} diff --git a/internal/services/user/port.go b/internal/services/user/port.go index 34700ad..b6f20cf 100644 --- a/internal/services/user/port.go +++ b/internal/services/user/port.go @@ -12,4 +12,6 @@ type UserStore interface { GetAllUsers(ctx context.Context) ([]domain.User, error) UpdateUser(ctx context.Context, id int64, firstName, lastName, email, phoneNumber, password, role string, verified bool) error DeleteUser(ctx context.Context, id int64) error + // + //GetUserByEmailPhone(ctx context.Context, emailPhone EmailPhone) (domain.User, error) } diff --git a/internal/services/user/service.go b/internal/services/user/service.go index d427b66..8232bbb 100644 --- a/internal/services/user/service.go +++ b/internal/services/user/service.go @@ -4,13 +4,14 @@ import ( "context" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" + "golang.org/x/crypto/bcrypt" ) type Service struct { userStore UserStore } -func NewService(userStore UserStore) *Service { +func NewService(userStore UserStore, RefreshExpiry int) *Service { return &Service{ userStore: userStore, } @@ -31,3 +32,11 @@ func (s *Service) UpdateUser(ctx context.Context, id int64, firstName, lastName, func (s *Service) DeleteUser(ctx context.Context, id int64) error { return s.userStore.DeleteUser(ctx, id) } +func hashPassword(plaintextPassword string) ([]byte, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(plaintextPassword), 12) + if err != nil { + return []byte{}, err + } + + return hash, nil +} diff --git a/internal/web_server/jwt/jwt.go b/internal/web_server/jwt/jwt.go new file mode 100644 index 0000000..4dd89e1 --- /dev/null +++ b/internal/web_server/jwt/jwt.go @@ -0,0 +1,53 @@ +package user + +import ( + "errors" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +// type UserToken struct { +// UserId string +// } +var ( + ErrExpiredToken = errors.New("token expired") + ErrMalformedToken = errors.New("token malformed") + ErrTokenNotExpired = errors.New("token not expired") + ErrRefreshTokenNotFound = errors.New("refresh token not found") // i.e login again +) + +type UserClaim struct { + jwt.RegisteredClaims + UserId string +} + +func CreateJwt(userId string, key string, expiry int) (string, error) { + token := jwt.NewWithClaims(jwt.SigningMethodHS256, UserClaim{RegisteredClaims: jwt.RegisteredClaims{Issuer: "github.com/lafetz/snippitstash", + IssuedAt: jwt.NewNumericDate(time.Now()), + Audience: jwt.ClaimStrings{"fortune.com"}, + NotBefore: jwt.NewNumericDate(time.Now()), + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(expiry) * time.Second))}, + UserId: userId, + }) + jwtToken, err := token.SignedString([]byte(key)) // + return jwtToken, err +} +func ParseJwt(jwtToken string, key string) (*UserClaim, error) { + token, err := jwt.ParseWithClaims(jwtToken, &UserClaim{}, func(token *jwt.Token) (interface{}, error) { + return []byte(key), nil + }) + if err != nil { + if errors.Is(err, jwt.ErrTokenExpired) { + return nil, ErrExpiredToken + } + if errors.Is(err, jwt.ErrTokenMalformed) { + return nil, ErrMalformedToken + } + return nil, err + } + if claims, ok := token.Claims.(*UserClaim); ok && token.Valid { + return claims, nil + } + return nil, err +} diff --git a/internal/web_server/jwt/jwt_test.go b/internal/web_server/jwt/jwt_test.go new file mode 100644 index 0000000..45d1dcc --- /dev/null +++ b/internal/web_server/jwt/jwt_test.go @@ -0,0 +1,89 @@ +package user + +// func TestCreateJwt(t *testing.T) { +// // Define a user to test +// user := &domain.User{ +// ID: 123, +// } + +// // Secret key used for signing the JWT +// secretKey := "secret" + +// // Token expiry time (in seconds) +// expiry := 3600 // 1 hour + +// // Call CreateJwt function +// tokenString, err := CreateJwt(user, secretKey, expiry) + +// // Assertions +// assert.NoError(t, err, "Error should be nil when creating a JWT") +// assert.NotEmpty(t, tokenString, "Token string should not be empty") + +// // Parse the token back and verify its claims +// claims, err := ParseJwt(tokenString, secretKey) +// assert.NoError(t, err, "Error should be nil when parsing the JWT") +// assert.Equal(t, strconv.Itoa(int(user.ID)), claims.UserId, "User ID should match") +// assert.Equal(t, "github.com/lafetz/snippitstash", claims.Issuer, "Issuer should match") +// assert.True(t, claims.ExpiresAt.Time.After(time.Now()), "Token should not be expired yet") +// expectedExpiryTime := time.Now().Add(time.Duration(expiry) * time.Second) +// // Allow for a small margin of error due to the time delay in generating the token +// assert.True(t, claims.ExpiresAt.Time.Before(expectedExpiryTime.Add(1*time.Second)), +// "Token expiry time should be within the expected range") +// assert.True(t, claims.ExpiresAt.Time.After(expectedExpiryTime.Add(-1*time.Second)), +// "Token expiry time should be within the expected range") +// } +// func TestParseJwt(t *testing.T) { +// // Define a user to test +// user := &domain.User{ +// ID: 123, +// } + +// // Secret key used for signing the JWT +// secretKey := "secret" + +// // Token expiry time (in seconds) +// expiry := 3600 // 1 hour + +// // Generate a token using the CreateJwt function +// tokenString, err := CreateJwt(user, secretKey, expiry) +// assert.NoError(t, err, "Error should be nil when creating a JWT") +// assert.NotEmpty(t, tokenString, "Token string should not be empty") + +// // Now, we will parse the token +// claims, err := ParseJwt(tokenString, secretKey) +// assert.NoError(t, err, "Error should be nil when parsing the JWT") +// assert.NotNil(t, claims, "Claims should not be nil") + +// // Verify that the claims match the user and other values +// assert.Equal(t, strconv.Itoa(int(user.ID)), claims.UserId, "User ID should match") +// assert.Equal(t, "github.com/lafetz/snippitstash", claims.Issuer, "Issuer should match") +// assert.Equal(t, "fortune.com", claims.Audience[0], "Audience should match") +// assert.True(t, claims.ExpiresAt.Time.After(time.Now()), "Token should not be expired yet") + +// // Ensure the parsing fails when using an invalid token +// invalidToken := tokenString + "invalid" +// _, err = ParseJwt(invalidToken, secretKey) +// assert.Error(t, err, "Parsing an invalid token should return an error") +// } +// func TestParseJwte(t *testing.T) { +// // Define user and key +// user := &domain.User{ID: 1} +// key := "secretkey" + +// // Test valid token (not expired) +// validJwt, err := CreateJwt(user, key, 4) // Set expiry to 10 seconds +// assert.NoError(t, err) + +// // Test if the token is parsed correctly +// claims, err := ParseJwt(validJwt, key) +// assert.NoError(t, err) +// assert.Equal(t, "1", claims.UserId) + +// // Wait for token to expire +// time.Sleep(5 * time.Second) // Wait longer than the expiry time to test expiration + +// // Test expired token +// _, err = ParseJwt(validJwt, key) + +// assert.Error(t, jwt.ErrTokenExpired) // Expect an error because the token should be expired +// } diff --git a/internal/web_server/app_routes.go b/internal/web_server/routes.go similarity index 100% rename from internal/web_server/app_routes.go rename to internal/web_server/routes.go From c8b215cc43f88057878e993ba76a9384439252d1 Mon Sep 17 00:00:00 2001 From: lafetz Date: Sat, 29 Mar 2025 06:25:19 +0300 Subject: [PATCH 2/5] impl auth http layer --- cmd/main.go | 41 ++- db/migrations/000001_fortune.up.sql | 30 ++- db/query/auth.sql | 16 ++ docs/docs.go | 285 ++++++++++++++++++++ docs/swagger.json | 259 ++++++++++++++++++ docs/swagger.yaml | 172 ++++++++++++ gen/db/auth.sql.go | 94 +++++++ gen/db/models.go | 13 +- go.mod | 40 ++- go.sum | 165 ++++++++++-- internal/config/config.go | 61 ++++- internal/logger/logger.go | 7 +- internal/repository/auth.go | 71 +++++ internal/services/authentication/impl.go | 49 ++-- internal/services/authentication/port.go | 2 +- internal/services/authentication/service.go | 10 +- internal/web_server/app.go | 30 ++- internal/web_server/dto/user.go | 6 + internal/web_server/jwt/jwt.go | 6 +- internal/web_server/jwt/jwt_test.go | 2 +- internal/web_server/middleware.go | 43 +++ internal/web_server/response/res.go | 47 ++++ internal/web_server/routes.go | 20 +- internal/web_server/user_handler.go | 170 ++++++++++++ internal/web_server/validator/validatord.go | 66 ++++- load_env.sh | 16 ++ makefile | 4 +- 27 files changed, 1621 insertions(+), 104 deletions(-) create mode 100644 db/query/auth.sql create mode 100644 docs/docs.go create mode 100644 docs/swagger.json create mode 100644 docs/swagger.yaml create mode 100644 gen/db/auth.sql.go create mode 100644 internal/repository/auth.go create mode 100644 internal/web_server/dto/user.go create mode 100644 internal/web_server/middleware.go create mode 100644 internal/web_server/response/res.go create mode 100644 internal/web_server/user_handler.go create mode 100644 load_env.sh diff --git a/cmd/main.go b/cmd/main.go index 7899d54..451f9d4 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -6,16 +6,29 @@ import ( "os" "github.com/SamuelTariku/FortuneBet-Backend/internal/config" + customlogger "github.com/SamuelTariku/FortuneBet-Backend/internal/logger" "github.com/SamuelTariku/FortuneBet-Backend/internal/repository" - "github.com/joho/godotenv" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/authentication" + httpserver "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server" + customvalidator "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/validator" + "github.com/go-playground/validator/v10" ) +// @title FortuneBet API +// @version 1.0 +// @description This is server for FortuneBet. +// @termsOfService http://swagger.io/terms/ +// @contact.name API Support +// @contact.url http://www.swagger.io/support +// @contact.email support@swagger.io +// @license.name Apache 2.0 +// @license.url http://www.apache.org/licenses/LICENSE-2.0.html +// @SecurityDefinitions.apiKey Bearer +// @in header +// @name Authorization +// @BasePath / func main() { - err := godotenv.Load() - if err != nil { - slog.Error(err.Error()) - os.Exit(1) - } + cfg, err := config.NewConfig() if err != nil { slog.Error(err.Error()) @@ -23,9 +36,21 @@ func main() { } db, _, err := repository.OpenDB(cfg.DbUrl) if err != nil { - fmt.Print(err) + fmt.Print("db", err) os.Exit(1) } + logger := customlogger.NewLogger(cfg.Env, cfg.LogLevel) store := repository.NewStore(db) - fmt.Println(store) + v := customvalidator.NewCustomValidator(validator.New()) + authSvc := authentication.NewService(store, store, cfg.RefreshExpiry) + app := httpserver.NewApp(cfg.Port, v, authSvc, logger, httpserver.JwtConfig{ + JwtAccessKey: cfg.JwtKey, + JwtAccessExpiry: cfg.AccessExpiry, + }) + logger.Info("Starting server", "port", cfg.Port) + if err := app.Run(); err != nil { + logger.Error("Failed to start server", "error", err) + os.Exit(1) + } + } diff --git a/db/migrations/000001_fortune.up.sql b/db/migrations/000001_fortune.up.sql index b1df3e1..69b5959 100644 --- a/db/migrations/000001_fortune.up.sql +++ b/db/migrations/000001_fortune.up.sql @@ -7,6 +7,32 @@ CREATE TABLE users ( password BYTEA NOT NULL, role VARCHAR(50) NOT NULL, verified BOOLEAN DEFAULT FALSE, - created_at TIMESTAMP , - updated_at TIMESTAMP + created_at TIMESTAMPTZ , + updated_at TIMESTAMPTZ , + CONSTRAINT unique_email_phone UNIQUE (email, phone_number) +); +CREATE TABLE refresh_tokens ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL, + token TEXT NOT NULL UNIQUE, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP NOT NULL, + revoked BOOLEAN DEFAULT FALSE NOT NULL, + CONSTRAINT unique_token UNIQUE (token) +); +----------------------------------------------seed data------------------------------------------------------------- +-------------------------------------- DO NOT USE IN PRODUCTION------------------------------------------------- + +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +INSERT INTO users (first_name, last_name, email, phone_number, password, role, created_at, updated_at) +VALUES ( + 'John', + 'Doe', + 'john.doe@example.com', + '1234567890', + crypt('password123', gen_salt('bf'))::bytea, + 'user', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP ); \ No newline at end of file diff --git a/db/query/auth.sql b/db/query/auth.sql new file mode 100644 index 0000000..71e45ec --- /dev/null +++ b/db/query/auth.sql @@ -0,0 +1,16 @@ +-- name: GetUserByEmailPhone :one +SELECT * FROM users +WHERE email = $1 OR phone_number = $2; + +-- name: CreateRefreshToken :exec +INSERT INTO refresh_tokens (user_id, token, expires_at, created_at, revoked) +VALUES ($1, $2, $3, $4, $5); + +-- name: GetRefreshToken :one +SELECT * FROM refresh_tokens +WHERE token = $1; + +-- name: RevokeRefreshToken :exec +UPDATE refresh_tokens +SET revoked = TRUE +WHERE token = $1; \ No newline at end of file diff --git a/docs/docs.go b/docs/docs.go new file mode 100644 index 0000000..28a683c --- /dev/null +++ b/docs/docs.go @@ -0,0 +1,285 @@ +// Package docs Code generated by swaggo/swag. DO NOT EDIT +package docs + +import "github.com/swaggo/swag" + +const docTemplate = `{ + "schemes": {{ marshal .Schemes }}, + "swagger": "2.0", + "info": { + "description": "{{escape .Description}}", + "title": "{{.Title}}", + "termsOfService": "http://swagger.io/terms/", + "contact": { + "name": "API Support", + "url": "http://www.swagger.io/support", + "email": "support@swagger.io" + }, + "license": { + "name": "Apache 2.0", + "url": "http://www.apache.org/licenses/LICENSE-2.0.html" + }, + "version": "{{.Version}}" + }, + "host": "{{.Host}}", + "basePath": "{{.BasePath}}", + "paths": { + "/auth/login": { + "post": { + "description": "Login customer", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Login customer", + "parameters": [ + { + "description": "Login customer", + "name": "login", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/httpserver.loginCustomerReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/httpserver.loginCustomerRes" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, + "/auth/logout": { + "post": { + "description": "Logout customer", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Logout customer", + "parameters": [ + { + "description": "Logout customer", + "name": "logout", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/httpserver.logoutReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, + "/auth/refresh": { + "post": { + "description": "Refresh token", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Refresh token", + "parameters": [ + { + "description": "tokens", + "name": "refresh", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/httpserver.refreshToken" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/httpserver.loginCustomerRes" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + } + }, + "definitions": { + "httpserver.loginCustomerReq": { + "type": "object", + "properties": { + "email": { + "type": "string", + "example": "john.doe@example.com" + }, + "password": { + "type": "string", + "example": "password123" + }, + "phone_number": { + "type": "string", + "example": "1234567890" + } + } + }, + "httpserver.loginCustomerRes": { + "type": "object", + "properties": { + "access_token": { + "type": "string" + }, + "refresh_token": { + "type": "string" + } + } + }, + "httpserver.logoutReq": { + "type": "object", + "properties": { + "refresh_token": { + "type": "string" + } + } + }, + "httpserver.refreshToken": { + "type": "object", + "properties": { + "access_token": { + "type": "string" + }, + "refresh_token": { + "type": "string" + } + } + }, + "response.APIResponse": { + "type": "object", + "properties": { + "data": {}, + "message": { + "type": "string" + }, + "metadata": {}, + "status": { + "$ref": "#/definitions/response.Status" + }, + "timestamp": { + "type": "string" + } + } + }, + "response.Status": { + "type": "string", + "enum": [ + "error", + "success" + ], + "x-enum-varnames": [ + "Error", + "Success" + ] + } + }, + "securityDefinitions": { + "Bearer": { + "type": "apiKey", + "name": "Authorization", + "in": "header" + } + } +}` + +// SwaggerInfo holds exported Swagger Info so clients can modify it +var SwaggerInfo = &swag.Spec{ + Version: "1.0", + Host: "", + BasePath: "", + Schemes: []string{}, + Title: "FortuneBet API", + Description: "This is server for FortuneBet.", + InfoInstanceName: "swagger", + SwaggerTemplate: docTemplate, + LeftDelim: "{{", + RightDelim: "}}", +} + +func init() { + swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo) +} diff --git a/docs/swagger.json b/docs/swagger.json new file mode 100644 index 0000000..07db1f3 --- /dev/null +++ b/docs/swagger.json @@ -0,0 +1,259 @@ +{ + "swagger": "2.0", + "info": { + "description": "This is server for FortuneBet.", + "title": "FortuneBet API", + "termsOfService": "http://swagger.io/terms/", + "contact": { + "name": "API Support", + "url": "http://www.swagger.io/support", + "email": "support@swagger.io" + }, + "license": { + "name": "Apache 2.0", + "url": "http://www.apache.org/licenses/LICENSE-2.0.html" + }, + "version": "1.0" + }, + "paths": { + "/auth/login": { + "post": { + "description": "Login customer", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Login customer", + "parameters": [ + { + "description": "Login customer", + "name": "login", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/httpserver.loginCustomerReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/httpserver.loginCustomerRes" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, + "/auth/logout": { + "post": { + "description": "Logout customer", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Logout customer", + "parameters": [ + { + "description": "Logout customer", + "name": "logout", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/httpserver.logoutReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, + "/auth/refresh": { + "post": { + "description": "Refresh token", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Refresh token", + "parameters": [ + { + "description": "tokens", + "name": "refresh", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/httpserver.refreshToken" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/httpserver.loginCustomerRes" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + } + }, + "definitions": { + "httpserver.loginCustomerReq": { + "type": "object", + "properties": { + "email": { + "type": "string", + "example": "john.doe@example.com" + }, + "password": { + "type": "string", + "example": "password123" + }, + "phone_number": { + "type": "string", + "example": "1234567890" + } + } + }, + "httpserver.loginCustomerRes": { + "type": "object", + "properties": { + "access_token": { + "type": "string" + }, + "refresh_token": { + "type": "string" + } + } + }, + "httpserver.logoutReq": { + "type": "object", + "properties": { + "refresh_token": { + "type": "string" + } + } + }, + "httpserver.refreshToken": { + "type": "object", + "properties": { + "access_token": { + "type": "string" + }, + "refresh_token": { + "type": "string" + } + } + }, + "response.APIResponse": { + "type": "object", + "properties": { + "data": {}, + "message": { + "type": "string" + }, + "metadata": {}, + "status": { + "$ref": "#/definitions/response.Status" + }, + "timestamp": { + "type": "string" + } + } + }, + "response.Status": { + "type": "string", + "enum": [ + "error", + "success" + ], + "x-enum-varnames": [ + "Error", + "Success" + ] + } + }, + "securityDefinitions": { + "Bearer": { + "type": "apiKey", + "name": "Authorization", + "in": "header" + } + } +} \ No newline at end of file diff --git a/docs/swagger.yaml b/docs/swagger.yaml new file mode 100644 index 0000000..81f777c --- /dev/null +++ b/docs/swagger.yaml @@ -0,0 +1,172 @@ +definitions: + httpserver.loginCustomerReq: + properties: + email: + example: john.doe@example.com + type: string + password: + example: password123 + type: string + phone_number: + example: "1234567890" + type: string + type: object + httpserver.loginCustomerRes: + properties: + access_token: + type: string + refresh_token: + type: string + type: object + httpserver.logoutReq: + properties: + refresh_token: + type: string + type: object + httpserver.refreshToken: + properties: + access_token: + type: string + refresh_token: + type: string + type: object + response.APIResponse: + properties: + data: {} + message: + type: string + metadata: {} + status: + $ref: '#/definitions/response.Status' + timestamp: + type: string + type: object + response.Status: + enum: + - error + - success + type: string + x-enum-varnames: + - Error + - Success +info: + contact: + email: support@swagger.io + name: API Support + url: http://www.swagger.io/support + description: This is server for FortuneBet. + license: + name: Apache 2.0 + url: http://www.apache.org/licenses/LICENSE-2.0.html + termsOfService: http://swagger.io/terms/ + title: FortuneBet API + version: "1.0" +paths: + /auth/login: + post: + consumes: + - application/json + description: Login customer + parameters: + - description: Login customer + in: body + name: login + required: true + schema: + $ref: '#/definitions/httpserver.loginCustomerReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/httpserver.loginCustomerRes' + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.APIResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/response.APIResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/response.APIResponse' + summary: Login customer + tags: + - auth + /auth/logout: + post: + consumes: + - application/json + description: Logout customer + parameters: + - description: Logout customer + in: body + name: logout + required: true + schema: + $ref: '#/definitions/httpserver.logoutReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/response.APIResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.APIResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/response.APIResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/response.APIResponse' + summary: Logout customer + tags: + - auth + /auth/refresh: + post: + consumes: + - application/json + description: Refresh token + parameters: + - description: tokens + in: body + name: refresh + required: true + schema: + $ref: '#/definitions/httpserver.refreshToken' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/httpserver.loginCustomerRes' + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.APIResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/response.APIResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/response.APIResponse' + summary: Refresh token + tags: + - auth +securityDefinitions: + Bearer: + in: header + name: Authorization + type: apiKey +swagger: "2.0" diff --git a/gen/db/auth.sql.go b/gen/db/auth.sql.go new file mode 100644 index 0000000..7ad4b74 --- /dev/null +++ b/gen/db/auth.sql.go @@ -0,0 +1,94 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.26.0 +// source: auth.sql + +package dbgen + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const CreateRefreshToken = `-- name: CreateRefreshToken :exec +INSERT INTO refresh_tokens (user_id, token, expires_at, created_at, revoked) +VALUES ($1, $2, $3, $4, $5) +` + +type CreateRefreshTokenParams struct { + UserID int64 + Token string + ExpiresAt pgtype.Timestamptz + CreatedAt pgtype.Timestamptz + Revoked bool +} + +func (q *Queries) CreateRefreshToken(ctx context.Context, arg CreateRefreshTokenParams) error { + _, err := q.db.Exec(ctx, CreateRefreshToken, + arg.UserID, + arg.Token, + arg.ExpiresAt, + arg.CreatedAt, + arg.Revoked, + ) + return err +} + +const GetRefreshToken = `-- name: GetRefreshToken :one +SELECT id, user_id, token, expires_at, created_at, revoked FROM refresh_tokens +WHERE token = $1 +` + +func (q *Queries) GetRefreshToken(ctx context.Context, token string) (RefreshToken, error) { + row := q.db.QueryRow(ctx, GetRefreshToken, token) + var i RefreshToken + err := row.Scan( + &i.ID, + &i.UserID, + &i.Token, + &i.ExpiresAt, + &i.CreatedAt, + &i.Revoked, + ) + return i, err +} + +const GetUserByEmailPhone = `-- name: GetUserByEmailPhone :one +SELECT id, first_name, last_name, email, phone_number, password, role, verified, created_at, updated_at FROM users +WHERE email = $1 OR phone_number = $2 +` + +type GetUserByEmailPhoneParams struct { + Email string + PhoneNumber string +} + +func (q *Queries) GetUserByEmailPhone(ctx context.Context, arg GetUserByEmailPhoneParams) (User, error) { + row := q.db.QueryRow(ctx, GetUserByEmailPhone, arg.Email, arg.PhoneNumber) + var i User + err := row.Scan( + &i.ID, + &i.FirstName, + &i.LastName, + &i.Email, + &i.PhoneNumber, + &i.Password, + &i.Role, + &i.Verified, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const RevokeRefreshToken = `-- name: RevokeRefreshToken :exec +UPDATE refresh_tokens +SET revoked = TRUE +WHERE token = $1 +` + +func (q *Queries) RevokeRefreshToken(ctx context.Context, token string) error { + _, err := q.db.Exec(ctx, RevokeRefreshToken, token) + return err +} diff --git a/gen/db/models.go b/gen/db/models.go index 82db472..b32f097 100644 --- a/gen/db/models.go +++ b/gen/db/models.go @@ -8,6 +8,15 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +type RefreshToken struct { + ID int64 + UserID int64 + Token string + ExpiresAt pgtype.Timestamptz + CreatedAt pgtype.Timestamptz + Revoked bool +} + type User struct { ID int64 FirstName string @@ -17,6 +26,6 @@ type User struct { Password []byte Role string Verified pgtype.Bool - CreatedAt pgtype.Timestamp - UpdatedAt pgtype.Timestamp + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz } diff --git a/go.mod b/go.mod index 73cdf76..2fb3275 100644 --- a/go.mod +++ b/go.mod @@ -4,39 +4,51 @@ go 1.24.1 require ( github.com/bytedance/sonic v1.13.2 + github.com/go-playground/validator/v10 v10.26.0 github.com/gofiber/fiber/v2 v2.52.6 github.com/golang-jwt/jwt/v5 v5.2.2 github.com/jackc/pgx/v5 v5.7.4 github.com/joho/godotenv v1.5.1 - github.com/stretchr/testify v1.8.4 - golang.org/x/crypto v0.32.0 + github.com/swaggo/fiber-swagger v1.3.0 + github.com/swaggo/swag v1.16.4 + golang.org/x/crypto v0.36.0 ) require ( - github.com/andybalholm/brotli v1.1.0 // indirect + github.com/KyleBanks/depth v1.2.1 // indirect + github.com/andybalholm/brotli v1.1.1 // indirect github.com/bytedance/sonic/loader v0.2.4 // indirect github.com/cloudwego/base64x v0.1.5 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect + github.com/gabriel-vasile/mimetype v1.4.8 // indirect + github.com/go-openapi/jsonpointer v0.21.1 // indirect + github.com/go-openapi/jsonreference v0.21.0 // indirect + github.com/go-openapi/spec v0.21.0 // indirect + github.com/go-openapi/swag v0.23.1 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect github.com/google/uuid v1.6.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect - github.com/klauspost/compress v1.17.9 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/klauspost/compress v1.18.0 // indirect github.com/klauspost/cpuid/v2 v2.0.9 // indirect - github.com/kr/text v0.2.0 // indirect - github.com/mattn/go-colorable v0.1.13 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mailru/easyjson v0.9.0 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/rivo/uniseg v0.2.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/swaggo/files v0.0.0-20220610200504-28940afbdbfe // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect - github.com/valyala/fasthttp v1.51.0 // indirect - github.com/valyala/tcplisten v1.0.0 // indirect + github.com/valyala/fasthttp v1.59.0 // indirect golang.org/x/arch v0.0.0-20210923205945-b76863e36670 // indirect - golang.org/x/sync v0.10.0 // indirect - golang.org/x/sys v0.29.0 // indirect - golang.org/x/text v0.21.0 // indirect + golang.org/x/net v0.38.0 // indirect + golang.org/x/sync v0.12.0 // indirect + golang.org/x/sys v0.31.0 // indirect + golang.org/x/text v0.23.0 // indirect + golang.org/x/tools v0.31.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index d53d292..c86e5af 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,12 @@ -github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= -github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= +github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= +github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= +github.com/agiledragon/gomonkey/v2 v2.3.1/go.mod h1:ap1AmDzcVOAz1YpeJ3TCzIgstoaWLA6jbbgxfB4w2iY= +github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= +github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= +github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ= github.com/bytedance/sonic v1.13.2/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4= github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= @@ -8,16 +15,44 @@ github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFos github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4= github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= +github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonpointer v0.21.1 h1:whnzv/pNXtK2FbX/W9yJfRmE2gsmkfahjMKB0fZvcic= +github.com/go-openapi/jsonpointer v0.21.1/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk= +github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns= +github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= +github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= +github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I= +github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY= +github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk= +github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= +github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= +github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU= +github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k= +github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= +github.com/gofiber/fiber/v2 v2.32.0/go.mod h1:CMy5ZLiXkn6qwthrl03YMyW1NLfj0rhxz2LKl4t7ZTY= github.com/gofiber/fiber/v2 v2.52.6 h1:Rfp+ILPiYSvvVuIPvxrBns+HJp8qGLDnLJawAu27XVI= github.com/gofiber/fiber/v2 v2.52.6/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -28,62 +63,144 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= -github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/klauspost/compress v1.15.0/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= -github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= -github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= -github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= -github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= +github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/otiai10/copy v1.7.0/go.mod h1:rmRl6QPdJj6EiUqXQ/4Nn2lLXoNQjFCQbbNrxgc/t3U= +github.com/otiai10/curr v0.0.0-20150429015615-9b4961190c95/go.mod h1:9qAhocn7zKJG+0mI8eUu6xqkFDYS2kb2saOteoSB3cE= +github.com/otiai10/curr v1.0.0/go.mod h1:LskTG5wDwr8Rs+nNQ+1LlxRjAtTZZjtJW4rMXl6j4vs= +github.com/otiai10/mint v1.3.0/go.mod h1:F5AjcsTsWUqX+Na9fpHb52P8pcRX2CI6A3ctIT91xUo= +github.com/otiai10/mint v1.3.3/go.mod h1:/yxELlJQ0ufhjUwhshSj+wFjZ78CnZ48/1wtmBH1OTc= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/swaggo/fiber-swagger v1.3.0 h1:RMjIVDleQodNVdKuu7GRs25Eq8RVXK7MwY9f5jbobNg= +github.com/swaggo/fiber-swagger v1.3.0/go.mod h1:18MuDqBkYEiUmeM/cAAB8CI28Bi62d/mys39j1QqF9w= +github.com/swaggo/files v0.0.0-20220610200504-28940afbdbfe h1:K8pHPVoTgxFJt1lXuIzzOX7zZhZFldJQK/CgKx9BFIc= +github.com/swaggo/files v0.0.0-20220610200504-28940afbdbfe/go.mod h1:lKJPbtWzJ9JhsTN1k1gZgleJWY/cqq0psdoMmaThG3w= +github.com/swaggo/swag v1.8.1/go.mod h1:ugemnJsPZm/kRwFUnzBlbHRd0JY9zE1M4F+uy2pAaPQ= +github.com/swaggo/swag v1.16.4 h1:clWJtd9LStiG3VeijiCfOVODP6VpHtKdQy9ELFG3s1A= +github.com/swaggo/swag v1.16.4/go.mod h1:VBsHJRsDvfYvqoiMKnsdwhNV9LEMHgEDZcyVYX0sxPg= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA= -github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g= -github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= +github.com/valyala/fasthttp v1.35.0/go.mod h1:t/G+3rLek+CyY9bnIE+YlMRddxVAAGjhxndDB4i4C0I= +github.com/valyala/fasthttp v1.36.0/go.mod h1:t/G+3rLek+CyY9bnIE+YlMRddxVAAGjhxndDB4i4C0I= +github.com/valyala/fasthttp v1.59.0 h1:Qu0qYHfXvPk1mSLNqcFtEk6DpxgA26hy6bmydotDpRI= +github.com/valyala/fasthttp v1.59.0/go.mod h1:GTxNb9Bc6r2a9D0TWNSPwDz78UxnTGBViY3xZNEqyYU= github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= golang.org/x/arch v0.0.0-20210923205945-b76863e36670 h1:18EFjUmQOcUvxNYSkA6jO9VAiXCnxFY6NyDX0bHDmkU= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= -golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= -golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= -golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= -golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= +golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= +golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM= +golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= +golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= +golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= -golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= -golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.7/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo= +golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU= +golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= diff --git a/internal/config/config.go b/internal/config/config.go index 0d53448..229bd47 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -2,18 +2,33 @@ package config import ( "errors" + "log/slog" "os" "strconv" + + customlogger "github.com/SamuelTariku/FortuneBet-Backend/internal/logger" + "github.com/joho/godotenv" ) var ( - ErrInvalidDbUrl = errors.New("db url is invalid") - ErrInvalidPort = errors.New("port number is invalid") + ErrInvalidDbUrl = errors.New("db url is invalid") + ErrInvalidPort = errors.New("port number is invalid") + ErrRefreshExpiry = errors.New("refresh token expiry is invalid") + ErrAccessExpiry = errors.New("access token expiry is invalid") + ErrInvalidJwtKey = errors.New("jwt key is invalid") + ErrLogLevel = errors.New("log level not set") + ErrInvalidLevel = errors.New("invalid log level") + ErrInvalidEnv = errors.New("env not set or invalid") ) type Config struct { - Port int - DbUrl string + Port int + DbUrl string + RefreshExpiry int + AccessExpiry int + JwtKey string + LogLevel slog.Level + Env string } func NewConfig() (*Config, error) { @@ -24,7 +39,16 @@ func NewConfig() (*Config, error) { return config, nil } func (c *Config) loadEnv() error { - + err := godotenv.Load() + if err != nil { + return errors.New("failed to load env file") + } + // env + env := os.Getenv("ENV") + if env == "" { + return ErrInvalidEnv + } + c.Env = env portStr := os.Getenv("PORT") port, err := strconv.Atoi(portStr) if err != nil { @@ -37,6 +61,33 @@ func (c *Config) loadEnv() error { return ErrInvalidDbUrl } c.DbUrl = dbUrl + refreshExpiryStr := os.Getenv("REFRESH_EXPIRY") + refreshExpiry, err := strconv.Atoi(refreshExpiryStr) + if err != nil { + return ErrRefreshExpiry + } + c.RefreshExpiry = refreshExpiry + jwtKey := os.Getenv("JWT_KEY") + if jwtKey == "" { + return ErrInvalidJwtKey + } + c.JwtKey = jwtKey + accessExpiryStr := os.Getenv("ACCESS_EXPIRY") + accessExpiry, err := strconv.Atoi(accessExpiryStr) + if err != nil { + return ErrAccessExpiry + } + c.AccessExpiry = accessExpiry + // log level + logLevel := os.Getenv("LOG_LEVEL") + if logLevel == "" { + return ErrLogLevel + } + lvl, ok := customlogger.LogLevels[logLevel] + if !ok { + return ErrInvalidLevel + } + c.LogLevel = lvl return nil } diff --git a/internal/logger/logger.go b/internal/logger/logger.go index 2c7035f..043836c 100644 --- a/internal/logger/logger.go +++ b/internal/logger/logger.go @@ -11,8 +11,12 @@ var LogLevels = map[string]slog.Level{ "warn": slog.LevelWarn, "error": slog.LevelError, } +var Environment = map[string]string{ + "dev": "development", + "prod": "production", +} -func NewLogger(env string, lvl slog.Level, version string) *slog.Logger { +func NewLogger(env string, lvl slog.Level) *slog.Logger { var logHandler slog.Handler switch env { case "development": @@ -28,7 +32,6 @@ func NewLogger(env string, lvl slog.Level, version string) *slog.Logger { logger := slog.New(logHandler).With(slog.Group( "service_info", slog.String("env", env), - slog.String("version", version), ), ) diff --git a/internal/repository/auth.go b/internal/repository/auth.go new file mode 100644 index 0000000..663e5b3 --- /dev/null +++ b/internal/repository/auth.go @@ -0,0 +1,71 @@ +package repository + +import ( + "context" + "database/sql" + "errors" + + dbgen "github.com/SamuelTariku/FortuneBet-Backend/gen/db" + "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/authentication" + "github.com/jackc/pgx/v5/pgtype" +) + +func (s *Store) GetUserByEmailPhone(ctx context.Context, email, phone string) (domain.User, error) { + user, err := s.queries.GetUserByEmailPhone(ctx, dbgen.GetUserByEmailPhoneParams{ + Email: email, + PhoneNumber: phone, + }) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return domain.User{}, authentication.ErrUserNotFound + } + return domain.User{}, err + } + return domain.User{ + ID: user.ID, + FirstName: user.FirstName, + LastName: user.LastName, + Email: user.Email, + PhoneNumber: user.PhoneNumber, + Password: user.Password, + Role: user.Role, + }, nil + +} + +func (s *Store) CreateRefreshToken(ctx context.Context, rt domain.RefreshToken) error { + return s.queries.CreateRefreshToken(ctx, dbgen.CreateRefreshTokenParams{ + UserID: rt.UserID, + Token: rt.Token, + CreatedAt: pgtype.Timestamptz{ + Time: rt.CreatedAt, + Valid: true, + }, + ExpiresAt: pgtype.Timestamptz{ + Time: rt.ExpiresAt, + Valid: true, + }, + Revoked: rt.Revoked, + }) + +} +func (s *Store) GetRefreshToken(ctx context.Context, token string) (domain.RefreshToken, error) { + rf, err := s.queries.GetRefreshToken(ctx, token) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return domain.RefreshToken{}, authentication.ErrRefreshTokenNotFound + } + return domain.RefreshToken{}, err + } + return domain.RefreshToken{ + Token: rf.Token, + UserID: rf.UserID, + CreatedAt: rf.CreatedAt.Time, + ExpiresAt: rf.ExpiresAt.Time, + Revoked: rf.Revoked, + }, nil +} +func (s *Store) RevokeRefreshToken(ctx context.Context, token string) error { + return s.queries.RevokeRefreshToken(ctx, token) +} diff --git a/internal/services/authentication/impl.go b/internal/services/authentication/impl.go index 1bac295..7447312 100644 --- a/internal/services/authentication/impl.go +++ b/internal/services/authentication/impl.go @@ -18,23 +18,25 @@ var ( ErrRefreshTokenNotFound = errors.New("refresh token not found") // i.e login again ) -func (s *Service) Login(ctx context.Context, emailPhone EmailPhone, password string) (string, error) { - user, err := s.userStore.GetUserByEmailPhone(ctx, emailPhone) +type LoginSuccess struct { + UserId int64 + Role string + RfToken string +} + +func (s *Service) Login(ctx context.Context, email, phone string, password string) (LoginSuccess, error) { + user, err := s.userStore.GetUserByEmailPhone(ctx, email, phone) if err != nil { - return "", err + return LoginSuccess{}, err } err = matchPassword(password, user.Password) if err != nil { - return "", err + return LoginSuccess{}, err } - // //create session - // accessToken, err := CreateJwt(strconv.Itoa(int(user.ID)), s.jwtConfig.JwtAccessKey, s.jwtConfig.JwtAccessExpiry) - // if err != nil { - // return Tokens{}, err - // } + refreshToken, err := generateRefreshToken() if err != nil { - return "", err + return LoginSuccess{}, err } err = s.tokenStore.CreateRefreshToken(ctx, domain.RefreshToken{ Token: refreshToken, @@ -43,19 +45,17 @@ func (s *Service) Login(ctx context.Context, emailPhone EmailPhone, password str ExpiresAt: time.Now().Add(time.Duration(s.RefreshExpiry) * time.Second), }) if err != nil { - return "", err + return LoginSuccess{}, err } - return refreshToken, nil + return LoginSuccess{ + UserId: user.ID, + Role: user.Role, + RfToken: refreshToken, + }, nil } func (s *Service) RefreshToken(ctx context.Context, refToken string) (string, error) { - // us, err := ParseJwt(tokens.RefreshToken, s.jwtConfig.JwtAccessKey) - // if err == nil { - // return Tokens{}, err - // } - // if !errors.Is(err, ErrExpiredToken) { - // return Tokens{}, err - // } + token, err := s.tokenStore.GetRefreshToken(ctx, refToken) if err != nil { return "", err @@ -66,21 +66,12 @@ func (s *Service) RefreshToken(ctx context.Context, refToken string) (string, er if token.ExpiresAt.Before(time.Now()) { return "", ErrExpiredToken } - // - // naccessToken, err := CreateJwt(token., s.jwtConfig.JwtAccessKey, s.jwtConfig.JwtAccessExpiry) - // if err != nil { - // return Tokens{}, err - // } + newRefToken, err := generateRefreshToken() if err != nil { return "", err } - // ntokens := Tokens{ - // AccessToken: naccessToken, - // RefreshToken: nrefreshToken, - // } - err = s.tokenStore.CreateRefreshToken(ctx, domain.RefreshToken{ Token: newRefToken, UserID: token.UserID, diff --git a/internal/services/authentication/port.go b/internal/services/authentication/port.go index a9f3136..d177dbe 100644 --- a/internal/services/authentication/port.go +++ b/internal/services/authentication/port.go @@ -7,7 +7,7 @@ import ( ) type UserStore interface { - GetUserByEmailPhone(ctx context.Context, emailPhone EmailPhone) (domain.User, error) + GetUserByEmailPhone(ctx context.Context, email, phone string) (domain.User, error) } type TokenStore interface { CreateRefreshToken(ctx context.Context, rt domain.RefreshToken) error diff --git a/internal/services/authentication/service.go b/internal/services/authentication/service.go index e40b75b..577e9da 100644 --- a/internal/services/authentication/service.go +++ b/internal/services/authentication/service.go @@ -1,10 +1,10 @@ package authentication -type EmailPhone struct { - Email ValidString - PhoneNumber ValidString - Password ValidString -} +// type EmailPhone struct { +// Email ValidString +// PhoneNumber ValidString +// Password ValidString +// } type ValidString struct { Value string Valid bool diff --git a/internal/web_server/app.go b/internal/web_server/app.go index b7da149..3672a2c 100644 --- a/internal/web_server/app.go +++ b/internal/web_server/app.go @@ -2,17 +2,33 @@ package httpserver import ( "fmt" + "log/slog" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/authentication" + customvalidator "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/validator" "github.com/bytedance/sonic" "github.com/gofiber/fiber/v2" ) +type JwtConfig struct { + JwtAccessKey string + JwtAccessExpiry int +} type App struct { - fiber *fiber.App - port int + fiber *fiber.App + logger *slog.Logger + port int + authSvc *authentication.Service + validator *customvalidator.CustomValidator + JwtConfig JwtConfig } -func NewApp(port int) *App { +func NewApp( + port int, validator *customvalidator.CustomValidator, + authSvc *authentication.Service, + logger *slog.Logger, + JwtConfig JwtConfig, +) *App { app := fiber.New(fiber.Config{ CaseSensitive: true, DisableHeaderNormalizing: true, @@ -20,8 +36,12 @@ func NewApp(port int) *App { JSONDecoder: sonic.Unmarshal, }) s := &App{ - fiber: app, - port: port, + fiber: app, + port: port, + authSvc: authSvc, + validator: validator, + logger: logger, + JwtConfig: JwtConfig, } s.initAppRoutes() diff --git a/internal/web_server/dto/user.go b/internal/web_server/dto/user.go new file mode 100644 index 0000000..7708f59 --- /dev/null +++ b/internal/web_server/dto/user.go @@ -0,0 +1,6 @@ +package dto + +var loginCustomerRes struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` +} diff --git a/internal/web_server/jwt/jwt.go b/internal/web_server/jwt/jwt.go index 4dd89e1..7dc3659 100644 --- a/internal/web_server/jwt/jwt.go +++ b/internal/web_server/jwt/jwt.go @@ -1,4 +1,4 @@ -package user +package jwtutil import ( "errors" @@ -20,15 +20,17 @@ var ( type UserClaim struct { jwt.RegisteredClaims UserId string + Role string } -func CreateJwt(userId string, key string, expiry int) (string, error) { +func CreateJwt(userId string, Role string, key string, expiry int) (string, error) { token := jwt.NewWithClaims(jwt.SigningMethodHS256, UserClaim{RegisteredClaims: jwt.RegisteredClaims{Issuer: "github.com/lafetz/snippitstash", IssuedAt: jwt.NewNumericDate(time.Now()), Audience: jwt.ClaimStrings{"fortune.com"}, NotBefore: jwt.NewNumericDate(time.Now()), ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(expiry) * time.Second))}, UserId: userId, + Role: Role, }) jwtToken, err := token.SignedString([]byte(key)) // return jwtToken, err diff --git a/internal/web_server/jwt/jwt_test.go b/internal/web_server/jwt/jwt_test.go index 45d1dcc..bffe0c5 100644 --- a/internal/web_server/jwt/jwt_test.go +++ b/internal/web_server/jwt/jwt_test.go @@ -1,4 +1,4 @@ -package user +package jwtutil // func TestCreateJwt(t *testing.T) { // // Define a user to test diff --git a/internal/web_server/middleware.go b/internal/web_server/middleware.go new file mode 100644 index 0000000..e80689f --- /dev/null +++ b/internal/web_server/middleware.go @@ -0,0 +1,43 @@ +package httpserver + +import ( + "errors" + "strings" + + jwtutil "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/jwt" + "github.com/gofiber/fiber/v2" +) + +func (a *App) authMiddleware(c *fiber.Ctx) error { + + authHeader := c.Get("Authorization") + if authHeader == "" { + return fiber.NewError(fiber.StatusUnauthorized, "Authorization header missing") + } + + if !strings.HasPrefix(authHeader, "Bearer ") { + return fiber.NewError(fiber.StatusUnauthorized, "Invalid authorization header format") + } + + accessToken := strings.TrimPrefix(authHeader, "Bearer ") + c.Locals("access_token", accessToken) + claim, err := jwtutil.ParseJwt(accessToken, a.JwtConfig.JwtAccessKey) + if err != nil { + if errors.Is(err, jwtutil.ErrExpiredToken) { + return fiber.NewError(fiber.StatusUnauthorized, "Access token expired") + } + return fiber.NewError(fiber.StatusUnauthorized, "Invalid access token") + } + + refreshToken := c.Get("Refresh-Token") + if refreshToken == "" { + + // refreshToken = c.Cookies("refresh_token", "") + + return fiber.NewError(fiber.StatusUnauthorized, "Refresh token missing") + } + c.Locals("user_id", claim.UserId) + c.Locals("role", claim.Role) + c.Locals("refresh_token", refreshToken) + return c.Next() +} diff --git a/internal/web_server/response/res.go b/internal/web_server/response/res.go new file mode 100644 index 0000000..593758d --- /dev/null +++ b/internal/web_server/response/res.go @@ -0,0 +1,47 @@ +package response + +import ( + "time" + + "github.com/gofiber/fiber/v2" +) + +type Status string + +const ( + Error Status = "error" + Success Status = "success" +) + +type APIResponse struct { + Status Status `json:"status"` + Message string `json:"message"` + Data interface{} `json:"data,omitempty"` + Metadata interface{} `json:"metadata,omitempty"` + Timestamp time.Time `json:"timestamp"` +} + +func NewAPIResponse( + status Status, message string, + data interface{}, metadata interface{}, +) APIResponse { + + return APIResponse{ + Status: status, + Message: message, + Data: data, + Metadata: metadata, + Timestamp: time.Now(), + } +} +func WriteJSON(c *fiber.Ctx, status int, message string, data, metadata interface{}) error { + var apiStatus Status + if status >= 200 && status <= 299 { + apiStatus = Success + } else { + apiStatus = Error + } + apiRes := NewAPIResponse(apiStatus, message, data, metadata) + + return c.Status(status).JSON(apiRes) +} diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index 8388c84..e54fc9c 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -1,5 +1,23 @@ package httpserver +import ( + _ "github.com/SamuelTariku/FortuneBet-Backend/docs" + "github.com/gofiber/fiber/v2" + fiberSwagger "github.com/swaggo/fiber-swagger" +) + func (a *App) initAppRoutes() { - // a.fiber.Group("/users", users.CreateAccount(a.userAPI)) + a.fiber.Post("/auth/login", a.LoginCustomer) + a.fiber.Post("/auth/refresh", a.authMiddleware, a.RefreshToken) + a.fiber.Post("/auth/logout", a.authMiddleware, a.LogOutCustomer) + a.fiber.Get("/auth/test", a.authMiddleware, func(c *fiber.Ctx) error { + userId := c.Locals("user_id") + role := c.Locals("role") + refreshToken := c.Locals("refresh_token") + a.logger.Info("User ID: " + userId.(string)) + a.logger.Info("Role: " + role.(string)) + a.logger.Info("Refresh Token: " + refreshToken.(string)) + return c.SendString("Test endpoint") + }) + a.fiber.Get("/swagger/*", fiberSwagger.WrapHandler) } diff --git a/internal/web_server/user_handler.go b/internal/web_server/user_handler.go new file mode 100644 index 0000000..199eadb --- /dev/null +++ b/internal/web_server/user_handler.go @@ -0,0 +1,170 @@ +package httpserver + +import ( + "errors" + "strconv" + + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/authentication" + jwtutil "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/jwt" + "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/response" + "github.com/gofiber/fiber/v2" +) + +type loginCustomerReq struct { + Email string `json:"email" example:"john.doe@example.com"` + PhoneNumber string `json:"phone_number" example:"1234567890"` + Password string `json:"password" example:"password123"` +} + +type loginCustomerRes struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` +} + +// LoginCustomer godoc +// @Summary Login customer +// @Description Login customer +// @Tags auth +// @Accept json +// @Produce json +// @Param login body loginCustomerReq true "Login customer" +// @Success 200 {object} loginCustomerRes +// @Failure 400 {object} response.APIResponse +// @Failure 401 {object} response.APIResponse +// @Failure 500 {object} response.APIResponse +// @Router /auth/login [post] +func (a *App) LoginCustomer(c *fiber.Ctx) error { + var req loginCustomerReq + if err := c.BodyParser(&req); err != nil { + a.logger.Error("Login failed", "error", err) + response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", nil, nil) + } + valErrs, ok := a.validator.Validate(c, req) + if !ok { + + response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", valErrs, nil) + return nil + } + successRes, err := a.authSvc.Login(c.Context(), req.Email, req.PhoneNumber, req.Password) + if err != nil { + a.logger.Info("Login failed", "error", err) + if errors.Is(err, authentication.ErrInvalidPassword) { + response.WriteJSON(c, fiber.StatusUnauthorized, "Invalid password or not registered", nil, nil) + return nil + } + if errors.Is(err, authentication.ErrUserNotFound) { + response.WriteJSON(c, fiber.StatusUnauthorized, "Invalid password or not registered", nil, nil) + return nil + } + a.logger.Error("Login failed", "error", err) + response.WriteJSON(c, fiber.StatusInternalServerError, "Internal server error", nil, nil) + return nil + + } + accessToken, err := jwtutil.CreateJwt(strconv.Itoa(int(successRes.UserId)), successRes.Role, a.JwtConfig.JwtAccessKey, a.JwtConfig.JwtAccessExpiry) + res := loginCustomerRes{ + AccessToken: accessToken, + RefreshToken: successRes.RfToken, + } + return response.WriteJSON(c, fiber.StatusOK, "Login successful", res, nil) +} + +type refreshToken struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` +} + +// RefreshToken godoc +// @Summary Refresh token +// @Description Refresh token +// @Tags auth +// @Accept json +// @Produce json +// @Param refresh body refreshToken true "tokens" +// @Success 200 {object} loginCustomerRes +// @Failure 400 {object} response.APIResponse +// @Failure 401 {object} response.APIResponse +// @Failure 500 {object} response.APIResponse +// @Router /auth/refresh [post] +func (a *App) RefreshToken(c *fiber.Ctx) error { + var req refreshToken + if err := c.BodyParser(&req); err != nil { + response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", nil, nil) + } + valErrs, ok := a.validator.Validate(c, req) + if !ok { + response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", valErrs, nil) + return nil + } + rf, err := a.authSvc.RefreshToken(c.Context(), req.RefreshToken) + if err != nil { + a.logger.Info("Refresh token failed", "error", err) + if errors.Is(err, authentication.ErrExpiredToken) { + response.WriteJSON(c, fiber.StatusUnauthorized, "The refresh token has expired", nil, nil) + return nil + } + if errors.Is(err, authentication.ErrRefreshTokenNotFound) { + response.WriteJSON(c, fiber.StatusUnauthorized, "Refresh token not found", nil, nil) + return nil + } + a.logger.Error("Refresh token failed", "error", err) + response.WriteJSON(c, fiber.StatusInternalServerError, "Internal server error", nil, nil) + return nil + } + accessToken, err := jwtutil.CreateJwt("", "", a.JwtConfig.JwtAccessKey, a.JwtConfig.JwtAccessExpiry) + if err != nil { + a.logger.Error("Create jwt failed", "error", err) + response.WriteJSON(c, fiber.StatusInternalServerError, "Internal server error", nil, nil) + return nil + } + + res := loginCustomerRes{ + AccessToken: accessToken, + RefreshToken: rf, + } + return response.WriteJSON(c, fiber.StatusOK, "refresh successful", res, nil) +} + +type logoutReq struct { + RefreshToken string `json:"refresh_token"` +} + +// LogOutCustomer godoc +// @Summary Logout customer +// @Description Logout customer +// @Tags auth +// @Accept json +// @Produce json +// @Param logout body logoutReq true "Logout customer" +// @Success 200 {object} response.APIResponse +// @Failure 400 {object} response.APIResponse +// @Failure 401 {object} response.APIResponse +// @Failure 500 {object} response.APIResponse +// @Router /auth/logout [post] +func (a *App) LogOutCustomer(c *fiber.Ctx) error { + var req logoutReq + if err := c.BodyParser(&req); err != nil { + response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", nil, nil) + } + valErrs, ok := a.validator.Validate(c, req) + if !ok { + response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", valErrs, nil) + return nil + } + err := a.authSvc.Logout(c.Context(), req.RefreshToken) + if err != nil { + a.logger.Info("Logout failed", "error", err) + if errors.Is(err, authentication.ErrExpiredToken) { + response.WriteJSON(c, fiber.StatusUnauthorized, "The refresh token has expired", nil, nil) + return nil + } + if errors.Is(err, authentication.ErrRefreshTokenNotFound) { + response.WriteJSON(c, fiber.StatusUnauthorized, "Refresh token not found", nil, nil) + return nil + } + a.logger.Error("Logout failed", "error", err) + response.WriteJSON(c, fiber.StatusInternalServerError, "Internal server error", nil, nil) + return nil + } + return response.WriteJSON(c, fiber.StatusOK, "Logout successful", nil, nil) +} diff --git a/internal/web_server/validator/validatord.go b/internal/web_server/validator/validatord.go index 47f2da9..16e13e9 100644 --- a/internal/web_server/validator/validatord.go +++ b/internal/web_server/validator/validatord.go @@ -1 +1,65 @@ -package validator +package customvalidator + +import ( + "strings" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" +) + +type CustomValidator struct { + validate *validator.Validate +} + +func NewCustomValidator(validate *validator.Validate) *CustomValidator { + + return &CustomValidator{ + validate: validate, + } +} + +func (v *CustomValidator) Validate(c *fiber.Ctx, input interface{}) (map[string]string, bool) { + err := v.validate.Struct(input) + if err != nil { + if validationErrors, ok := err.(validator.ValidationErrors); ok { + errors := ValidateModel(validationErrors) + return errors, false + } + } + return nil, true +} + +type ValidationErrorResponse struct { + StatusCode int `json:"statusCode"` + Errors interface{} `json:"errors"` +} + +func ValidateModel(err validator.ValidationErrors) map[string]string { + errors := make(map[string]string) + + for _, err := range err { + + errors[strings.ToLower(err.Field())] = errorMsgs(err.Tag(), err.Param()) + + } + return errors + +} + +func errorMsgs(tag string, value string) string { + switch tag { + case "required": + return "This field is required" + case "numeric": + return "must be numeric " + value + case "lte": + return "can not be greater than " + value + case "gte": + return "can not be less than " + value + case "len": + return "length should be equal to " + value + case "email": + return "must be a valid email address" + } + return "" +} diff --git a/load_env.sh b/load_env.sh new file mode 100644 index 0000000..761d37d --- /dev/null +++ b/load_env.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +dotenv() { + if [ -f .env ]; then + while IFS='=' read -r key value; do + if [[ ! -z "$key" && "$key" != \#* ]]; then + export "$key=$value" + fi + done < .env + else + echo ".env file not found." + fi +} + + +dotenv \ No newline at end of file diff --git a/makefile b/makefile index 845b5e7..6fad4c7 100644 --- a/makefile +++ b/makefile @@ -12,11 +12,11 @@ build: go build -ldflags="-s" -o ./bin/web ./ .PHONY: run run: - echo "Running Go application"; \ + @echo "Running Go application"; \ go run ./cmd/main.go .PHONY: air air: - echo "Running air"; \ + @echo "Running air"; \ air -c .air.toml .PHONY: migrations/up migrations/new: From ef006abd104e28f83be8599a6fa8c09623e0db80 Mon Sep 17 00:00:00 2001 From: lafetz Date: Sat, 29 Mar 2025 06:34:35 +0300 Subject: [PATCH 3/5] rm dto --- internal/web_server/dto/user.go | 6 ------ load_env.sh | 16 ---------------- 2 files changed, 22 deletions(-) delete mode 100644 internal/web_server/dto/user.go delete mode 100644 load_env.sh diff --git a/internal/web_server/dto/user.go b/internal/web_server/dto/user.go deleted file mode 100644 index 7708f59..0000000 --- a/internal/web_server/dto/user.go +++ /dev/null @@ -1,6 +0,0 @@ -package dto - -var loginCustomerRes struct { - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` -} diff --git a/load_env.sh b/load_env.sh deleted file mode 100644 index 761d37d..0000000 --- a/load_env.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/bin/bash - -dotenv() { - if [ -f .env ]; then - while IFS='=' read -r key value; do - if [[ ! -z "$key" && "$key" != \#* ]]; then - export "$key=$value" - fi - done < .env - else - echo ".env file not found." - fi -} - - -dotenv \ No newline at end of file From d1a33b18dcb7b06af943b51ee1deaebc4601865e Mon Sep 17 00:00:00 2001 From: lafetz Date: Sun, 30 Mar 2025 22:18:20 +0300 Subject: [PATCH 4/5] move handlers to separate package --- cmd/main.go | 3 +- db/migrations/000001_fortune.up.sql | 18 +- internal/domain/common.go | 10 + internal/domain/otp.go | 29 +++ internal/domain/role.go | 11 ++ internal/domain/user.go | 38 +++- internal/repository/auth.go | 2 +- internal/repository/user.go | 6 +- internal/services/authentication/impl.go | 2 +- internal/services/user/port.go | 20 +- internal/services/user/service.go | 189 +++++++++++++++++-- internal/web_server/app.go | 9 +- internal/web_server/handlers/auth_handler.go | 183 ++++++++++++++++++ internal/web_server/jwt/jwt.go | 9 +- internal/web_server/routes.go | 7 +- internal/web_server/user_handler.go | 170 ----------------- makefile | 6 + 17 files changed, 499 insertions(+), 213 deletions(-) create mode 100644 internal/domain/common.go create mode 100644 internal/domain/otp.go create mode 100644 internal/domain/role.go create mode 100644 internal/web_server/handlers/auth_handler.go delete mode 100644 internal/web_server/user_handler.go diff --git a/cmd/main.go b/cmd/main.go index 451f9d4..5bc6f41 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -10,6 +10,7 @@ import ( "github.com/SamuelTariku/FortuneBet-Backend/internal/repository" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/authentication" httpserver "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server" + jwtutil "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/jwt" customvalidator "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/validator" "github.com/go-playground/validator/v10" ) @@ -43,7 +44,7 @@ func main() { store := repository.NewStore(db) v := customvalidator.NewCustomValidator(validator.New()) authSvc := authentication.NewService(store, store, cfg.RefreshExpiry) - app := httpserver.NewApp(cfg.Port, v, authSvc, logger, httpserver.JwtConfig{ + app := httpserver.NewApp(cfg.Port, v, authSvc, logger, jwtutil.JwtConfig{ JwtAccessKey: cfg.JwtKey, JwtAccessExpiry: cfg.AccessExpiry, }) diff --git a/db/migrations/000001_fortune.up.sql b/db/migrations/000001_fortune.up.sql index 69b5959..7647214 100644 --- a/db/migrations/000001_fortune.up.sql +++ b/db/migrations/000001_fortune.up.sql @@ -2,14 +2,20 @@ CREATE TABLE users ( id BIGSERIAL PRIMARY KEY, first_name VARCHAR(255) NOT NULL, last_name VARCHAR(255) NOT NULL, - email VARCHAR(255) UNIQUE NOT NULL, - phone_number VARCHAR(20) UNIQUE NOT NULL, + email VARCHAR(255) UNIQUE , + phone_number VARCHAR(20) UNIQUE, password BYTEA NOT NULL, role VARCHAR(50) NOT NULL, - verified BOOLEAN DEFAULT FALSE, - created_at TIMESTAMPTZ , - updated_at TIMESTAMPTZ , - CONSTRAINT unique_email_phone UNIQUE (email, phone_number) + email_verified BOOLEAN NOT NULL DEFAULT FALSE, + phone_verified BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + suspended_at TIMESTAMPTZ NULL, -- this can be NULL if the user is not suspended + suspended BOOLEAN NOT NULL DEFAULT FALSE, + CHECK ( + (email IS NOT NULL AND phone_number IS NULL) OR + (email IS NULL AND phone_number IS NOT NULL) + ) ); CREATE TABLE refresh_tokens ( id BIGSERIAL PRIMARY KEY, diff --git a/internal/domain/common.go b/internal/domain/common.go new file mode 100644 index 0000000..94666fd --- /dev/null +++ b/internal/domain/common.go @@ -0,0 +1,10 @@ +package domain + +type ValidString struct { + Value string + Valid bool +} +type ValidBool struct { + Value bool + Valid bool +} diff --git a/internal/domain/otp.go b/internal/domain/otp.go new file mode 100644 index 0000000..cc3630f --- /dev/null +++ b/internal/domain/otp.go @@ -0,0 +1,29 @@ +package domain + +import "time" + +type OtpFor string + +const ( + OtpReset OtpFor = "reset" + OtpRegister OtpFor = "register" +) + +type OtpMedium string + +const ( + OtpMediumEmail OtpMedium = "email" + OtpMediumSms OtpMedium = "sms" +) + +type Otp struct { + ID int64 + SentTo string + Medium OtpMedium + For OtpFor + Otp string + Used bool + UsedAt time.Time + CreatedAt time.Time + ExpiresAt time.Time +} diff --git a/internal/domain/role.go b/internal/domain/role.go new file mode 100644 index 0000000..59a17a5 --- /dev/null +++ b/internal/domain/role.go @@ -0,0 +1,11 @@ +package domain + +type Role string + +const ( + RoleAdmin Role = "admin" + RoleCustomer Role = "customer" + RoleSuperAdmin Role = "super_admin" + RoleBranchManager Role = "branch_manager" + RoleCashier Role = "cashier" +) diff --git a/internal/domain/user.go b/internal/domain/user.go index 21d1a77..ed38ae8 100644 --- a/internal/domain/user.go +++ b/internal/domain/user.go @@ -9,8 +9,38 @@ type User struct { Email string PhoneNumber string Password []byte - Role string - Verified bool - CreatedAt time.Time - UpdatedAt time.Time + Role Role + // + EmailVerified bool + PhoneVerified bool + // + CreatedAt time.Time + UpdatedAt time.Time + // + SuspendedAt time.Time + Suspended bool +} +type RegisterUserReq struct { + FirstName string + LastName string + Email string + PhoneNumber string + Password string + //Role string + Otp string + ReferalCode string + // + OtpMedium OtpMedium +} +type ResetPasswordReq struct { + Email string + PhoneNumber string + Password string + Otp string + OtpMedium OtpMedium +} +type UpdateUserReq struct { + FirstName ValidString + LastName ValidString + Suspended ValidBool } diff --git a/internal/repository/auth.go b/internal/repository/auth.go index 663e5b3..9695fc9 100644 --- a/internal/repository/auth.go +++ b/internal/repository/auth.go @@ -29,7 +29,7 @@ func (s *Store) GetUserByEmailPhone(ctx context.Context, email, phone string) (d Email: user.Email, PhoneNumber: user.PhoneNumber, Password: user.Password, - Role: user.Role, + Role: domain.Role(user.Role), }, nil } diff --git a/internal/repository/user.go b/internal/repository/user.go index d6f7a36..dbbc6ca 100644 --- a/internal/repository/user.go +++ b/internal/repository/user.go @@ -26,7 +26,7 @@ func (s *Store) CreateUser(ctx context.Context, firstName, lastName, email, phon Email: user.Email, PhoneNumber: user.PhoneNumber, Password: user.Password, - Role: user.Role, + // Role: user.Role, }, nil } @@ -42,7 +42,7 @@ func (s *Store) GetUserByID(ctx context.Context, id int64) (domain.User, error) Email: user.Email, PhoneNumber: user.PhoneNumber, Password: user.Password, - Role: user.Role, + // Role: user.Role, }, nil } func (s *Store) GetAllUsers(ctx context.Context) ([]domain.User, error) { @@ -59,7 +59,7 @@ func (s *Store) GetAllUsers(ctx context.Context) ([]domain.User, error) { Email: user.Email, PhoneNumber: user.PhoneNumber, Password: user.Password, - Role: user.Role, + // Role: user.Role, }) } return result, nil diff --git a/internal/services/authentication/impl.go b/internal/services/authentication/impl.go index 7447312..ea8de4d 100644 --- a/internal/services/authentication/impl.go +++ b/internal/services/authentication/impl.go @@ -20,7 +20,7 @@ var ( type LoginSuccess struct { UserId int64 - Role string + Role domain.Role RfToken string } diff --git a/internal/services/user/port.go b/internal/services/user/port.go index b6f20cf..8773525 100644 --- a/internal/services/user/port.go +++ b/internal/services/user/port.go @@ -7,11 +7,25 @@ import ( ) type UserStore interface { - CreateUser(ctx context.Context, CfirstName, lastName, email, phoneNumber, password, role string, verified bool) (domain.User, error) + CreateUser(ctx context.Context, user domain.User, usedOtpId int64) (domain.User, error) GetUserByID(ctx context.Context, id int64) (domain.User, error) GetAllUsers(ctx context.Context) ([]domain.User, error) - UpdateUser(ctx context.Context, id int64, firstName, lastName, email, phoneNumber, password, role string, verified bool) error + UpdateUser(ctx context.Context, user domain.UpdateUserReq) error DeleteUser(ctx context.Context, id int64) error + CheckPhoneEmailExist(ctx context.Context, phoneNum, email string) (bool, bool, error) + GetUserByEmail(ctx context.Context, email string) (domain.User, error) + GetUserByPhone(ctx context.Context, phoneNum string) (domain.User, error) // - //GetUserByEmailPhone(ctx context.Context, emailPhone EmailPhone) (domain.User, error) + UpdatePassword(ctx context.Context, identifier string, password []byte, usedOtpId int64) error // identifier verified email or phone +} +type SmsGateway interface { + SendSMSOTP(ctx context.Context, phoneNumber, otp string) error +} +type EmailGateway interface { + SendEmailOTP(ctx context.Context, email string, otp string) error +} +type OtpStore interface { + CreateOtp(ctx context.Context, otp domain.Otp) error + GetOtp(ctx context.Context, sentTo string, sentfor domain.OtpFor, medium domain.OtpMedium) (domain.Otp, error) + MarkUsed(ctx context.Context, id int64) error } diff --git a/internal/services/user/service.go b/internal/services/user/service.go index 8232bbb..2172211 100644 --- a/internal/services/user/service.go +++ b/internal/services/user/service.go @@ -2,36 +2,199 @@ package user import ( "context" + "errors" + "time" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" "golang.org/x/crypto/bcrypt" ) +const ( + OtpExpiry = 5 * time.Minute +) + +var ( + ErrOtpAlreadyUsed = errors.New("otp already used") + ErrInvalidOtp = errors.New("invalid otp") + ErrOtpExpired = errors.New("otp expired") +) + type Service struct { - userStore UserStore + userStore UserStore + otpStore OtpStore + smsGateway SmsGateway + emailGateway EmailGateway } -func NewService(userStore UserStore, RefreshExpiry int) *Service { +func NewService( + userStore UserStore, RefreshExpiry int, + otpStore OtpStore, smsGateway SmsGateway, + emailGateway EmailGateway, +) *Service { return &Service{ userStore: userStore, + otpStore: otpStore, } } -func (s *Service) CreateUser(ctx context.Context, firstName, lastName, email, phoneNumber, password, role string, verified bool) (domain.User, error) { - return s.userStore.CreateUser(ctx, firstName, lastName, email, phoneNumber, password, role, verified) +func (s *Service) CheckPhoneEmailExist(ctx context.Context, phoneNum, email string) (bool, bool, error) { // email,phone,error + return s.userStore.CheckPhoneEmailExist(ctx, phoneNum, email) +} +func (s *Service) SendRegisterCode(ctx context.Context, medium domain.OtpMedium, sentTo string) error { + var err error + // check if user exists + switch medium { + case domain.OtpMediumEmail: + _, err = s.userStore.GetUserByEmail(ctx, sentTo) + case domain.OtpMediumSms: + _, err = s.userStore.GetUserByPhone(ctx, sentTo) + } + + if err != nil { + return err + } + + // send otp based on the medium + return s.SendOtp(ctx, sentTo, domain.OtpReset, medium) +} +func (s *Service) RegisterUser(ctx context.Context, registerReq domain.RegisterUserReq) (domain.User, error) { // normal + // get otp + var sentTo string + if registerReq.OtpMedium == domain.OtpMediumEmail { + sentTo = registerReq.Email + } else { + sentTo = registerReq.PhoneNumber + } + // + otp, err := s.otpStore.GetOtp( + ctx, sentTo, + domain.OtpRegister, registerReq.OtpMedium) + if err != nil { + return domain.User{}, err + } + // verify otp + if otp.Used { + return domain.User{}, ErrOtpAlreadyUsed + } + if time.Now().After(otp.ExpiresAt) { + return domain.User{}, ErrOtpExpired + } + if otp.Otp != registerReq.Otp { + return domain.User{}, ErrInvalidOtp + } + + hashedPassword, err := hashPassword(registerReq.Password) + if err != nil { + return domain.User{}, err + } + userR := domain.User{ + FirstName: registerReq.FirstName, + LastName: registerReq.LastName, + Email: registerReq.Email, + PhoneNumber: registerReq.PhoneNumber, + Password: hashedPassword, + Role: "user", + EmailVerified: registerReq.OtpMedium == domain.OtpMediumEmail, + PhoneVerified: registerReq.OtpMedium == domain.OtpMediumSms, + } + // create the user and mark otp as used + user, err := s.userStore.CreateUser(ctx, userR, otp.ID) + if err != nil { + return domain.User{}, err + } + return user, nil +} + +func (s *Service) SendResetCode(ctx context.Context, medium domain.OtpMedium, sentTo string) error { + + var err error + // check if user exists + switch medium { + case domain.OtpMediumEmail: + _, err = s.userStore.GetUserByEmail(ctx, sentTo) + case domain.OtpMediumSms: + _, err = s.userStore.GetUserByPhone(ctx, sentTo) + } + + if err != nil { + return err + } + + return s.SendOtp(ctx, sentTo, domain.OtpReset, medium) + +} + +func (s *Service) ResetPassword(ctx context.Context, resetReq domain.ResetPasswordReq) error { + var sentTo string + if resetReq.OtpMedium == domain.OtpMediumEmail { + sentTo = resetReq.Email + } else { + sentTo = resetReq.PhoneNumber + } + otp, err := s.otpStore.GetOtp( + ctx, sentTo, + domain.OtpRegister, resetReq.OtpMedium) + if err != nil { + return err + } + // + if otp.Used { + return ErrOtpAlreadyUsed + } + if time.Now().After(otp.ExpiresAt) { + return ErrOtpExpired + } + if otp.Otp != resetReq.Otp { + return ErrInvalidOtp + } + // hash password + hashedPassword, err := hashPassword(resetReq.Password) + if err != nil { + return err + } + // reset pass and mark otp as used + err = s.userStore.UpdatePassword(ctx, sentTo, hashedPassword, otp.ID) + if err != nil { + return err + } + return nil +} +func (s *Service) SendOtp(ctx context.Context, sentTo string, otpFor domain.OtpFor, medium domain.OtpMedium) error { + otpCode := "123456" // Generate OTP code + + otp := domain.Otp{ + SentTo: sentTo, + Medium: medium, + For: otpFor, + Otp: otpCode, + Used: false, + CreatedAt: time.Now(), + ExpiresAt: time.Now().Add(OtpExpiry), + } + + err := s.otpStore.CreateOtp(ctx, otp) + if err != nil { + return err + } + + switch medium { + case domain.OtpMediumSms: + return s.smsGateway.SendSMSOTP(ctx, sentTo, otpCode) + case domain.OtpMediumEmail: + return s.emailGateway.SendEmailOTP(ctx, sentTo, otpCode) + } + return nil +} + +func (s *Service) UpdateUser(ctx context.Context, user domain.UpdateUserReq) error { + // update user + return s.userStore.UpdateUser(ctx, user) + } func (s *Service) GetUserByID(ctx context.Context, id int64) (domain.User, error) { return s.userStore.GetUserByID(ctx, id) } -func (s *Service) GetAllUsers(ctx context.Context) ([]domain.User, error) { - return s.userStore.GetAllUsers(ctx) -} -func (s *Service) UpdateUser(ctx context.Context, id int64, firstName, lastName, email, phoneNumber, password, role string, verified bool) error { - return s.userStore.UpdateUser(ctx, id, firstName, lastName, email, phoneNumber, password, role, verified) -} -func (s *Service) DeleteUser(ctx context.Context, id int64) error { - return s.userStore.DeleteUser(ctx, id) -} + func hashPassword(plaintextPassword string) ([]byte, error) { hash, err := bcrypt.GenerateFromPassword([]byte(plaintextPassword), 12) if err != nil { diff --git a/internal/web_server/app.go b/internal/web_server/app.go index 3672a2c..811b973 100644 --- a/internal/web_server/app.go +++ b/internal/web_server/app.go @@ -5,29 +5,26 @@ import ( "log/slog" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/authentication" + jwtutil "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/jwt" customvalidator "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/validator" "github.com/bytedance/sonic" "github.com/gofiber/fiber/v2" ) -type JwtConfig struct { - JwtAccessKey string - JwtAccessExpiry int -} type App struct { fiber *fiber.App logger *slog.Logger port int authSvc *authentication.Service validator *customvalidator.CustomValidator - JwtConfig JwtConfig + JwtConfig jwtutil.JwtConfig } func NewApp( port int, validator *customvalidator.CustomValidator, authSvc *authentication.Service, logger *slog.Logger, - JwtConfig JwtConfig, + JwtConfig jwtutil.JwtConfig, ) *App { app := fiber.New(fiber.Config{ CaseSensitive: true, diff --git a/internal/web_server/handlers/auth_handler.go b/internal/web_server/handlers/auth_handler.go new file mode 100644 index 0000000..551b259 --- /dev/null +++ b/internal/web_server/handlers/auth_handler.go @@ -0,0 +1,183 @@ +package handlers + +import ( + "errors" + "log/slog" + "strconv" + + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/authentication" + jwtutil "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/jwt" + "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/response" + customvalidator "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/validator" + "github.com/gofiber/fiber/v2" +) + +type loginCustomerReq struct { + Email string `json:"email" example:"john.doe@example.com"` + PhoneNumber string `json:"phone_number" example:"1234567890"` + Password string `json:"password" example:"password123"` +} + +type loginCustomerRes struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` +} + +// LoginCustomer godoc +// @Summary Login customer +// @Description Login customer +// @Tags auth +// @Accept json +// @Produce json +// @Param login body loginCustomerReq true "Login customer" +// @Success 200 {object} loginCustomerRes +// @Failure 400 {object} response.APIResponse +// @Failure 401 {object} response.APIResponse +// @Failure 500 {object} response.APIResponse +// @Router /auth/login [post] +func LoginCustomer( + logger *slog.Logger, authSvc *authentication.Service, + validator *customvalidator.CustomValidator, JwtConfig jwtutil.JwtConfig) fiber.Handler { + return func(c *fiber.Ctx) error { + var req loginCustomerReq + if err := c.BodyParser(&req); err != nil { + logger.Error("Login failed", "error", err) + response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", nil, nil) + } + valErrs, ok := validator.Validate(c, req) + if !ok { + + response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", valErrs, nil) + return nil + } + successRes, err := authSvc.Login(c.Context(), req.Email, req.PhoneNumber, req.Password) + if err != nil { + logger.Info("Login failed", "error", err) + if errors.Is(err, authentication.ErrInvalidPassword) { + response.WriteJSON(c, fiber.StatusUnauthorized, "Invalid password or not registered", nil, nil) + return nil + } + if errors.Is(err, authentication.ErrUserNotFound) { + response.WriteJSON(c, fiber.StatusUnauthorized, "Invalid password or not registered", nil, nil) + return nil + } + logger.Error("Login failed", "error", err) + response.WriteJSON(c, fiber.StatusInternalServerError, "Internal server error", nil, nil) + return nil + + } + accessToken, err := jwtutil.CreateJwt(strconv.Itoa(int(successRes.UserId)), successRes.Role, JwtConfig.JwtAccessKey, JwtConfig.JwtAccessExpiry) + res := loginCustomerRes{ + AccessToken: accessToken, + RefreshToken: successRes.RfToken, + } + return response.WriteJSON(c, fiber.StatusOK, "Login successful", res, nil) + } +} + +type refreshToken struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` +} + +// RefreshToken godoc +// @Summary Refresh token +// @Description Refresh token +// @Tags auth +// @Accept json +// @Produce json +// @Param refresh body refreshToken true "tokens" +// @Success 200 {object} loginCustomerRes +// @Failure 400 {object} response.APIResponse +// @Failure 401 {object} response.APIResponse +// @Failure 500 {object} response.APIResponse +// @Router /auth/refresh [post] +func RefreshToken(logger *slog.Logger, authSvc *authentication.Service, + validator *customvalidator.CustomValidator, JwtConfig jwtutil.JwtConfig) fiber.Handler { + return func(c *fiber.Ctx) error { + var req refreshToken + if err := c.BodyParser(&req); err != nil { + response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", nil, nil) + } + valErrs, ok := validator.Validate(c, req) + if !ok { + response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", valErrs, nil) + return nil + } + rf, err := authSvc.RefreshToken(c.Context(), req.RefreshToken) + if err != nil { + logger.Info("Refresh token failed", "error", err) + if errors.Is(err, authentication.ErrExpiredToken) { + response.WriteJSON(c, fiber.StatusUnauthorized, "The refresh token has expired", nil, nil) + return nil + } + if errors.Is(err, authentication.ErrRefreshTokenNotFound) { + response.WriteJSON(c, fiber.StatusUnauthorized, "Refresh token not found", nil, nil) + return nil + } + logger.Error("Refresh token failed", "error", err) + response.WriteJSON(c, fiber.StatusInternalServerError, "Internal server error", nil, nil) + return nil + } + accessToken, err := jwtutil.CreateJwt("", "", JwtConfig.JwtAccessKey, JwtConfig.JwtAccessExpiry) + if err != nil { + logger.Error("Create jwt failed", "error", err) + response.WriteJSON(c, fiber.StatusInternalServerError, "Internal server error", nil, nil) + return nil + } + + res := loginCustomerRes{ + AccessToken: accessToken, + RefreshToken: rf, + } + return response.WriteJSON(c, fiber.StatusOK, "refresh successful", res, nil) + } +} + +type logoutReq struct { + RefreshToken string `json:"refresh_token"` +} + +// LogOutCustomer godoc +// @Summary Logout customer +// @Description Logout customer +// @Tags auth +// @Accept json +// @Produce json +// @Param logout body logoutReq true "Logout customer" +// @Success 200 {object} response.APIResponse +// @Failure 400 {object} response.APIResponse +// @Failure 401 {object} response.APIResponse +// @Failure 500 {object} response.APIResponse +// @Router /auth/logout [post] +func LogOutCustomer( + logger *slog.Logger, authSvc *authentication.Service, + validator *customvalidator.CustomValidator) fiber.Handler { + return func(c *fiber.Ctx) error { + var req logoutReq + if err := c.BodyParser(&req); err != nil { + response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", nil, nil) + } + valErrs, ok := validator.Validate(c, req) + if !ok { + response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", valErrs, nil) + return nil + } + err := authSvc.Logout(c.Context(), req.RefreshToken) + if err != nil { + logger.Info("Logout failed", "error", err) + if errors.Is(err, authentication.ErrExpiredToken) { + response.WriteJSON(c, fiber.StatusUnauthorized, "The refresh token has expired", nil, nil) + return nil + } + if errors.Is(err, authentication.ErrRefreshTokenNotFound) { + response.WriteJSON(c, fiber.StatusUnauthorized, "Refresh token not found", nil, nil) + return nil + } + logger.Error("Logout failed", "error", err) + response.WriteJSON(c, fiber.StatusInternalServerError, "Internal server error", nil, nil) + return nil + } + return response.WriteJSON(c, fiber.StatusOK, "Logout successful", nil, nil) + } +} diff --git a/internal/web_server/jwt/jwt.go b/internal/web_server/jwt/jwt.go index 7dc3659..a59e81f 100644 --- a/internal/web_server/jwt/jwt.go +++ b/internal/web_server/jwt/jwt.go @@ -4,6 +4,7 @@ import ( "errors" "time" + "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" "github.com/golang-jwt/jwt/v5" ) @@ -20,10 +21,14 @@ var ( type UserClaim struct { jwt.RegisteredClaims UserId string - Role string + Role domain.Role +} +type JwtConfig struct { + JwtAccessKey string + JwtAccessExpiry int } -func CreateJwt(userId string, Role string, key string, expiry int) (string, error) { +func CreateJwt(userId string, Role domain.Role, key string, expiry int) (string, error) { token := jwt.NewWithClaims(jwt.SigningMethodHS256, UserClaim{RegisteredClaims: jwt.RegisteredClaims{Issuer: "github.com/lafetz/snippitstash", IssuedAt: jwt.NewNumericDate(time.Now()), Audience: jwt.ClaimStrings{"fortune.com"}, diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index e54fc9c..4af9781 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -2,14 +2,15 @@ package httpserver import ( _ "github.com/SamuelTariku/FortuneBet-Backend/docs" + "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/handlers" "github.com/gofiber/fiber/v2" fiberSwagger "github.com/swaggo/fiber-swagger" ) func (a *App) initAppRoutes() { - a.fiber.Post("/auth/login", a.LoginCustomer) - a.fiber.Post("/auth/refresh", a.authMiddleware, a.RefreshToken) - a.fiber.Post("/auth/logout", a.authMiddleware, a.LogOutCustomer) + a.fiber.Post("/auth/login", handlers.LoginCustomer(a.logger, a.authSvc, a.validator, a.JwtConfig)) + a.fiber.Post("/auth/refresh", a.authMiddleware, handlers.RefreshToken(a.logger, a.authSvc, a.validator, a.JwtConfig)) + a.fiber.Post("/auth/logout", a.authMiddleware, handlers.LogOutCustomer(a.logger, a.authSvc, a.validator)) a.fiber.Get("/auth/test", a.authMiddleware, func(c *fiber.Ctx) error { userId := c.Locals("user_id") role := c.Locals("role") diff --git a/internal/web_server/user_handler.go b/internal/web_server/user_handler.go deleted file mode 100644 index 199eadb..0000000 --- a/internal/web_server/user_handler.go +++ /dev/null @@ -1,170 +0,0 @@ -package httpserver - -import ( - "errors" - "strconv" - - "github.com/SamuelTariku/FortuneBet-Backend/internal/services/authentication" - jwtutil "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/jwt" - "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/response" - "github.com/gofiber/fiber/v2" -) - -type loginCustomerReq struct { - Email string `json:"email" example:"john.doe@example.com"` - PhoneNumber string `json:"phone_number" example:"1234567890"` - Password string `json:"password" example:"password123"` -} - -type loginCustomerRes struct { - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` -} - -// LoginCustomer godoc -// @Summary Login customer -// @Description Login customer -// @Tags auth -// @Accept json -// @Produce json -// @Param login body loginCustomerReq true "Login customer" -// @Success 200 {object} loginCustomerRes -// @Failure 400 {object} response.APIResponse -// @Failure 401 {object} response.APIResponse -// @Failure 500 {object} response.APIResponse -// @Router /auth/login [post] -func (a *App) LoginCustomer(c *fiber.Ctx) error { - var req loginCustomerReq - if err := c.BodyParser(&req); err != nil { - a.logger.Error("Login failed", "error", err) - response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", nil, nil) - } - valErrs, ok := a.validator.Validate(c, req) - if !ok { - - response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", valErrs, nil) - return nil - } - successRes, err := a.authSvc.Login(c.Context(), req.Email, req.PhoneNumber, req.Password) - if err != nil { - a.logger.Info("Login failed", "error", err) - if errors.Is(err, authentication.ErrInvalidPassword) { - response.WriteJSON(c, fiber.StatusUnauthorized, "Invalid password or not registered", nil, nil) - return nil - } - if errors.Is(err, authentication.ErrUserNotFound) { - response.WriteJSON(c, fiber.StatusUnauthorized, "Invalid password or not registered", nil, nil) - return nil - } - a.logger.Error("Login failed", "error", err) - response.WriteJSON(c, fiber.StatusInternalServerError, "Internal server error", nil, nil) - return nil - - } - accessToken, err := jwtutil.CreateJwt(strconv.Itoa(int(successRes.UserId)), successRes.Role, a.JwtConfig.JwtAccessKey, a.JwtConfig.JwtAccessExpiry) - res := loginCustomerRes{ - AccessToken: accessToken, - RefreshToken: successRes.RfToken, - } - return response.WriteJSON(c, fiber.StatusOK, "Login successful", res, nil) -} - -type refreshToken struct { - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` -} - -// RefreshToken godoc -// @Summary Refresh token -// @Description Refresh token -// @Tags auth -// @Accept json -// @Produce json -// @Param refresh body refreshToken true "tokens" -// @Success 200 {object} loginCustomerRes -// @Failure 400 {object} response.APIResponse -// @Failure 401 {object} response.APIResponse -// @Failure 500 {object} response.APIResponse -// @Router /auth/refresh [post] -func (a *App) RefreshToken(c *fiber.Ctx) error { - var req refreshToken - if err := c.BodyParser(&req); err != nil { - response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", nil, nil) - } - valErrs, ok := a.validator.Validate(c, req) - if !ok { - response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", valErrs, nil) - return nil - } - rf, err := a.authSvc.RefreshToken(c.Context(), req.RefreshToken) - if err != nil { - a.logger.Info("Refresh token failed", "error", err) - if errors.Is(err, authentication.ErrExpiredToken) { - response.WriteJSON(c, fiber.StatusUnauthorized, "The refresh token has expired", nil, nil) - return nil - } - if errors.Is(err, authentication.ErrRefreshTokenNotFound) { - response.WriteJSON(c, fiber.StatusUnauthorized, "Refresh token not found", nil, nil) - return nil - } - a.logger.Error("Refresh token failed", "error", err) - response.WriteJSON(c, fiber.StatusInternalServerError, "Internal server error", nil, nil) - return nil - } - accessToken, err := jwtutil.CreateJwt("", "", a.JwtConfig.JwtAccessKey, a.JwtConfig.JwtAccessExpiry) - if err != nil { - a.logger.Error("Create jwt failed", "error", err) - response.WriteJSON(c, fiber.StatusInternalServerError, "Internal server error", nil, nil) - return nil - } - - res := loginCustomerRes{ - AccessToken: accessToken, - RefreshToken: rf, - } - return response.WriteJSON(c, fiber.StatusOK, "refresh successful", res, nil) -} - -type logoutReq struct { - RefreshToken string `json:"refresh_token"` -} - -// LogOutCustomer godoc -// @Summary Logout customer -// @Description Logout customer -// @Tags auth -// @Accept json -// @Produce json -// @Param logout body logoutReq true "Logout customer" -// @Success 200 {object} response.APIResponse -// @Failure 400 {object} response.APIResponse -// @Failure 401 {object} response.APIResponse -// @Failure 500 {object} response.APIResponse -// @Router /auth/logout [post] -func (a *App) LogOutCustomer(c *fiber.Ctx) error { - var req logoutReq - if err := c.BodyParser(&req); err != nil { - response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", nil, nil) - } - valErrs, ok := a.validator.Validate(c, req) - if !ok { - response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", valErrs, nil) - return nil - } - err := a.authSvc.Logout(c.Context(), req.RefreshToken) - if err != nil { - a.logger.Info("Logout failed", "error", err) - if errors.Is(err, authentication.ErrExpiredToken) { - response.WriteJSON(c, fiber.StatusUnauthorized, "The refresh token has expired", nil, nil) - return nil - } - if errors.Is(err, authentication.ErrRefreshTokenNotFound) { - response.WriteJSON(c, fiber.StatusUnauthorized, "Refresh token not found", nil, nil) - return nil - } - a.logger.Error("Logout failed", "error", err) - response.WriteJSON(c, fiber.StatusInternalServerError, "Internal server error", nil, nil) - return nil - } - return response.WriteJSON(c, fiber.StatusOK, "Logout successful", nil, nil) -} diff --git a/makefile b/makefile index 6fad4c7..1cacc84 100644 --- a/makefile +++ b/makefile @@ -30,3 +30,9 @@ migrations/up: .PHONY: swagger swagger: swag init -g cmd/main.go +.PHONY: db-up +db-up: + docker compose -f compose.db.yaml up +.PHONY: db-down +db-down: + docker compose -f compose.db.yaml down \ No newline at end of file From ca7aa9d67c86094d960b0f2827b3d49b9fd6e89c Mon Sep 17 00:00:00 2001 From: lafetz Date: Mon, 31 Mar 2025 00:25:50 +0300 Subject: [PATCH 5/5] fix registration and password reset --- cmd/main.go | 9 +- db/migrations/000001_fortune.up.sql | 39 +- db/query/otp.sql | 14 + db/query/user.sql | 40 +- docs/docs.go | 447 ++++++++++++++++++- docs/swagger.json | 447 ++++++++++++++++++- docs/swagger.yaml | 304 ++++++++++++- gen/db/auth.sql.go | 13 +- gen/db/models.go | 35 +- gen/db/otp.sql.go | 77 ++++ gen/db/user.sql.go | 218 +++++++-- internal/domain/otp.go | 12 +- internal/domain/user.go | 9 +- internal/mocks/mock_email/email.go | 18 + internal/mocks/mock_sms/sms.go | 19 + internal/repository/auth.go | 14 +- internal/repository/otp.go | 50 +++ internal/repository/user.go | 193 ++++++-- internal/services/user/common.go | 44 ++ internal/services/user/port.go | 1 - internal/services/user/register.go | 79 ++++ internal/services/user/reset.go | 63 +++ internal/services/user/service.go | 186 +------- internal/services/user/user.go | 16 + internal/web_server/app.go | 4 + internal/web_server/handlers/auth_handler.go | 5 +- internal/web_server/handlers/user.go | 365 +++++++++++++++ internal/web_server/jwt/jwt.go | 4 +- internal/web_server/middleware.go | 2 +- internal/web_server/routes.go | 14 + 30 files changed, 2409 insertions(+), 332 deletions(-) create mode 100644 db/query/otp.sql create mode 100644 gen/db/otp.sql.go create mode 100644 internal/mocks/mock_email/email.go create mode 100644 internal/mocks/mock_sms/sms.go create mode 100644 internal/repository/otp.go create mode 100644 internal/services/user/common.go create mode 100644 internal/services/user/register.go create mode 100644 internal/services/user/reset.go create mode 100644 internal/services/user/user.go create mode 100644 internal/web_server/handlers/user.go diff --git a/cmd/main.go b/cmd/main.go index 5bc6f41..1797c8e 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -7,8 +7,11 @@ import ( "github.com/SamuelTariku/FortuneBet-Backend/internal/config" customlogger "github.com/SamuelTariku/FortuneBet-Backend/internal/logger" + mockemail "github.com/SamuelTariku/FortuneBet-Backend/internal/mocks/mock_email" + mocksms "github.com/SamuelTariku/FortuneBet-Backend/internal/mocks/mock_sms" "github.com/SamuelTariku/FortuneBet-Backend/internal/repository" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/authentication" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/user" httpserver "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server" jwtutil "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/jwt" customvalidator "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/validator" @@ -44,10 +47,14 @@ func main() { store := repository.NewStore(db) v := customvalidator.NewCustomValidator(validator.New()) authSvc := authentication.NewService(store, store, cfg.RefreshExpiry) + mockSms := mocksms.NewMockSMS() + mockemail := mockemail.NewMockEmail() + userSvc := user.NewService(store, store, mockSms, mockemail) app := httpserver.NewApp(cfg.Port, v, authSvc, logger, jwtutil.JwtConfig{ JwtAccessKey: cfg.JwtKey, JwtAccessExpiry: cfg.AccessExpiry, - }) + }, userSvc, + ) logger.Info("Starting server", "port", cfg.Port) if err := app.Run(); err != nil { logger.Error("Failed to start server", "error", err) diff --git a/db/migrations/000001_fortune.up.sql b/db/migrations/000001_fortune.up.sql index 7647214..a4e3bd0 100644 --- a/db/migrations/000001_fortune.up.sql +++ b/db/migrations/000001_fortune.up.sql @@ -4,18 +4,16 @@ CREATE TABLE users ( last_name VARCHAR(255) NOT NULL, email VARCHAR(255) UNIQUE , phone_number VARCHAR(20) UNIQUE, - password BYTEA NOT NULL, role VARCHAR(50) NOT NULL, + password BYTEA NOT NULL, email_verified BOOLEAN NOT NULL DEFAULT FALSE, phone_verified BOOLEAN NOT NULL DEFAULT FALSE, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ , + -- suspended_at TIMESTAMPTZ NULL, -- this can be NULL if the user is not suspended suspended BOOLEAN NOT NULL DEFAULT FALSE, - CHECK ( - (email IS NOT NULL AND phone_number IS NULL) OR - (email IS NULL AND phone_number IS NOT NULL) - ) + CHECK (email IS NOT NULL OR phone_number IS NOT NULL) ); CREATE TABLE refresh_tokens ( id BIGSERIAL PRIMARY KEY, @@ -26,19 +24,38 @@ CREATE TABLE refresh_tokens ( revoked BOOLEAN DEFAULT FALSE NOT NULL, CONSTRAINT unique_token UNIQUE (token) ); +----- + CREATE TABLE otps ( + id BIGSERIAL PRIMARY KEY, + sent_to VARCHAR(255) NOT NULL, + medium VARCHAR(50) NOT NULL, + otp_for VARCHAR(50) NOT NULL, + otp VARCHAR(10) NOT NULL, + used BOOLEAN NOT NULL DEFAULT FALSE, + used_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMPTZ NOT NULL +); ----------------------------------------------seed data------------------------------------------------------------- -------------------------------------- DO NOT USE IN PRODUCTION------------------------------------------------- CREATE EXTENSION IF NOT EXISTS pgcrypto; -INSERT INTO users (first_name, last_name, email, phone_number, password, role, created_at, updated_at) -VALUES ( +INSERT INTO users ( + first_name, last_name, email, phone_number, password, role, + email_verified, phone_verified, created_at, updated_at, + suspended_at, suspended +) VALUES ( 'John', 'Doe', 'john.doe@example.com', - '1234567890', + NULL, crypt('password123', gen_salt('bf'))::bytea, - 'user', + 'customer', + TRUE, + FALSE, CURRENT_TIMESTAMP, - CURRENT_TIMESTAMP + CURRENT_TIMESTAMP, + NULL, + FALSE ); \ No newline at end of file diff --git a/db/query/otp.sql b/db/query/otp.sql new file mode 100644 index 0000000..90aec56 --- /dev/null +++ b/db/query/otp.sql @@ -0,0 +1,14 @@ +-- name: CreateOtp :exec +INSERT INTO otps (sent_to, medium, otp_for, otp, used, created_at, expires_at) +VALUES ($1, $2, $3, $4, FALSE, CURRENT_TIMESTAMP, $5); + +-- name: GetOtp :one +SELECT id, sent_to, medium, otp_for, otp, used, used_at, created_at, expires_at +FROM otps +WHERE sent_to = $1 AND otp_for = $2 AND medium = $3 +ORDER BY created_at DESC LIMIT 1; + +-- name: MarkOtpAsUsed :exec +UPDATE otps +SET used = TRUE, used_at = CURRENT_TIMESTAMP +WHERE id = $1; \ No newline at end of file diff --git a/db/query/user.sql b/db/query/user.sql index 1d356e9..c0f14c9 100644 --- a/db/query/user.sql +++ b/db/query/user.sql @@ -1,16 +1,42 @@ -- name: CreateUser :one -INSERT INTO users (first_name, last_name, email, phone_number, password, role, verified) -VALUES ($1, $2, $3, $4, $5, $6, $7) -RETURNING *; + +INSERT INTO users (first_name, last_name, email, phone_number, role, password, email_verified, phone_verified, created_at, updated_at) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) +RETURNING id, first_name, last_name, email, phone_number, role, email_verified, phone_verified, created_at, updated_at; -- name: GetUserByID :one -SELECT * FROM users WHERE id = $1; +SELECT * +FROM users +WHERE id = $1; -- name: GetAllUsers :many -SELECT * FROM users; +SELECT id, first_name, last_name, email, phone_number, role, email_verified, phone_verified, created_at, updated_at +FROM users; -- name: UpdateUser :exec -UPDATE users SET first_name = $2, last_name = $3, email = $4, phone_number = $5, password = $6, role = $7, verified = $8, updated_at = CURRENT_TIMESTAMP WHERE id = $1; +UPDATE users +SET first_name = $1, last_name = $2, email = $3, phone_number = $4, role = $5, updated_at = CURRENT_TIMESTAMP +WHERE id = $6; -- name: DeleteUser :exec -DELETE FROM users WHERE id = $1; +DELETE FROM users +WHERE id = $1; + +-- name: CheckPhoneEmailExist :one +SELECT + EXISTS (SELECT 1 FROM users WHERE users.phone_number = $1 AND users.phone_number IS NOT NULL) AS phone_exists, + EXISTS (SELECT 1 FROM users WHERE users.email = $2 AND users.email IS NOT NULL) AS email_exists; +-- name: GetUserByEmail :one +SELECT id, first_name, last_name, email, phone_number, role, email_verified, phone_verified, created_at, updated_at +FROM users +WHERE email = $1; + +-- name: GetUserByPhone :one +SELECT id, first_name, last_name, email, phone_number, role, email_verified, phone_verified, created_at, updated_at +FROM users +WHERE phone_number = $1; + +-- name: UpdatePassword :exec +UPDATE users +SET password = $1, updated_at = CURRENT_TIMESTAMP +WHERE (email = $2 OR phone_number = $3); \ No newline at end of file diff --git a/docs/docs.go b/docs/docs.go index 28a683c..6625028 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -44,7 +44,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/httpserver.loginCustomerReq" + "$ref": "#/definitions/handlers.loginCustomerReq" } } ], @@ -52,7 +52,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/httpserver.loginCustomerRes" + "$ref": "#/definitions/handlers.loginCustomerRes" } }, "400": { @@ -96,7 +96,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/httpserver.logoutReq" + "$ref": "#/definitions/handlers.logoutReq" } } ], @@ -148,7 +148,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/httpserver.refreshToken" + "$ref": "#/definitions/handlers.refreshToken" } } ], @@ -156,7 +156,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/httpserver.loginCustomerRes" + "$ref": "#/definitions/handlers.loginCustomerRes" } }, "400": { @@ -179,10 +179,439 @@ const docTemplate = `{ } } } + }, + "/user/checkPhoneEmailExist": { + "post": { + "description": "Check if phone number or email exist", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Check if phone number or email exist", + "parameters": [ + { + "description": "Check phone number or email exist", + "name": "checkPhoneEmailExist", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.CheckPhoneEmailExistReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.CheckPhoneEmailExistRes" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, + "/user/profile": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Get user profile", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Get user profile", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.UserProfileRes" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, + "/user/register": { + "post": { + "description": "Register user", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Register user", + "parameters": [ + { + "description": "Register user", + "name": "registerUser", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.RegisterUserReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, + "/user/resetPassword": { + "post": { + "description": "Reset password", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Reset password", + "parameters": [ + { + "description": "Reset password", + "name": "resetPassword", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.ResetPasswordReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, + "/user/sendRegisterCode": { + "post": { + "description": "Send register code", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Send register code", + "parameters": [ + { + "description": "Send register code", + "name": "registerCode", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.RegisterCodeReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, + "/user/sendResetCode": { + "post": { + "description": "Send reset code", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Send reset code", + "parameters": [ + { + "description": "Send reset code", + "name": "resetCode", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.ResetCodeReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } } }, "definitions": { - "httpserver.loginCustomerReq": { + "domain.Role": { + "type": "string", + "enum": [ + "admin", + "customer", + "super_admin", + "branch_manager", + "cashier" + ], + "x-enum-varnames": [ + "RoleAdmin", + "RoleCustomer", + "RoleSuperAdmin", + "RoleBranchManager", + "RoleCashier" + ] + }, + "handlers.CheckPhoneEmailExistReq": { + "type": "object", + "properties": { + "email": { + "type": "string", + "example": "john.doe@example.com" + }, + "phone_number": { + "type": "string", + "example": "1234567890" + } + } + }, + "handlers.CheckPhoneEmailExistRes": { + "type": "object", + "properties": { + "email_exist": { + "type": "boolean" + }, + "phone_number_exist": { + "type": "boolean" + } + } + }, + "handlers.RegisterCodeReq": { + "type": "object", + "properties": { + "email": { + "type": "string", + "example": "john.doe@example.com" + }, + "phone_number": { + "type": "string", + "example": "1234567890" + } + } + }, + "handlers.RegisterUserReq": { + "type": "object", + "properties": { + "email": { + "type": "string", + "example": "john.doe@example.com" + }, + "first_name": { + "type": "string", + "example": "John" + }, + "last_name": { + "type": "string", + "example": "Doe" + }, + "otp": { + "description": "Role string", + "type": "string", + "example": "123456" + }, + "password": { + "type": "string", + "example": "password123" + }, + "phone_number": { + "type": "string", + "example": "1234567890" + }, + "referal_code": { + "type": "string", + "example": "ABC123" + } + } + }, + "handlers.ResetCodeReq": { + "type": "object", + "properties": { + "email": { + "type": "string", + "example": "john.doe@example.com" + }, + "phone_number": { + "type": "string", + "example": "1234567890" + } + } + }, + "handlers.ResetPasswordReq": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "otp": { + "type": "string" + }, + "password": { + "type": "string" + }, + "phoneNumber": { + "type": "string" + } + } + }, + "handlers.UserProfileRes": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "email": { + "type": "string" + }, + "email_verified": { + "type": "boolean" + }, + "first_name": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "last_name": { + "type": "string" + }, + "phone_number": { + "type": "string" + }, + "phone_verified": { + "type": "boolean" + }, + "role": { + "$ref": "#/definitions/domain.Role" + }, + "suspended": { + "type": "boolean" + }, + "suspended_at": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "handlers.loginCustomerReq": { "type": "object", "properties": { "email": { @@ -199,7 +628,7 @@ const docTemplate = `{ } } }, - "httpserver.loginCustomerRes": { + "handlers.loginCustomerRes": { "type": "object", "properties": { "access_token": { @@ -210,7 +639,7 @@ const docTemplate = `{ } } }, - "httpserver.logoutReq": { + "handlers.logoutReq": { "type": "object", "properties": { "refresh_token": { @@ -218,7 +647,7 @@ const docTemplate = `{ } } }, - "httpserver.refreshToken": { + "handlers.refreshToken": { "type": "object", "properties": { "access_token": { diff --git a/docs/swagger.json b/docs/swagger.json index 07db1f3..76ae6c5 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -36,7 +36,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/httpserver.loginCustomerReq" + "$ref": "#/definitions/handlers.loginCustomerReq" } } ], @@ -44,7 +44,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/httpserver.loginCustomerRes" + "$ref": "#/definitions/handlers.loginCustomerRes" } }, "400": { @@ -88,7 +88,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/httpserver.logoutReq" + "$ref": "#/definitions/handlers.logoutReq" } } ], @@ -140,7 +140,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/httpserver.refreshToken" + "$ref": "#/definitions/handlers.refreshToken" } } ], @@ -148,7 +148,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/httpserver.loginCustomerRes" + "$ref": "#/definitions/handlers.loginCustomerRes" } }, "400": { @@ -171,10 +171,439 @@ } } } + }, + "/user/checkPhoneEmailExist": { + "post": { + "description": "Check if phone number or email exist", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Check if phone number or email exist", + "parameters": [ + { + "description": "Check phone number or email exist", + "name": "checkPhoneEmailExist", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.CheckPhoneEmailExistReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.CheckPhoneEmailExistRes" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, + "/user/profile": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Get user profile", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Get user profile", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.UserProfileRes" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, + "/user/register": { + "post": { + "description": "Register user", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Register user", + "parameters": [ + { + "description": "Register user", + "name": "registerUser", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.RegisterUserReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, + "/user/resetPassword": { + "post": { + "description": "Reset password", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Reset password", + "parameters": [ + { + "description": "Reset password", + "name": "resetPassword", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.ResetPasswordReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, + "/user/sendRegisterCode": { + "post": { + "description": "Send register code", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Send register code", + "parameters": [ + { + "description": "Send register code", + "name": "registerCode", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.RegisterCodeReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, + "/user/sendResetCode": { + "post": { + "description": "Send reset code", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Send reset code", + "parameters": [ + { + "description": "Send reset code", + "name": "resetCode", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.ResetCodeReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } } }, "definitions": { - "httpserver.loginCustomerReq": { + "domain.Role": { + "type": "string", + "enum": [ + "admin", + "customer", + "super_admin", + "branch_manager", + "cashier" + ], + "x-enum-varnames": [ + "RoleAdmin", + "RoleCustomer", + "RoleSuperAdmin", + "RoleBranchManager", + "RoleCashier" + ] + }, + "handlers.CheckPhoneEmailExistReq": { + "type": "object", + "properties": { + "email": { + "type": "string", + "example": "john.doe@example.com" + }, + "phone_number": { + "type": "string", + "example": "1234567890" + } + } + }, + "handlers.CheckPhoneEmailExistRes": { + "type": "object", + "properties": { + "email_exist": { + "type": "boolean" + }, + "phone_number_exist": { + "type": "boolean" + } + } + }, + "handlers.RegisterCodeReq": { + "type": "object", + "properties": { + "email": { + "type": "string", + "example": "john.doe@example.com" + }, + "phone_number": { + "type": "string", + "example": "1234567890" + } + } + }, + "handlers.RegisterUserReq": { + "type": "object", + "properties": { + "email": { + "type": "string", + "example": "john.doe@example.com" + }, + "first_name": { + "type": "string", + "example": "John" + }, + "last_name": { + "type": "string", + "example": "Doe" + }, + "otp": { + "description": "Role string", + "type": "string", + "example": "123456" + }, + "password": { + "type": "string", + "example": "password123" + }, + "phone_number": { + "type": "string", + "example": "1234567890" + }, + "referal_code": { + "type": "string", + "example": "ABC123" + } + } + }, + "handlers.ResetCodeReq": { + "type": "object", + "properties": { + "email": { + "type": "string", + "example": "john.doe@example.com" + }, + "phone_number": { + "type": "string", + "example": "1234567890" + } + } + }, + "handlers.ResetPasswordReq": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "otp": { + "type": "string" + }, + "password": { + "type": "string" + }, + "phoneNumber": { + "type": "string" + } + } + }, + "handlers.UserProfileRes": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "email": { + "type": "string" + }, + "email_verified": { + "type": "boolean" + }, + "first_name": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "last_name": { + "type": "string" + }, + "phone_number": { + "type": "string" + }, + "phone_verified": { + "type": "boolean" + }, + "role": { + "$ref": "#/definitions/domain.Role" + }, + "suspended": { + "type": "boolean" + }, + "suspended_at": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "handlers.loginCustomerReq": { "type": "object", "properties": { "email": { @@ -191,7 +620,7 @@ } } }, - "httpserver.loginCustomerRes": { + "handlers.loginCustomerRes": { "type": "object", "properties": { "access_token": { @@ -202,7 +631,7 @@ } } }, - "httpserver.logoutReq": { + "handlers.logoutReq": { "type": "object", "properties": { "refresh_token": { @@ -210,7 +639,7 @@ } } }, - "httpserver.refreshToken": { + "handlers.refreshToken": { "type": "object", "properties": { "access_token": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 81f777c..166d41d 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -1,5 +1,116 @@ definitions: - httpserver.loginCustomerReq: + domain.Role: + enum: + - admin + - customer + - super_admin + - branch_manager + - cashier + type: string + x-enum-varnames: + - RoleAdmin + - RoleCustomer + - RoleSuperAdmin + - RoleBranchManager + - RoleCashier + handlers.CheckPhoneEmailExistReq: + properties: + email: + example: john.doe@example.com + type: string + phone_number: + example: "1234567890" + type: string + type: object + handlers.CheckPhoneEmailExistRes: + properties: + email_exist: + type: boolean + phone_number_exist: + type: boolean + type: object + handlers.RegisterCodeReq: + properties: + email: + example: john.doe@example.com + type: string + phone_number: + example: "1234567890" + type: string + type: object + handlers.RegisterUserReq: + properties: + email: + example: john.doe@example.com + type: string + first_name: + example: John + type: string + last_name: + example: Doe + type: string + otp: + description: Role string + example: "123456" + type: string + password: + example: password123 + type: string + phone_number: + example: "1234567890" + type: string + referal_code: + example: ABC123 + type: string + type: object + handlers.ResetCodeReq: + properties: + email: + example: john.doe@example.com + type: string + phone_number: + example: "1234567890" + type: string + type: object + handlers.ResetPasswordReq: + properties: + email: + type: string + otp: + type: string + password: + type: string + phoneNumber: + type: string + type: object + handlers.UserProfileRes: + properties: + created_at: + type: string + email: + type: string + email_verified: + type: boolean + first_name: + type: string + id: + type: integer + last_name: + type: string + phone_number: + type: string + phone_verified: + type: boolean + role: + $ref: '#/definitions/domain.Role' + suspended: + type: boolean + suspended_at: + type: string + updated_at: + type: string + type: object + handlers.loginCustomerReq: properties: email: example: john.doe@example.com @@ -11,19 +122,19 @@ definitions: example: "1234567890" type: string type: object - httpserver.loginCustomerRes: + handlers.loginCustomerRes: properties: access_token: type: string refresh_token: type: string type: object - httpserver.logoutReq: + handlers.logoutReq: properties: refresh_token: type: string type: object - httpserver.refreshToken: + handlers.refreshToken: properties: access_token: type: string @@ -73,14 +184,14 @@ paths: name: login required: true schema: - $ref: '#/definitions/httpserver.loginCustomerReq' + $ref: '#/definitions/handlers.loginCustomerReq' produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/httpserver.loginCustomerRes' + $ref: '#/definitions/handlers.loginCustomerRes' "400": description: Bad Request schema: @@ -107,7 +218,7 @@ paths: name: logout required: true schema: - $ref: '#/definitions/httpserver.logoutReq' + $ref: '#/definitions/handlers.logoutReq' produces: - application/json responses: @@ -141,14 +252,14 @@ paths: name: refresh required: true schema: - $ref: '#/definitions/httpserver.refreshToken' + $ref: '#/definitions/handlers.refreshToken' produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/httpserver.loginCustomerRes' + $ref: '#/definitions/handlers.loginCustomerRes' "400": description: Bad Request schema: @@ -164,6 +275,181 @@ paths: summary: Refresh token tags: - auth + /user/checkPhoneEmailExist: + post: + consumes: + - application/json + description: Check if phone number or email exist + parameters: + - description: Check phone number or email exist + in: body + name: checkPhoneEmailExist + required: true + schema: + $ref: '#/definitions/handlers.CheckPhoneEmailExistReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.CheckPhoneEmailExistRes' + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.APIResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/response.APIResponse' + summary: Check if phone number or email exist + tags: + - user + /user/profile: + get: + consumes: + - application/json + description: Get user profile + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.UserProfileRes' + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.APIResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/response.APIResponse' + security: + - Bearer: [] + summary: Get user profile + tags: + - user + /user/register: + post: + consumes: + - application/json + description: Register user + parameters: + - description: Register user + in: body + name: registerUser + required: true + schema: + $ref: '#/definitions/handlers.RegisterUserReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/response.APIResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.APIResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/response.APIResponse' + summary: Register user + tags: + - user + /user/resetPassword: + post: + consumes: + - application/json + description: Reset password + parameters: + - description: Reset password + in: body + name: resetPassword + required: true + schema: + $ref: '#/definitions/handlers.ResetPasswordReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/response.APIResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.APIResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/response.APIResponse' + summary: Reset password + tags: + - user + /user/sendRegisterCode: + post: + consumes: + - application/json + description: Send register code + parameters: + - description: Send register code + in: body + name: registerCode + required: true + schema: + $ref: '#/definitions/handlers.RegisterCodeReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/response.APIResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.APIResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/response.APIResponse' + summary: Send register code + tags: + - user + /user/sendResetCode: + post: + consumes: + - application/json + description: Send reset code + parameters: + - description: Send reset code + in: body + name: resetCode + required: true + schema: + $ref: '#/definitions/handlers.ResetCodeReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/response.APIResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.APIResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/response.APIResponse' + summary: Send reset code + tags: + - user securityDefinitions: Bearer: in: header diff --git a/gen/db/auth.sql.go b/gen/db/auth.sql.go index 7ad4b74..27fb891 100644 --- a/gen/db/auth.sql.go +++ b/gen/db/auth.sql.go @@ -55,13 +55,13 @@ func (q *Queries) GetRefreshToken(ctx context.Context, token string) (RefreshTok } const GetUserByEmailPhone = `-- name: GetUserByEmailPhone :one -SELECT id, first_name, last_name, email, phone_number, password, role, verified, created_at, updated_at FROM users +SELECT id, first_name, last_name, email, phone_number, role, password, email_verified, phone_verified, created_at, updated_at, suspended_at, suspended FROM users WHERE email = $1 OR phone_number = $2 ` type GetUserByEmailPhoneParams struct { - Email string - PhoneNumber string + Email pgtype.Text + PhoneNumber pgtype.Text } func (q *Queries) GetUserByEmailPhone(ctx context.Context, arg GetUserByEmailPhoneParams) (User, error) { @@ -73,11 +73,14 @@ func (q *Queries) GetUserByEmailPhone(ctx context.Context, arg GetUserByEmailPho &i.LastName, &i.Email, &i.PhoneNumber, - &i.Password, &i.Role, - &i.Verified, + &i.Password, + &i.EmailVerified, + &i.PhoneVerified, &i.CreatedAt, &i.UpdatedAt, + &i.SuspendedAt, + &i.Suspended, ) return i, err } diff --git a/gen/db/models.go b/gen/db/models.go index b32f097..a1465c2 100644 --- a/gen/db/models.go +++ b/gen/db/models.go @@ -8,6 +8,18 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +type Otp struct { + ID int64 + SentTo string + Medium string + OtpFor string + Otp string + Used bool + UsedAt pgtype.Timestamptz + CreatedAt pgtype.Timestamptz + ExpiresAt pgtype.Timestamptz +} + type RefreshToken struct { ID int64 UserID int64 @@ -18,14 +30,17 @@ type RefreshToken struct { } type User struct { - ID int64 - FirstName string - LastName string - Email string - PhoneNumber string - Password []byte - Role string - Verified pgtype.Bool - CreatedAt pgtype.Timestamptz - UpdatedAt pgtype.Timestamptz + ID int64 + FirstName string + LastName string + Email pgtype.Text + PhoneNumber pgtype.Text + Role string + Password []byte + EmailVerified bool + PhoneVerified bool + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz + SuspendedAt pgtype.Timestamptz + Suspended bool } diff --git a/gen/db/otp.sql.go b/gen/db/otp.sql.go new file mode 100644 index 0000000..0e93b5a --- /dev/null +++ b/gen/db/otp.sql.go @@ -0,0 +1,77 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.26.0 +// source: otp.sql + +package dbgen + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const CreateOtp = `-- name: CreateOtp :exec +INSERT INTO otps (sent_to, medium, otp_for, otp, used, created_at, expires_at) +VALUES ($1, $2, $3, $4, FALSE, CURRENT_TIMESTAMP, $5) +` + +type CreateOtpParams struct { + SentTo string + Medium string + OtpFor string + Otp string + ExpiresAt pgtype.Timestamptz +} + +func (q *Queries) CreateOtp(ctx context.Context, arg CreateOtpParams) error { + _, err := q.db.Exec(ctx, CreateOtp, + arg.SentTo, + arg.Medium, + arg.OtpFor, + arg.Otp, + arg.ExpiresAt, + ) + return err +} + +const GetOtp = `-- name: GetOtp :one +SELECT id, sent_to, medium, otp_for, otp, used, used_at, created_at, expires_at +FROM otps +WHERE sent_to = $1 AND otp_for = $2 AND medium = $3 +ORDER BY created_at DESC LIMIT 1 +` + +type GetOtpParams struct { + SentTo string + OtpFor string + Medium string +} + +func (q *Queries) GetOtp(ctx context.Context, arg GetOtpParams) (Otp, error) { + row := q.db.QueryRow(ctx, GetOtp, arg.SentTo, arg.OtpFor, arg.Medium) + var i Otp + err := row.Scan( + &i.ID, + &i.SentTo, + &i.Medium, + &i.OtpFor, + &i.Otp, + &i.Used, + &i.UsedAt, + &i.CreatedAt, + &i.ExpiresAt, + ) + return i, err +} + +const MarkOtpAsUsed = `-- name: MarkOtpAsUsed :exec +UPDATE otps +SET used = TRUE, used_at = CURRENT_TIMESTAMP +WHERE id = $1 +` + +func (q *Queries) MarkOtpAsUsed(ctx context.Context, id int64) error { + _, err := q.db.Exec(ctx, MarkOtpAsUsed, id) + return err +} diff --git a/gen/db/user.sql.go b/gen/db/user.sql.go index c1b551e..eaa5f52 100644 --- a/gen/db/user.sql.go +++ b/gen/db/user.sql.go @@ -11,42 +11,81 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +const CheckPhoneEmailExist = `-- name: CheckPhoneEmailExist :one +SELECT + EXISTS (SELECT 1 FROM users WHERE users.phone_number = $1 AND users.phone_number IS NOT NULL) AS phone_exists, + EXISTS (SELECT 1 FROM users WHERE users.email = $2 AND users.email IS NOT NULL) AS email_exists +` + +type CheckPhoneEmailExistParams struct { + PhoneNumber pgtype.Text + Email pgtype.Text +} + +type CheckPhoneEmailExistRow struct { + PhoneExists bool + EmailExists bool +} + +func (q *Queries) CheckPhoneEmailExist(ctx context.Context, arg CheckPhoneEmailExistParams) (CheckPhoneEmailExistRow, error) { + row := q.db.QueryRow(ctx, CheckPhoneEmailExist, arg.PhoneNumber, arg.Email) + var i CheckPhoneEmailExistRow + err := row.Scan(&i.PhoneExists, &i.EmailExists) + return i, err +} + const CreateUser = `-- name: CreateUser :one -INSERT INTO users (first_name, last_name, email, phone_number, password, role, verified) -VALUES ($1, $2, $3, $4, $5, $6, $7) -RETURNING id, first_name, last_name, email, phone_number, password, role, verified, created_at, updated_at + +INSERT INTO users (first_name, last_name, email, phone_number, role, password, email_verified, phone_verified, created_at, updated_at) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) +RETURNING id, first_name, last_name, email, phone_number, role, email_verified, phone_verified, created_at, updated_at ` type CreateUserParams struct { - FirstName string - LastName string - Email string - PhoneNumber string - Password []byte - Role string - Verified pgtype.Bool + FirstName string + LastName string + Email pgtype.Text + PhoneNumber pgtype.Text + Role string + Password []byte + EmailVerified bool + PhoneVerified bool } -func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) { +type CreateUserRow struct { + ID int64 + FirstName string + LastName string + Email pgtype.Text + PhoneNumber pgtype.Text + Role string + EmailVerified bool + PhoneVerified bool + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz +} + +func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (CreateUserRow, error) { row := q.db.QueryRow(ctx, CreateUser, arg.FirstName, arg.LastName, arg.Email, arg.PhoneNumber, - arg.Password, arg.Role, - arg.Verified, + arg.Password, + arg.EmailVerified, + arg.PhoneVerified, ) - var i User + var i CreateUserRow err := row.Scan( &i.ID, &i.FirstName, &i.LastName, &i.Email, &i.PhoneNumber, - &i.Password, &i.Role, - &i.Verified, + &i.EmailVerified, + &i.PhoneVerified, &i.CreatedAt, &i.UpdatedAt, ) @@ -54,7 +93,8 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e } const DeleteUser = `-- name: DeleteUser :exec -DELETE FROM users WHERE id = $1 +DELETE FROM users +WHERE id = $1 ` func (q *Queries) DeleteUser(ctx context.Context, id int64) error { @@ -63,27 +103,41 @@ func (q *Queries) DeleteUser(ctx context.Context, id int64) error { } const GetAllUsers = `-- name: GetAllUsers :many -SELECT id, first_name, last_name, email, phone_number, password, role, verified, created_at, updated_at FROM users +SELECT id, first_name, last_name, email, phone_number, role, email_verified, phone_verified, created_at, updated_at +FROM users ` -func (q *Queries) GetAllUsers(ctx context.Context) ([]User, error) { +type GetAllUsersRow struct { + ID int64 + FirstName string + LastName string + Email pgtype.Text + PhoneNumber pgtype.Text + Role string + EmailVerified bool + PhoneVerified bool + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz +} + +func (q *Queries) GetAllUsers(ctx context.Context) ([]GetAllUsersRow, error) { rows, err := q.db.Query(ctx, GetAllUsers) if err != nil { return nil, err } defer rows.Close() - var items []User + var items []GetAllUsersRow for rows.Next() { - var i User + var i GetAllUsersRow if err := rows.Scan( &i.ID, &i.FirstName, &i.LastName, &i.Email, &i.PhoneNumber, - &i.Password, &i.Role, - &i.Verified, + &i.EmailVerified, + &i.PhoneVerified, &i.CreatedAt, &i.UpdatedAt, ); err != nil { @@ -97,8 +151,47 @@ func (q *Queries) GetAllUsers(ctx context.Context) ([]User, error) { return items, nil } +const GetUserByEmail = `-- name: GetUserByEmail :one +SELECT id, first_name, last_name, email, phone_number, role, email_verified, phone_verified, created_at, updated_at +FROM users +WHERE email = $1 +` + +type GetUserByEmailRow struct { + ID int64 + FirstName string + LastName string + Email pgtype.Text + PhoneNumber pgtype.Text + Role string + EmailVerified bool + PhoneVerified bool + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz +} + +func (q *Queries) GetUserByEmail(ctx context.Context, email pgtype.Text) (GetUserByEmailRow, error) { + row := q.db.QueryRow(ctx, GetUserByEmail, email) + var i GetUserByEmailRow + err := row.Scan( + &i.ID, + &i.FirstName, + &i.LastName, + &i.Email, + &i.PhoneNumber, + &i.Role, + &i.EmailVerified, + &i.PhoneVerified, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + const GetUserByID = `-- name: GetUserByID :one -SELECT id, first_name, last_name, email, phone_number, password, role, verified, created_at, updated_at FROM users WHERE id = $1 +SELECT id, first_name, last_name, email, phone_number, role, password, email_verified, phone_verified, created_at, updated_at, suspended_at, suspended +FROM users +WHERE id = $1 ` func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) { @@ -110,40 +203,95 @@ func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) { &i.LastName, &i.Email, &i.PhoneNumber, - &i.Password, &i.Role, - &i.Verified, + &i.Password, + &i.EmailVerified, + &i.PhoneVerified, + &i.CreatedAt, + &i.UpdatedAt, + &i.SuspendedAt, + &i.Suspended, + ) + return i, err +} + +const GetUserByPhone = `-- name: GetUserByPhone :one +SELECT id, first_name, last_name, email, phone_number, role, email_verified, phone_verified, created_at, updated_at +FROM users +WHERE phone_number = $1 +` + +type GetUserByPhoneRow struct { + ID int64 + FirstName string + LastName string + Email pgtype.Text + PhoneNumber pgtype.Text + Role string + EmailVerified bool + PhoneVerified bool + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz +} + +func (q *Queries) GetUserByPhone(ctx context.Context, phoneNumber pgtype.Text) (GetUserByPhoneRow, error) { + row := q.db.QueryRow(ctx, GetUserByPhone, phoneNumber) + var i GetUserByPhoneRow + err := row.Scan( + &i.ID, + &i.FirstName, + &i.LastName, + &i.Email, + &i.PhoneNumber, + &i.Role, + &i.EmailVerified, + &i.PhoneVerified, &i.CreatedAt, &i.UpdatedAt, ) return i, err } +const UpdatePassword = `-- name: UpdatePassword :exec +UPDATE users +SET password = $1, updated_at = CURRENT_TIMESTAMP +WHERE (email = $2 OR phone_number = $3) +` + +type UpdatePasswordParams struct { + Password []byte + Email pgtype.Text + PhoneNumber pgtype.Text +} + +func (q *Queries) UpdatePassword(ctx context.Context, arg UpdatePasswordParams) error { + _, err := q.db.Exec(ctx, UpdatePassword, arg.Password, arg.Email, arg.PhoneNumber) + return err +} + const UpdateUser = `-- name: UpdateUser :exec -UPDATE users SET first_name = $2, last_name = $3, email = $4, phone_number = $5, password = $6, role = $7, verified = $8, updated_at = CURRENT_TIMESTAMP WHERE id = $1 +UPDATE users +SET first_name = $1, last_name = $2, email = $3, phone_number = $4, role = $5, updated_at = CURRENT_TIMESTAMP +WHERE id = $6 ` type UpdateUserParams struct { - ID int64 FirstName string LastName string - Email string - PhoneNumber string - Password []byte + Email pgtype.Text + PhoneNumber pgtype.Text Role string - Verified pgtype.Bool + ID int64 } func (q *Queries) UpdateUser(ctx context.Context, arg UpdateUserParams) error { _, err := q.db.Exec(ctx, UpdateUser, - arg.ID, arg.FirstName, arg.LastName, arg.Email, arg.PhoneNumber, - arg.Password, arg.Role, - arg.Verified, + arg.ID, ) return err } diff --git a/internal/domain/otp.go b/internal/domain/otp.go index cc3630f..a6904e4 100644 --- a/internal/domain/otp.go +++ b/internal/domain/otp.go @@ -1,6 +1,16 @@ package domain -import "time" +import ( + "errors" + "time" +) + +var ( + ErrOtpNotFound = errors.New("otp not found") + ErrOtpAlreadyUsed = errors.New("otp already used") + ErrInvalidOtp = errors.New("invalid otp") + ErrOtpExpired = errors.New("otp expired") +) type OtpFor string diff --git a/internal/domain/user.go b/internal/domain/user.go index ed38ae8..ea44cc8 100644 --- a/internal/domain/user.go +++ b/internal/domain/user.go @@ -1,6 +1,13 @@ package domain -import "time" +import ( + "errors" + "time" +) + +var ( + ErrUserNotFound = errors.New("user not found") +) type User struct { ID int64 diff --git a/internal/mocks/mock_email/email.go b/internal/mocks/mock_email/email.go new file mode 100644 index 0000000..18056a6 --- /dev/null +++ b/internal/mocks/mock_email/email.go @@ -0,0 +1,18 @@ +package mockemail + +import ( + "context" + "fmt" +) + +type MockEmail struct { +} + +func NewMockEmail() *MockEmail { + return &MockEmail{} +} + +func (m *MockEmail) SendEmailOTP(ctx context.Context, email string, otp string) error { + fmt.Println("MockEmail: Sending OTP to", email, "with OTP:", otp) + return nil +} diff --git a/internal/mocks/mock_sms/sms.go b/internal/mocks/mock_sms/sms.go new file mode 100644 index 0000000..150b6d3 --- /dev/null +++ b/internal/mocks/mock_sms/sms.go @@ -0,0 +1,19 @@ +package mocksms + +import ( + "context" + "fmt" +) + +type MockSMS struct { +} + +func NewMockSMS() *MockSMS { + return &MockSMS{} +} +func (m *MockSMS) SendSMSOTP(ctx context.Context, phoneNumber, otp string) error { + fmt.Println("MockSMS: Sending OTP to", phoneNumber, "with OTP:", otp) + return nil +} + +// func (m *MockSms){} diff --git a/internal/repository/auth.go b/internal/repository/auth.go index 9695fc9..99739e9 100644 --- a/internal/repository/auth.go +++ b/internal/repository/auth.go @@ -13,8 +13,14 @@ import ( func (s *Store) GetUserByEmailPhone(ctx context.Context, email, phone string) (domain.User, error) { user, err := s.queries.GetUserByEmailPhone(ctx, dbgen.GetUserByEmailPhoneParams{ - Email: email, - PhoneNumber: phone, + Email: pgtype.Text{ + String: email, + Valid: true, + }, + PhoneNumber: pgtype.Text{ + String: phone, + Valid: true, + }, }) if err != nil { if errors.Is(err, sql.ErrNoRows) { @@ -26,8 +32,8 @@ func (s *Store) GetUserByEmailPhone(ctx context.Context, email, phone string) (d ID: user.ID, FirstName: user.FirstName, LastName: user.LastName, - Email: user.Email, - PhoneNumber: user.PhoneNumber, + Email: user.Email.String, + PhoneNumber: user.PhoneNumber.String, Password: user.Password, Role: domain.Role(user.Role), }, nil diff --git a/internal/repository/otp.go b/internal/repository/otp.go new file mode 100644 index 0000000..598a5eb --- /dev/null +++ b/internal/repository/otp.go @@ -0,0 +1,50 @@ +package repository + +import ( + "context" + "database/sql" + + dbgen "github.com/SamuelTariku/FortuneBet-Backend/gen/db" + "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" + "github.com/jackc/pgx/v5/pgtype" +) + +func (s *Store) CreateOtp(ctx context.Context, otp domain.Otp) error { + return s.queries.CreateOtp(ctx, dbgen.CreateOtpParams{ + SentTo: otp.SentTo, + Medium: string(otp.Medium), + OtpFor: string(otp.For), + Otp: otp.Otp, + ExpiresAt: pgtype.Timestamptz{ + Time: otp.ExpiresAt, + Valid: true, + }, + }) +} +func (s *Store) GetOtp(ctx context.Context, sentTo string, sentfor domain.OtpFor, medium domain.OtpMedium) (domain.Otp, error) { + row, err := s.queries.GetOtp(ctx, dbgen.GetOtpParams{ + SentTo: sentTo, + Medium: string(medium), + OtpFor: string(sentfor), + }) + if err != nil { + if err == sql.ErrNoRows { + return domain.Otp{}, domain.ErrOtpNotFound + } + return domain.Otp{}, err + } + return domain.Otp{ + ID: row.ID, + SentTo: row.SentTo, + Medium: domain.OtpMedium(row.Medium), + For: domain.OtpFor(row.OtpFor), + Otp: row.Otp, + Used: row.Used, + UsedAt: row.UsedAt.Time, + CreatedAt: row.CreatedAt.Time, + ExpiresAt: row.ExpiresAt.Time, + }, nil +} +func (s *Store) MarkOtpAsUsed(ctx context.Context, otp domain.Otp) error { + return s.queries.MarkOtpAsUsed(ctx, otp.ID) +} diff --git a/internal/repository/user.go b/internal/repository/user.go index dbbc6ca..d2d9b78 100644 --- a/internal/repository/user.go +++ b/internal/repository/user.go @@ -2,47 +2,70 @@ package repository import ( "context" + "database/sql" + "errors" + "fmt" dbgen "github.com/SamuelTariku/FortuneBet-Backend/gen/db" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" + "github.com/jackc/pgx/v5/pgtype" ) -func (s *Store) CreateUser(ctx context.Context, firstName, lastName, email, phoneNumber, password, role string, verified bool) (domain.User, error) { - user, err := s.queries.CreateUser(ctx, dbgen.CreateUserParams{ - FirstName: firstName, - LastName: lastName, - Email: email, - PhoneNumber: phoneNumber, - // Password: password, - Role: role, +func (s *Store) CreateUser(ctx context.Context, user domain.User, usedOtpId int64) (domain.User, error) { + err := s.queries.MarkOtpAsUsed(ctx, usedOtpId) + if err != nil { + return domain.User{}, err + } + userRes, err := s.queries.CreateUser(ctx, dbgen.CreateUserParams{ + FirstName: user.FirstName, + LastName: user.LastName, + Email: pgtype.Text{ + String: user.Email, + Valid: user.Email != "", + }, + PhoneNumber: pgtype.Text{ + String: user.PhoneNumber, + Valid: user.PhoneNumber != "", + }, + Password: user.Password, + Role: string(user.Role), + EmailVerified: user.EmailVerified, + PhoneVerified: user.PhoneVerified, }) if err != nil { return domain.User{}, err } return domain.User{ - ID: user.ID, - FirstName: user.FirstName, - LastName: user.LastName, - Email: user.Email, - PhoneNumber: user.PhoneNumber, - Password: user.Password, - // Role: user.Role, + ID: userRes.ID, + FirstName: userRes.FirstName, + LastName: userRes.LastName, + Email: userRes.Email.String, + PhoneNumber: userRes.PhoneNumber.String, + Role: domain.Role(userRes.Role), }, nil - } func (s *Store) GetUserByID(ctx context.Context, id int64) (domain.User, error) { user, err := s.queries.GetUserByID(ctx, id) if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return domain.User{}, domain.ErrUserNotFound + } return domain.User{}, err } return domain.User{ - ID: user.ID, - FirstName: user.FirstName, - LastName: user.LastName, - Email: user.Email, - PhoneNumber: user.PhoneNumber, - Password: user.Password, - // Role: user.Role, + ID: user.ID, + FirstName: user.FirstName, + LastName: user.LastName, + Email: user.Email.String, + PhoneNumber: user.PhoneNumber.String, + Role: domain.Role(user.Role), + EmailVerified: user.EmailVerified, + Password: user.Password, + PhoneVerified: user.PhoneVerified, + CreatedAt: user.CreatedAt.Time, + UpdatedAt: user.UpdatedAt.Time, + SuspendedAt: user.SuspendedAt.Time, + Suspended: user.Suspended, }, nil } func (s *Store) GetAllUsers(ctx context.Context) ([]domain.User, error) { @@ -50,32 +73,118 @@ func (s *Store) GetAllUsers(ctx context.Context) ([]domain.User, error) { if err != nil { return nil, err } - var result []domain.User - for _, user := range users { - result = append(result, domain.User{ + userList := make([]domain.User, len(users)) + for i, user := range users { + userList[i] = domain.User{ ID: user.ID, FirstName: user.FirstName, LastName: user.LastName, - Email: user.Email, - PhoneNumber: user.PhoneNumber, - Password: user.Password, - // Role: user.Role, - }) + Email: user.Email.String, + PhoneNumber: user.PhoneNumber.String, + Role: domain.Role(user.Role), + } } - return result, nil + return userList, nil } -func (s *Store) UpdateUser(ctx context.Context, id int64, firstName, lastName, email, phoneNumber, password, role string, verified bool) error { +func (s *Store) UpdateUser(ctx context.Context, user domain.UpdateUserReq) error { err := s.queries.UpdateUser(ctx, dbgen.UpdateUserParams{ - ID: id, - FirstName: firstName, - LastName: lastName, - Email: email, - PhoneNumber: phoneNumber, - // Password: password, - Role: role, + // ID: user.ID, + // FirstName: user.FirstName, + // LastName: user.LastName, + // Email: user.Email, + // PhoneNumber: user.PhoneNumber, }) - return err + if err != nil { + return err + } + return nil } func (s *Store) DeleteUser(ctx context.Context, id int64) error { - return s.queries.DeleteUser(ctx, id) + err := s.queries.DeleteUser(ctx, id) + if err != nil { + return err + } + return nil +} +func (s *Store) CheckPhoneEmailExist(ctx context.Context, phoneNum, email string) (bool, bool, error) { + fmt.Printf("phoneNum: %s, email: %s\n", phoneNum, email) + row, err := s.queries.CheckPhoneEmailExist(ctx, dbgen.CheckPhoneEmailExistParams{ + PhoneNumber: pgtype.Text{ + String: phoneNum, + Valid: phoneNum != "", + }, + Email: pgtype.Text{ + String: email, + + Valid: email != "", + }, + }) + fmt.Printf("row: %+v\n", row) + if err != nil { + return false, false, err + } + return row.EmailExists, row.PhoneExists, nil +} + +func (s *Store) GetUserByEmail(ctx context.Context, email string) (domain.User, error) { + user, err := s.queries.GetUserByEmail(ctx, pgtype.Text{ + String: email, + Valid: true, + }) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return domain.User{}, domain.ErrUserNotFound + } + return domain.User{}, err + } + return domain.User{ + ID: user.ID, + FirstName: user.FirstName, + LastName: user.LastName, + Email: user.Email.String, + PhoneNumber: user.PhoneNumber.String, + Role: domain.Role(user.Role), + }, nil +} +func (s *Store) GetUserByPhone(ctx context.Context, phoneNum string) (domain.User, error) { + user, err := s.queries.GetUserByPhone(ctx, pgtype.Text{ + String: phoneNum, + Valid: true, + }) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return domain.User{}, domain.ErrUserNotFound + } + return domain.User{}, err + } + return domain.User{ + ID: user.ID, + FirstName: user.FirstName, + LastName: user.LastName, + Email: user.Email.String, + PhoneNumber: user.PhoneNumber.String, + Role: domain.Role(user.Role), + }, nil +} + +func (s *Store) UpdatePassword(ctx context.Context, identifier string, password []byte, usedOtpId int64) error { + err := s.queries.MarkOtpAsUsed(ctx, usedOtpId) + if err != nil { + return err + } + err = s.queries.UpdatePassword(ctx, dbgen.UpdatePasswordParams{ + Password: password, + Email: pgtype.Text{ + String: identifier, + Valid: true, + }, + PhoneNumber: pgtype.Text{ + String: identifier, + Valid: true, + }, + }) + if err != nil { + return err + } + return nil } diff --git a/internal/services/user/common.go b/internal/services/user/common.go new file mode 100644 index 0000000..9adf8e4 --- /dev/null +++ b/internal/services/user/common.go @@ -0,0 +1,44 @@ +package user + +import ( + "context" + "time" + + "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" + "golang.org/x/crypto/bcrypt" +) + +func (s *Service) SendOtp(ctx context.Context, sentTo string, otpFor domain.OtpFor, medium domain.OtpMedium) error { + otpCode := "123456" // Generate OTP code + + otp := domain.Otp{ + SentTo: sentTo, + Medium: medium, + For: otpFor, + Otp: otpCode, + Used: false, + CreatedAt: time.Now(), + ExpiresAt: time.Now().Add(OtpExpiry), + } + + err := s.otpStore.CreateOtp(ctx, otp) + if err != nil { + return err + } + + switch medium { + case domain.OtpMediumSms: + return s.smsGateway.SendSMSOTP(ctx, sentTo, otpCode) + case domain.OtpMediumEmail: + return s.emailGateway.SendEmailOTP(ctx, sentTo, otpCode) + } + return nil +} +func hashPassword(plaintextPassword string) ([]byte, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(plaintextPassword), 12) + if err != nil { + return []byte{}, err + } + + return hash, nil +} diff --git a/internal/services/user/port.go b/internal/services/user/port.go index 8773525..aaf502d 100644 --- a/internal/services/user/port.go +++ b/internal/services/user/port.go @@ -27,5 +27,4 @@ type EmailGateway interface { type OtpStore interface { CreateOtp(ctx context.Context, otp domain.Otp) error GetOtp(ctx context.Context, sentTo string, sentfor domain.OtpFor, medium domain.OtpMedium) (domain.Otp, error) - MarkUsed(ctx context.Context, id int64) error } diff --git a/internal/services/user/register.go b/internal/services/user/register.go new file mode 100644 index 0000000..f6dcf71 --- /dev/null +++ b/internal/services/user/register.go @@ -0,0 +1,79 @@ +package user + +import ( + "context" + "fmt" + "time" + + "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" +) + +func (s *Service) CheckPhoneEmailExist(ctx context.Context, phoneNum, email string) (bool, bool, error) { // email,phone,error + return s.userStore.CheckPhoneEmailExist(ctx, phoneNum, email) +} +func (s *Service) SendRegisterCode(ctx context.Context, medium domain.OtpMedium, sentTo string) error { + var err error + // check if user exists + switch medium { + case domain.OtpMediumEmail: + _, err = s.userStore.GetUserByEmail(ctx, sentTo) + case domain.OtpMediumSms: + _, err = s.userStore.GetUserByPhone(ctx, sentTo) + } + + if err != nil && err != domain.ErrUserNotFound { + + return err + } + + // send otp based on the medium + return s.SendOtp(ctx, sentTo, domain.OtpRegister, medium) +} +func (s *Service) RegisterUser(ctx context.Context, registerReq domain.RegisterUserReq) (domain.User, error) { // normal + // get otp + fmt.Printf("registerReq: %+v\n", registerReq) + var sentTo string + if registerReq.OtpMedium == domain.OtpMediumEmail { + sentTo = registerReq.Email + } else { + sentTo = registerReq.PhoneNumber + } + // + otp, err := s.otpStore.GetOtp( + ctx, sentTo, + domain.OtpRegister, registerReq.OtpMedium) + if err != nil { + return domain.User{}, err + } + // verify otp + if otp.Used { + return domain.User{}, domain.ErrOtpAlreadyUsed + } + if time.Now().After(otp.ExpiresAt) { + return domain.User{}, domain.ErrOtpExpired + } + if otp.Otp != registerReq.Otp { + return domain.User{}, domain.ErrInvalidOtp + } + + hashedPassword, err := hashPassword(registerReq.Password) + if err != nil { + return domain.User{}, err + } + userR := domain.User{ + FirstName: registerReq.FirstName, + LastName: registerReq.LastName, + Email: registerReq.Email, + PhoneNumber: registerReq.PhoneNumber, + Password: hashedPassword, + Role: "user", + EmailVerified: registerReq.OtpMedium == domain.OtpMediumEmail, + PhoneVerified: registerReq.OtpMedium == domain.OtpMediumSms, + } + // create the user and mark otp as used + user, err := s.userStore.CreateUser(ctx, userR, otp.ID) + if err != nil { + return domain.User{}, err + } + return user, nil +} diff --git a/internal/services/user/reset.go b/internal/services/user/reset.go new file mode 100644 index 0000000..70309a8 --- /dev/null +++ b/internal/services/user/reset.go @@ -0,0 +1,63 @@ +package user + +import ( + "context" + "time" + + "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" +) + +func (s *Service) SendResetCode(ctx context.Context, medium domain.OtpMedium, sentTo string) error { + + var err error + // check if user exists + switch medium { + case domain.OtpMediumEmail: + _, err = s.userStore.GetUserByEmail(ctx, sentTo) + case domain.OtpMediumSms: + _, err = s.userStore.GetUserByPhone(ctx, sentTo) + } + + if err != nil { + return err + } + + return s.SendOtp(ctx, sentTo, domain.OtpReset, medium) + +} + +func (s *Service) ResetPassword(ctx context.Context, resetReq domain.ResetPasswordReq) error { + var sentTo string + if resetReq.OtpMedium == domain.OtpMediumEmail { + sentTo = resetReq.Email + } else { + sentTo = resetReq.PhoneNumber + } + otp, err := s.otpStore.GetOtp( + ctx, sentTo, + domain.OtpReset, resetReq.OtpMedium) + if err != nil { + return err + } + // + if otp.Used { + return domain.ErrOtpAlreadyUsed + } + if time.Now().After(otp.ExpiresAt) { + return domain.ErrOtpExpired + } + if otp.Otp != resetReq.Otp { + return domain.ErrInvalidOtp + } + // hash password + hashedPassword, err := hashPassword(resetReq.Password) + if err != nil { + return err + } + // reset pass and mark otp as used + err = s.userStore.UpdatePassword(ctx, sentTo, hashedPassword, otp.ID) + if err != nil { + return err + } + return nil +} diff --git a/internal/services/user/service.go b/internal/services/user/service.go index 2172211..cfa93fd 100644 --- a/internal/services/user/service.go +++ b/internal/services/user/service.go @@ -1,24 +1,13 @@ package user import ( - "context" - "errors" "time" - - "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" - "golang.org/x/crypto/bcrypt" ) const ( OtpExpiry = 5 * time.Minute ) -var ( - ErrOtpAlreadyUsed = errors.New("otp already used") - ErrInvalidOtp = errors.New("invalid otp") - ErrOtpExpired = errors.New("otp expired") -) - type Service struct { userStore UserStore otpStore OtpStore @@ -27,179 +16,14 @@ type Service struct { } func NewService( - userStore UserStore, RefreshExpiry int, + userStore UserStore, otpStore OtpStore, smsGateway SmsGateway, emailGateway EmailGateway, ) *Service { return &Service{ - userStore: userStore, - otpStore: otpStore, + userStore: userStore, + otpStore: otpStore, + smsGateway: smsGateway, + emailGateway: emailGateway, } } - -func (s *Service) CheckPhoneEmailExist(ctx context.Context, phoneNum, email string) (bool, bool, error) { // email,phone,error - return s.userStore.CheckPhoneEmailExist(ctx, phoneNum, email) -} -func (s *Service) SendRegisterCode(ctx context.Context, medium domain.OtpMedium, sentTo string) error { - var err error - // check if user exists - switch medium { - case domain.OtpMediumEmail: - _, err = s.userStore.GetUserByEmail(ctx, sentTo) - case domain.OtpMediumSms: - _, err = s.userStore.GetUserByPhone(ctx, sentTo) - } - - if err != nil { - return err - } - - // send otp based on the medium - return s.SendOtp(ctx, sentTo, domain.OtpReset, medium) -} -func (s *Service) RegisterUser(ctx context.Context, registerReq domain.RegisterUserReq) (domain.User, error) { // normal - // get otp - var sentTo string - if registerReq.OtpMedium == domain.OtpMediumEmail { - sentTo = registerReq.Email - } else { - sentTo = registerReq.PhoneNumber - } - // - otp, err := s.otpStore.GetOtp( - ctx, sentTo, - domain.OtpRegister, registerReq.OtpMedium) - if err != nil { - return domain.User{}, err - } - // verify otp - if otp.Used { - return domain.User{}, ErrOtpAlreadyUsed - } - if time.Now().After(otp.ExpiresAt) { - return domain.User{}, ErrOtpExpired - } - if otp.Otp != registerReq.Otp { - return domain.User{}, ErrInvalidOtp - } - - hashedPassword, err := hashPassword(registerReq.Password) - if err != nil { - return domain.User{}, err - } - userR := domain.User{ - FirstName: registerReq.FirstName, - LastName: registerReq.LastName, - Email: registerReq.Email, - PhoneNumber: registerReq.PhoneNumber, - Password: hashedPassword, - Role: "user", - EmailVerified: registerReq.OtpMedium == domain.OtpMediumEmail, - PhoneVerified: registerReq.OtpMedium == domain.OtpMediumSms, - } - // create the user and mark otp as used - user, err := s.userStore.CreateUser(ctx, userR, otp.ID) - if err != nil { - return domain.User{}, err - } - return user, nil -} - -func (s *Service) SendResetCode(ctx context.Context, medium domain.OtpMedium, sentTo string) error { - - var err error - // check if user exists - switch medium { - case domain.OtpMediumEmail: - _, err = s.userStore.GetUserByEmail(ctx, sentTo) - case domain.OtpMediumSms: - _, err = s.userStore.GetUserByPhone(ctx, sentTo) - } - - if err != nil { - return err - } - - return s.SendOtp(ctx, sentTo, domain.OtpReset, medium) - -} - -func (s *Service) ResetPassword(ctx context.Context, resetReq domain.ResetPasswordReq) error { - var sentTo string - if resetReq.OtpMedium == domain.OtpMediumEmail { - sentTo = resetReq.Email - } else { - sentTo = resetReq.PhoneNumber - } - otp, err := s.otpStore.GetOtp( - ctx, sentTo, - domain.OtpRegister, resetReq.OtpMedium) - if err != nil { - return err - } - // - if otp.Used { - return ErrOtpAlreadyUsed - } - if time.Now().After(otp.ExpiresAt) { - return ErrOtpExpired - } - if otp.Otp != resetReq.Otp { - return ErrInvalidOtp - } - // hash password - hashedPassword, err := hashPassword(resetReq.Password) - if err != nil { - return err - } - // reset pass and mark otp as used - err = s.userStore.UpdatePassword(ctx, sentTo, hashedPassword, otp.ID) - if err != nil { - return err - } - return nil -} -func (s *Service) SendOtp(ctx context.Context, sentTo string, otpFor domain.OtpFor, medium domain.OtpMedium) error { - otpCode := "123456" // Generate OTP code - - otp := domain.Otp{ - SentTo: sentTo, - Medium: medium, - For: otpFor, - Otp: otpCode, - Used: false, - CreatedAt: time.Now(), - ExpiresAt: time.Now().Add(OtpExpiry), - } - - err := s.otpStore.CreateOtp(ctx, otp) - if err != nil { - return err - } - - switch medium { - case domain.OtpMediumSms: - return s.smsGateway.SendSMSOTP(ctx, sentTo, otpCode) - case domain.OtpMediumEmail: - return s.emailGateway.SendEmailOTP(ctx, sentTo, otpCode) - } - return nil -} - -func (s *Service) UpdateUser(ctx context.Context, user domain.UpdateUserReq) error { - // update user - return s.userStore.UpdateUser(ctx, user) - -} -func (s *Service) GetUserByID(ctx context.Context, id int64) (domain.User, error) { - return s.userStore.GetUserByID(ctx, id) -} - -func hashPassword(plaintextPassword string) ([]byte, error) { - hash, err := bcrypt.GenerateFromPassword([]byte(plaintextPassword), 12) - if err != nil { - return []byte{}, err - } - - return hash, nil -} diff --git a/internal/services/user/user.go b/internal/services/user/user.go new file mode 100644 index 0000000..5b65b94 --- /dev/null +++ b/internal/services/user/user.go @@ -0,0 +1,16 @@ +package user + +import ( + "context" + + "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" +) + +func (s *Service) UpdateUser(ctx context.Context, user domain.UpdateUserReq) error { + // update user + return s.userStore.UpdateUser(ctx, user) + +} +func (s *Service) GetUserByID(ctx context.Context, id int64) (domain.User, error) { + return s.userStore.GetUserByID(ctx, id) +} diff --git a/internal/web_server/app.go b/internal/web_server/app.go index 811b973..2ebd22e 100644 --- a/internal/web_server/app.go +++ b/internal/web_server/app.go @@ -5,6 +5,7 @@ import ( "log/slog" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/authentication" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/user" jwtutil "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/jwt" customvalidator "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/validator" "github.com/bytedance/sonic" @@ -16,6 +17,7 @@ type App struct { logger *slog.Logger port int authSvc *authentication.Service + userSvc *user.Service validator *customvalidator.CustomValidator JwtConfig jwtutil.JwtConfig } @@ -25,6 +27,7 @@ func NewApp( authSvc *authentication.Service, logger *slog.Logger, JwtConfig jwtutil.JwtConfig, + userSvc *user.Service, ) *App { app := fiber.New(fiber.Config{ CaseSensitive: true, @@ -39,6 +42,7 @@ func NewApp( validator: validator, logger: logger, JwtConfig: JwtConfig, + userSvc: userSvc, } s.initAppRoutes() diff --git a/internal/web_server/handlers/auth_handler.go b/internal/web_server/handlers/auth_handler.go index 551b259..0022827 100644 --- a/internal/web_server/handlers/auth_handler.go +++ b/internal/web_server/handlers/auth_handler.go @@ -3,7 +3,6 @@ package handlers import ( "errors" "log/slog" - "strconv" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/authentication" jwtutil "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/jwt" @@ -66,7 +65,7 @@ func LoginCustomer( return nil } - accessToken, err := jwtutil.CreateJwt(strconv.Itoa(int(successRes.UserId)), successRes.Role, JwtConfig.JwtAccessKey, JwtConfig.JwtAccessExpiry) + accessToken, err := jwtutil.CreateJwt(successRes.UserId, successRes.Role, JwtConfig.JwtAccessKey, JwtConfig.JwtAccessExpiry) res := loginCustomerRes{ AccessToken: accessToken, RefreshToken: successRes.RfToken, @@ -119,7 +118,7 @@ func RefreshToken(logger *slog.Logger, authSvc *authentication.Service, response.WriteJSON(c, fiber.StatusInternalServerError, "Internal server error", nil, nil) return nil } - accessToken, err := jwtutil.CreateJwt("", "", JwtConfig.JwtAccessKey, JwtConfig.JwtAccessExpiry) + accessToken, err := jwtutil.CreateJwt(0, "", JwtConfig.JwtAccessKey, JwtConfig.JwtAccessExpiry) if err != nil { logger.Error("Create jwt failed", "error", err) response.WriteJSON(c, fiber.StatusInternalServerError, "Internal server error", nil, nil) diff --git a/internal/web_server/handlers/user.go b/internal/web_server/handlers/user.go new file mode 100644 index 0000000..139eb09 --- /dev/null +++ b/internal/web_server/handlers/user.go @@ -0,0 +1,365 @@ +package handlers + +import ( + "errors" + "log/slog" + "time" + + "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/user" + "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/response" + customvalidator "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/validator" + + "github.com/gofiber/fiber/v2" +) + +type CheckPhoneEmailExistReq struct { + Email string `json:"email" example:"john.doe@example.com"` + PhoneNumber string `json:"phone_number" example:"1234567890"` +} +type CheckPhoneEmailExistRes struct { + EmailExist bool `json:"email_exist"` + PhoneNumberExist bool `json:"phone_number_exist"` +} + +// CheckPhoneEmailExist godoc +// @Summary Check if phone number or email exist +// @Description Check if phone number or email exist +// @Tags user +// @Accept json +// @Produce json +// @Param checkPhoneEmailExist body CheckPhoneEmailExistReq true "Check phone number or email exist" +// @Success 200 {object} CheckPhoneEmailExistRes +// @Failure 400 {object} response.APIResponse +// @Failure 500 {object} response.APIResponse +// @Router /user/checkPhoneEmailExist [post] +func CheckPhoneEmailExist(logger *slog.Logger, userSvc *user.Service, + validator *customvalidator.CustomValidator) fiber.Handler { + return func(c *fiber.Ctx) error { + var req CheckPhoneEmailExistReq + if err := c.BodyParser(&req); err != nil { + logger.Error("CheckPhoneEmailExist failed", "error", err) + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "Invalid request", + }) + } + valErrs, ok := validator.Validate(c, req) + if !ok { + response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", valErrs, nil) + return nil + } + emailExist, phoneExist, err := userSvc.CheckPhoneEmailExist(c.Context(), req.PhoneNumber, req.Email) + if err != nil { + logger.Error("CheckPhoneEmailExist failed", "error", err) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "Internal server error", + }) + } + res := CheckPhoneEmailExistRes{ + EmailExist: emailExist, + PhoneNumberExist: phoneExist, + } + return response.WriteJSON(c, fiber.StatusOK, "Check Success", res, nil) + } +} + +type RegisterCodeReq struct { + Email string `json:"email" example:"john.doe@example.com"` + PhoneNumber string `json:"phone_number" example:"1234567890"` +} + +// SendRegisterCode godoc +// @Summary Send register code +// @Description Send register code +// @Tags user +// @Accept json +// @Produce json +// @Param registerCode body RegisterCodeReq true "Send register code" +// @Success 200 {object} response.APIResponse +// @Failure 400 {object} response.APIResponse +// @Failure 500 {object} response.APIResponse +// @Router /user/sendRegisterCode [post] +func SendRegisterCode(logger *slog.Logger, userSvc *user.Service, + validator *customvalidator.CustomValidator) fiber.Handler { + return func(c *fiber.Ctx) error { + var req RegisterCodeReq + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "Invalid request", + }) + } + valErrs, ok := validator.Validate(c, req) + if !ok { + response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", valErrs, nil) + return nil + } + var sentTo string + var medium domain.OtpMedium + if req.Email != "" { + sentTo = req.Email + medium = domain.OtpMediumEmail + } + if req.PhoneNumber != "" { + sentTo = req.PhoneNumber + medium = domain.OtpMediumSms + } + if err := userSvc.SendRegisterCode(c.Context(), medium, sentTo); err != nil { + logger.Error("SendRegisterCode failed", "error", err) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "Internal server error", + }) + } + return response.WriteJSON(c, fiber.StatusOK, "Code sent successfully", nil, nil) + } +} + +type RegisterUserReq struct { + FirstName string `json:"first_name" example:"John"` + LastName string `json:"last_name" example:"Doe"` + Email string `json:"email" example:"john.doe@example.com"` + PhoneNumber string `json:"phone_number" example:"1234567890"` + Password string `json:"password" example:"password123"` + //Role string + Otp string `json:"otp" example:"123456"` + ReferalCode string `json:"referal_code" example:"ABC123"` + // + +} + +// RegisterUser godoc +// @Summary Register user +// @Description Register user +// @Tags user +// @Accept json +// @Produce json +// @Param registerUser body RegisterUserReq true "Register user" +// @Success 200 {object} response.APIResponse +// @Failure 400 {object} response.APIResponse +// @Failure 500 {object} response.APIResponse +// @Router /user/register [post] +func RegisterUser(logger *slog.Logger, userSvc *user.Service, + validator *customvalidator.CustomValidator) fiber.Handler { + return func(c *fiber.Ctx) error { + var req RegisterUserReq + if err := c.BodyParser(&req); err != nil { + logger.Error("RegisterUser failed", "error", err) + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "Invalid request", + }) + } + valErrs, ok := validator.Validate(c, req) + if !ok { + response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", valErrs, nil) + return nil + } + user := domain.RegisterUserReq{ + FirstName: req.FirstName, + LastName: req.LastName, + Email: req.Email, + PhoneNumber: req.PhoneNumber, + Password: req.Password, + Otp: req.Otp, + ReferalCode: req.ReferalCode, + OtpMedium: domain.OtpMediumEmail, + } + medium, err := getMedium(req.Email, req.PhoneNumber) + if err != nil { + logger.Error("RegisterUser failed", "error", err) + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": err.Error(), + }) + } + user.OtpMedium = medium + if _, err := userSvc.RegisterUser(c.Context(), user); err != nil { + if errors.Is(err, domain.ErrOtpAlreadyUsed) { + return response.WriteJSON(c, fiber.StatusBadRequest, "Otp already used", nil, nil) + } + if errors.Is(err, domain.ErrOtpExpired) { + return response.WriteJSON(c, fiber.StatusBadRequest, "Otp expired", nil, nil) + } + if errors.Is(err, domain.ErrInvalidOtp) { + return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid otp", nil, nil) + } + if errors.Is(err, domain.ErrOtpNotFound) { + return response.WriteJSON(c, fiber.StatusBadRequest, "User already exist", nil, nil) + } + logger.Error("RegisterUser failed", "error", err) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "Internal server error", + }) + } + return response.WriteJSON(c, fiber.StatusOK, "Registration successful", nil, nil) + } +} + +type ResetCodeReq struct { + Email string `json:"email" example:"john.doe@example.com"` + PhoneNumber string `json:"phone_number" example:"1234567890"` +} + +// SendResetCode godoc +// @Summary Send reset code +// @Description Send reset code +// @Tags user +// @Accept json +// @Produce json +// @Param resetCode body ResetCodeReq true "Send reset code" +// @Success 200 {object} response.APIResponse +// @Failure 400 {object} response.APIResponse +// @Failure 500 {object} response.APIResponse +// @Router /user/sendResetCode [post] +func SendResetCode(logger *slog.Logger, userSvc *user.Service, + validator *customvalidator.CustomValidator) fiber.Handler { + return func(c *fiber.Ctx) error { + var req ResetCodeReq + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "Invalid request", + }) + } + valErrs, ok := validator.Validate(c, req) + if !ok { + response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", valErrs, nil) + return nil + } + var sentTo string + var medium domain.OtpMedium + if req.Email != "" { + sentTo = req.Email + medium = domain.OtpMediumEmail + } + if req.PhoneNumber != "" { + sentTo = req.PhoneNumber + medium = domain.OtpMediumSms + } + if err := userSvc.SendResetCode(c.Context(), medium, sentTo); err != nil { + logger.Error("SendResetCode failed", "error", err) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "Internal server error", + }) + } + return response.WriteJSON(c, fiber.StatusOK, "Code sent successfully", nil, nil) + + } +} + +type ResetPasswordReq struct { + Email string + PhoneNumber string + Password string + Otp string +} + +// ResetPassword godoc +// @Summary Reset password +// @Description Reset password +// @Tags user +// @Accept json +// @Produce json +// @Param resetPassword body ResetPasswordReq true "Reset password" +// @Success 200 {object} response.APIResponse +// @Failure 400 {object} response.APIResponse +// @Failure 500 {object} response.APIResponse +// @Router /user/resetPassword [post] +func ResetPassword(logger *slog.Logger, userSvc *user.Service, + validator *customvalidator.CustomValidator) fiber.Handler { + return func(c *fiber.Ctx) error { + var req ResetPasswordReq + if err := c.BodyParser(&req); err != nil { + logger.Error("ResetPassword failed", "error", err) + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "Invalid request", + }) + } + valErrs, ok := validator.Validate(c, req) + if !ok { + response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", valErrs, nil) + return nil + } + user := domain.ResetPasswordReq{ + Email: req.Email, + PhoneNumber: req.PhoneNumber, + Password: req.Password, + Otp: req.Otp, + } + medium, err := getMedium(req.Email, req.PhoneNumber) + if err != nil { + logger.Error("ResetPassword failed", "error", err) + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": err.Error(), + }) + } + user.OtpMedium = medium + if err := userSvc.ResetPassword(c.Context(), user); err != nil { + logger.Error("ResetPassword failed", "error", err) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "Internal server error", + }) + } + return response.WriteJSON(c, fiber.StatusOK, "Password reset successful", nil, nil) + } +} + +type UserProfileRes struct { + ID int64 `json:"id"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Email string `json:"email"` + PhoneNumber string `json:"phone_number"` + Role domain.Role `json:"role"` + EmailVerified bool `json:"email_verified"` + PhoneVerified bool `json:"phone_verified"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + SuspendedAt time.Time `json:"suspended_at"` + Suspended bool `json:"suspended"` +} + +// UserProfile godoc +// @Summary Get user profile +// @Description Get user profile +// @Tags user +// @Accept json +// @Produce json +// @Success 200 {object} UserProfileRes +// @Failure 400 {object} response.APIResponse +// @Failure 500 {object} response.APIResponse +// @Security Bearer +// @Router /user/profile [get] +func UserProfile(logger *slog.Logger, userSvc *user.Service) fiber.Handler { + return func(c *fiber.Ctx) error { + userId := c.Locals("user_id").(int64) + user, err := userSvc.GetUserByID(c.Context(), userId) + if err != nil { + logger.Error("GetUserProfile failed", "error", err) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "Internal server error", + }) + } + + res := UserProfileRes{ + ID: user.ID, + FirstName: user.FirstName, + LastName: user.LastName, + Email: user.Email, + PhoneNumber: user.PhoneNumber, + Role: user.Role, + EmailVerified: user.EmailVerified, + PhoneVerified: user.PhoneVerified, + CreatedAt: user.CreatedAt, + UpdatedAt: user.UpdatedAt, + SuspendedAt: user.SuspendedAt, + Suspended: user.Suspended, + } + return response.WriteJSON(c, fiber.StatusOK, "User profile retrieved successfully", res, nil) + } +} +func getMedium(email, phoneNumber string) (domain.OtpMedium, error) { + if email != "" { + return domain.OtpMediumEmail, nil + } + if phoneNumber != "" { + return domain.OtpMediumSms, nil + } + return "", errors.New("both email and phone number are empty") +} diff --git a/internal/web_server/jwt/jwt.go b/internal/web_server/jwt/jwt.go index a59e81f..530eb12 100644 --- a/internal/web_server/jwt/jwt.go +++ b/internal/web_server/jwt/jwt.go @@ -20,7 +20,7 @@ var ( type UserClaim struct { jwt.RegisteredClaims - UserId string + UserId int64 Role domain.Role } type JwtConfig struct { @@ -28,7 +28,7 @@ type JwtConfig struct { JwtAccessExpiry int } -func CreateJwt(userId string, Role domain.Role, key string, expiry int) (string, error) { +func CreateJwt(userId int64, Role domain.Role, key string, expiry int) (string, error) { token := jwt.NewWithClaims(jwt.SigningMethodHS256, UserClaim{RegisteredClaims: jwt.RegisteredClaims{Issuer: "github.com/lafetz/snippitstash", IssuedAt: jwt.NewNumericDate(time.Now()), Audience: jwt.ClaimStrings{"fortune.com"}, diff --git a/internal/web_server/middleware.go b/internal/web_server/middleware.go index e80689f..4f337fb 100644 --- a/internal/web_server/middleware.go +++ b/internal/web_server/middleware.go @@ -34,7 +34,7 @@ func (a *App) authMiddleware(c *fiber.Ctx) error { // refreshToken = c.Cookies("refresh_token", "") - return fiber.NewError(fiber.StatusUnauthorized, "Refresh token missing") + // return fiber.NewError(fiber.StatusUnauthorized, "Refresh token missing") } c.Locals("user_id", claim.UserId) c.Locals("role", claim.Role) diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index 4af9781..c30622d 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -20,5 +20,19 @@ func (a *App) initAppRoutes() { a.logger.Info("Refresh Token: " + refreshToken.(string)) return c.SendString("Test endpoint") }) + a.fiber.Post("/user/resetPassword", handlers.ResetPassword(a.logger, a.userSvc, a.validator)) + a.fiber.Post("/user/sendResetCode", handlers.SendResetCode(a.logger, a.userSvc, a.validator)) + a.fiber.Post("/user/register", handlers.RegisterUser(a.logger, a.userSvc, a.validator)) + a.fiber.Post("/user/sendRegisterCode", handlers.SendRegisterCode(a.logger, a.userSvc, a.validator)) + a.fiber.Post("/user/checkPhoneEmailExist", handlers.CheckPhoneEmailExist(a.logger, a.userSvc, a.validator)) + a.fiber.Get("/user/profile", a.authMiddleware, handlers.UserProfile(a.logger, a.userSvc)) + // Swagger a.fiber.Get("/swagger/*", fiberSwagger.WrapHandler) } + +///user/profile get +// @Router /user/resetPassword [post] +// @Router /user/sendResetCode [post] +// @Router /user/register [post] +// @Router /user/sendRegisterCode [post] +// @Router /user/checkPhoneEmailExist [post]