From 2da912739b42eeee89ef8d74135f9504406632e7 Mon Sep 17 00:00:00 2001 From: AmirMahdi Qiasvand Date: Sun, 14 Sep 2025 16:17:29 +0330 Subject: [PATCH] feat: add kyc layer service --- internal/app/app.go | 5 +- internal/domain/user.go | 109 ++++++++++++---------- internal/repository/storage/types/user.go | 4 +- internal/usecase/auth_service.go | 53 +++++++++-- pkg/jwt/claims.go | 1 + pkg/zohal/zohal.go | 46 ++++++++- 6 files changed, 159 insertions(+), 59 deletions(-) diff --git a/internal/app/app.go b/internal/app/app.go index 0e5fa2a..5922deb 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -8,6 +8,7 @@ import ( "backend/internal/usecase" cachePackage "backend/pkg/cache" "backend/pkg/postgres" + "backend/pkg/zohal" "fmt" "log" @@ -43,13 +44,15 @@ func NewAppContainer(cfg config.Config) (*AppContainer, error) { userRepo := storage.NewUserRepository(dbConn) sessionRepo := cache.NewSessionRepository(redis) challengeRepo := cache.NewChallengeRepository(redis) + zohal := zohal.NewZohal(cfg.KYCProvider) authService := usecase.NewAuthService( userRepo, sessionRepo, challengeRepo, 30, - cfg.JWT, + zohal, + cfg, ) return &AppContainer{ diff --git a/internal/domain/user.go b/internal/domain/user.go index 398a12d..80bce3a 100644 --- a/internal/domain/user.go +++ b/internal/domain/user.go @@ -39,12 +39,19 @@ type ChallengeRepo interface { Delete(ctx context.Context, pubKey string) error } +type KYCLevel int8 + +const ( + KYCLevel0 KYCLevel = iota + KYCLevel1 + KYCLevel2 +) + type User struct { - ID uuid.UUID - PubKey string - Name string - //TODO: add Kyc Embedded struct - //TODO: add NFT Embedded struct + ID uuid.UUID + PubKey string + Name string + KYCLevel KYCLevel LastName string PhoneNumber string Email *string @@ -56,7 +63,54 @@ type User struct { DeletedAt *time.Time } -// TODO: move to another file? +func NewUser(pubKey, name, lastName, phoneNumber, email, nationalID string) (*User, error) { + + _, err := validate.IsValidPhone(phoneNumber) + if err != nil { + return nil, err + } + + _, err = validate.IsValidNationalID(nationalID) + if err != nil { + return nil, err + } + + _, err = validate.IsValidPublicKey(pubKey) + if err != nil { + return nil, err + } + + return &User{ + ID: uuid.New(), + PubKey: pubKey, + Name: name, + LastName: lastName, + PhoneNumber: phoneNumber, + NationalID: nationalID, + CreatedAt: time.Now().UTC(), + UpdatedAt: time.Now().UTC(), + }, nil +} + +func (u *User) UpdateLastLogin() { + now := time.Now().UTC() + u.LastLogin = &now + u.UpdatedAt = now +} + +func (u *User) UpdateInfo(email string) { + addr, err := mail.ParseAddress(email) + if err == nil { + u.Email = &addr.Address + } + u.UpdatedAt = time.Now().UTC() +} + +func (u *User) UpdateKYCLevel(level KYCLevel) { + u.KYCLevel = level + u.UpdatedAt = time.Now().UTC() +} + type Challenge struct { Message string TimeStamp time.Time @@ -106,46 +160,3 @@ func (c *Challenge) VerifySignedBytes(expectedAddress string, msg []byte, sig [] return strings.EqualFold(expectedAddr.Hex(), recoveredAddr.Hex()), nil } - -func NewUser(pubKey, name, lastName, phoneNumber, email, nationalID string) (*User, error) { - - _, err := validate.IsValidPhone(phoneNumber) - if err != nil { - return nil, err - } - - _, err = validate.IsValidNationalID(nationalID) - if err != nil { - return nil, err - } - - _, err = validate.IsValidPublicKey(pubKey) - if err != nil { - return nil, err - } - - return &User{ - ID: uuid.New(), - PubKey: pubKey, - Name: name, - LastName: lastName, - PhoneNumber: phoneNumber, - NationalID: nationalID, - CreatedAt: time.Now().UTC(), - UpdatedAt: time.Now().UTC(), - }, nil -} - -func (u *User) UpdateLastLogin() { - now := time.Now().UTC() - u.LastLogin = &now - u.UpdatedAt = now -} - -func (u *User) UpdateInfo(email string) { - addr, err := mail.ParseAddress(email) - if err == nil { - u.Email = &addr.Address - } - u.UpdatedAt = time.Now().UTC() -} diff --git a/internal/repository/storage/types/user.go b/internal/repository/storage/types/user.go index 78c97b3..6979f9b 100644 --- a/internal/repository/storage/types/user.go +++ b/internal/repository/storage/types/user.go @@ -10,7 +10,7 @@ type User struct { PubKey string Name string LastName string - //TODO: add Kyc Embedded struct + KYCLevel int //TODO: add NFT Embedded struct Phone string NationalID string @@ -30,6 +30,7 @@ func CastUserToStorage(u domain.User) *User { PubKey: u.PubKey, Name: u.Name, LastName: u.LastName, + KYCLevel: int(u.KYCLevel), Phone: u.PhoneNumber, NationalID: u.NationalID, Email: u.Email, @@ -44,6 +45,7 @@ func CastUserToDomain(u User) *domain.User { PubKey: u.PubKey, Name: u.Name, LastName: u.LastName, + KYCLevel: domain.KYCLevel(u.KYCLevel), PhoneNumber: u.Phone, NationalID: u.NationalID, Email: u.Email, diff --git a/internal/usecase/auth_service.go b/internal/usecase/auth_service.go index 77ab744..8280a07 100644 --- a/internal/usecase/auth_service.go +++ b/internal/usecase/auth_service.go @@ -5,6 +5,7 @@ import ( "backend/internal/domain" "backend/pkg/jwt" "backend/pkg/validate/common" + "backend/pkg/zohal" "context" "errors" "fmt" @@ -24,8 +25,9 @@ type authService struct { userRepo domain.UserRepo sessionRepo domain.SessionRepo challengeRepo domain.ChallengeRepo + zohal *zohal.Zohal challengeExp uint - cfg config.JWT + cfg config.Config } type UserToken struct { @@ -39,13 +41,15 @@ func NewAuthService( sessionRepo domain.SessionRepo, challengeRepo domain.ChallengeRepo, challengeExp uint, - cfg config.JWT, + zohal *zohal.Zohal, + cfg config.Config, ) AuthService { return &authService{ userRepo: userRepo, sessionRepo: sessionRepo, challengeRepo: challengeRepo, challengeExp: challengeExp, + zohal: zohal, cfg: cfg, } } @@ -108,7 +112,7 @@ func (s *authService) Authenticate(ctx context.Context, pubKey string, signature return nil, fmt.Errorf("failed to update user: %w", err) } - expiresAt := time.Now().Add(time.Duration(s.cfg.TokenExpMinutes) * time.Minute) + expiresAt := time.Now().Add(time.Duration(s.cfg.JWT.TokenExpMinutes) * time.Minute) session := domain.NewSession(user.ID, user.PubKey, expiresAt) err = s.sessionRepo.Create(ctx, session) @@ -116,28 +120,31 @@ func (s *authService) Authenticate(ctx context.Context, pubKey string, signature return nil, fmt.Errorf("failed to create session: %w", err) } + //FIXME: KYC level will be set to 1 claims := &jwt.UserClaims{ UserID: uint(user.ID.ID()), + KYCLevel: int(user.KYCLevel), Sections: []string{}, } claims.ExpiresAt = jwt2.NewNumericDate(expiresAt) claims.IssuedAt = jwt2.NewNumericDate(time.Now()) - authToken, err := jwt.CreateToken([]byte(s.cfg.TokenSecret), claims) + authToken, err := jwt.CreateToken([]byte(s.cfg.JWT.TokenSecret), claims) if err != nil { return nil, err } - refreshExpiresAt := time.Now().Add(time.Duration(s.cfg.RefreshTokenExpMinutes) * time.Minute) + refreshExpiresAt := time.Now().Add(time.Duration(s.cfg.JWT.RefreshTokenExpMinutes) * time.Minute) refreshClaims := &jwt.UserClaims{ UserID: uint(user.ID.ID()), + KYCLevel: int(user.KYCLevel), Sections: []string{"refresh"}, } refreshClaims.ExpiresAt = jwt2.NewNumericDate(refreshExpiresAt) refreshClaims.IssuedAt = jwt2.NewNumericDate(time.Now()) - refreshToken, err := jwt.CreateToken([]byte(s.cfg.TokenSecret), refreshClaims) + refreshToken, err := jwt.CreateToken([]byte(s.cfg.JWT.TokenSecret), refreshClaims) if err != nil { return nil, fmt.Errorf("failed to create refresh token: %w", err) } @@ -148,3 +155,37 @@ func (s *authService) Authenticate(ctx context.Context, pubKey string, signature ExpiresAt: expiresAt.Unix(), }, nil } + +func (s *authService) VerifyKYC(ctx context.Context, userID, nationalID, birthDate string) error { + uID, err := uuid.Parse(userID) + if err != nil { + return fmt.Errorf("invalid user ID format: %w", err) + } + + user, err := s.userRepo.GetByID(ctx, uID) + if err != nil { + return fmt.Errorf("failed to get user: %w", err) + } + + shahkarResp, err := s.zohal.Shahkar(ctx, user.PhoneNumber, user.NationalID) + if err != nil { + return fmt.Errorf("shahkar verification failed: %w", err) + } + + identityResp, err := s.zohal.GetPerson(ctx, nationalID, birthDate) + if err != nil { + return fmt.Errorf("identity verification failed: %w", err) + } + + if shahkarResp.StatusCode == 200 && identityResp.StatusCode == 200 { + user.UpdateKYCLevel(domain.KYCLevel1) + err = s.userRepo.Update(ctx, user) + if err != nil { + return fmt.Errorf("failed to update user KYC level: %w", err) + } + return nil + } + + return fmt.Errorf("KYC verification failed - shahkar status: %d, identity status: %d", + shahkarResp.StatusCode, identityResp.StatusCode) +} diff --git a/pkg/jwt/claims.go b/pkg/jwt/claims.go index 1088e05..2ed10a3 100644 --- a/pkg/jwt/claims.go +++ b/pkg/jwt/claims.go @@ -5,5 +5,6 @@ import jwt2 "github.com/golang-jwt/jwt/v5" type UserClaims struct { jwt2.RegisteredClaims UserID uint + KYCLevel int Sections []string } diff --git a/pkg/zohal/zohal.go b/pkg/zohal/zohal.go index b90f37c..91c5240 100644 --- a/pkg/zohal/zohal.go +++ b/pkg/zohal/zohal.go @@ -16,6 +16,13 @@ type Zohal struct { cfg config.KYC } +func NewZohal(cfg config.KYC) *Zohal { + return &Zohal{ + cfg: cfg, + } +} + +// check Phone and NationalID func (z *Zohal) Shahkar(ctx context.Context, phone, nationalID string) (ZohalShahkarResp, error) { var req ZohalShahkarReq var resp ZohalShahkarResp @@ -28,7 +35,6 @@ func (z *Zohal) Shahkar(ctx context.Context, phone, nationalID string) (ZohalSha if !ok { return resp, err } - header := make(map[string]string) header["Content-Type"] = "application/json" header["Accept"] = "application/json" @@ -58,6 +64,42 @@ func (z *Zohal) Shahkar(ctx context.Context, phone, nationalID string) (ZohalSha if err != nil { return resp, err } - return resp, err } + +func (z *Zohal) GetPerson(ctx context.Context, nationalID, birthDate string) (ZohalIdentityResp, error) { + var req ZohalIdentityReq + var resp ZohalIdentityResp + + header := make(map[string]string) + header["Content-Type"] = "application/json" + header["Accept"] = "application/json" + header["Authorization"] = fmt.Sprintf("Bearer %s", z.cfg.APIKey) + + req.BirthDate = birthDate + req.NationalID = nationalID + + u, err := url.Parse(z.cfg.URL) + if err != nil { + return resp, err + } + u = u.JoinPath("inquiry", "shahkar") + + client := util.NewHttpClient() + clientResp, err := client.HttpRequest(ctx, http.MethodPost, u.String(), req, header) + if err != nil { + return resp, err + } + + bodyBytes, err := io.ReadAll(clientResp.Body) + if err != nil { + return resp, err + } + + err = json.Unmarshal(bodyBytes, &resp) + if err != nil { + return resp, err + } + return resp, err + +}