impl service layer for auth

This commit is contained in:
lafetz 2025-03-28 01:30:55 +03:00
parent 63b443171d
commit e8f0e43836
16 changed files with 372 additions and 12 deletions

View File

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

View File

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

View File

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

10
go.mod
View File

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

11
go.sum
View File

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

View File

@ -1 +1,12 @@
package domain
import "time"
type RefreshToken struct {
ID int64
UserID int64
Token string
ExpiresAt time.Time
CreatedAt time.Time
Revoked bool
}

View File

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

View File

@ -13,7 +13,7 @@ func (s *Store) CreateUser(ctx context.Context, firstName, lastName, email, phon
LastName: lastName,
Email: email,
PhoneNumber: phoneNumber,
Password: password,
// Password: password,
Role: role,
})
if err != nil {
@ -71,7 +71,7 @@ func (s *Store) UpdateUser(ctx context.Context, id int64, firstName, lastName, e
LastName: lastName,
Email: email,
PhoneNumber: phoneNumber,
Password: password,
// Password: password,
Role: role,
})
return err

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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