feat: add kyc layer service

This commit is contained in:
AmirMahdi Qiasvand 2025-09-14 16:17:29 +03:30
parent 667d2ce2f4
commit 2da912739b
6 changed files with 159 additions and 59 deletions

View File

@ -8,6 +8,7 @@ import (
"backend/internal/usecase" "backend/internal/usecase"
cachePackage "backend/pkg/cache" cachePackage "backend/pkg/cache"
"backend/pkg/postgres" "backend/pkg/postgres"
"backend/pkg/zohal"
"fmt" "fmt"
"log" "log"
@ -43,13 +44,15 @@ func NewAppContainer(cfg config.Config) (*AppContainer, error) {
userRepo := storage.NewUserRepository(dbConn) userRepo := storage.NewUserRepository(dbConn)
sessionRepo := cache.NewSessionRepository(redis) sessionRepo := cache.NewSessionRepository(redis)
challengeRepo := cache.NewChallengeRepository(redis) challengeRepo := cache.NewChallengeRepository(redis)
zohal := zohal.NewZohal(cfg.KYCProvider)
authService := usecase.NewAuthService( authService := usecase.NewAuthService(
userRepo, userRepo,
sessionRepo, sessionRepo,
challengeRepo, challengeRepo,
30, 30,
cfg.JWT, zohal,
cfg,
) )
return &AppContainer{ return &AppContainer{

View File

@ -39,12 +39,19 @@ type ChallengeRepo interface {
Delete(ctx context.Context, pubKey string) error Delete(ctx context.Context, pubKey string) error
} }
type KYCLevel int8
const (
KYCLevel0 KYCLevel = iota
KYCLevel1
KYCLevel2
)
type User struct { type User struct {
ID uuid.UUID ID uuid.UUID
PubKey string PubKey string
Name string Name string
//TODO: add Kyc Embedded struct KYCLevel KYCLevel
//TODO: add NFT Embedded struct
LastName string LastName string
PhoneNumber string PhoneNumber string
Email *string Email *string
@ -56,7 +63,54 @@ type User struct {
DeletedAt *time.Time 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 { type Challenge struct {
Message string Message string
TimeStamp time.Time TimeStamp time.Time
@ -106,46 +160,3 @@ func (c *Challenge) VerifySignedBytes(expectedAddress string, msg []byte, sig []
return strings.EqualFold(expectedAddr.Hex(), recoveredAddr.Hex()), nil 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()
}

View File

@ -10,7 +10,7 @@ type User struct {
PubKey string PubKey string
Name string Name string
LastName string LastName string
//TODO: add Kyc Embedded struct KYCLevel int
//TODO: add NFT Embedded struct //TODO: add NFT Embedded struct
Phone string Phone string
NationalID string NationalID string
@ -30,6 +30,7 @@ func CastUserToStorage(u domain.User) *User {
PubKey: u.PubKey, PubKey: u.PubKey,
Name: u.Name, Name: u.Name,
LastName: u.LastName, LastName: u.LastName,
KYCLevel: int(u.KYCLevel),
Phone: u.PhoneNumber, Phone: u.PhoneNumber,
NationalID: u.NationalID, NationalID: u.NationalID,
Email: u.Email, Email: u.Email,
@ -44,6 +45,7 @@ func CastUserToDomain(u User) *domain.User {
PubKey: u.PubKey, PubKey: u.PubKey,
Name: u.Name, Name: u.Name,
LastName: u.LastName, LastName: u.LastName,
KYCLevel: domain.KYCLevel(u.KYCLevel),
PhoneNumber: u.Phone, PhoneNumber: u.Phone,
NationalID: u.NationalID, NationalID: u.NationalID,
Email: u.Email, Email: u.Email,

View File

@ -5,6 +5,7 @@ import (
"backend/internal/domain" "backend/internal/domain"
"backend/pkg/jwt" "backend/pkg/jwt"
"backend/pkg/validate/common" "backend/pkg/validate/common"
"backend/pkg/zohal"
"context" "context"
"errors" "errors"
"fmt" "fmt"
@ -24,8 +25,9 @@ type authService struct {
userRepo domain.UserRepo userRepo domain.UserRepo
sessionRepo domain.SessionRepo sessionRepo domain.SessionRepo
challengeRepo domain.ChallengeRepo challengeRepo domain.ChallengeRepo
zohal *zohal.Zohal
challengeExp uint challengeExp uint
cfg config.JWT cfg config.Config
} }
type UserToken struct { type UserToken struct {
@ -39,13 +41,15 @@ func NewAuthService(
sessionRepo domain.SessionRepo, sessionRepo domain.SessionRepo,
challengeRepo domain.ChallengeRepo, challengeRepo domain.ChallengeRepo,
challengeExp uint, challengeExp uint,
cfg config.JWT, zohal *zohal.Zohal,
cfg config.Config,
) AuthService { ) AuthService {
return &authService{ return &authService{
userRepo: userRepo, userRepo: userRepo,
sessionRepo: sessionRepo, sessionRepo: sessionRepo,
challengeRepo: challengeRepo, challengeRepo: challengeRepo,
challengeExp: challengeExp, challengeExp: challengeExp,
zohal: zohal,
cfg: cfg, 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) 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) session := domain.NewSession(user.ID, user.PubKey, expiresAt)
err = s.sessionRepo.Create(ctx, session) 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) return nil, fmt.Errorf("failed to create session: %w", err)
} }
//FIXME: KYC level will be set to 1
claims := &jwt.UserClaims{ claims := &jwt.UserClaims{
UserID: uint(user.ID.ID()), UserID: uint(user.ID.ID()),
KYCLevel: int(user.KYCLevel),
Sections: []string{}, Sections: []string{},
} }
claims.ExpiresAt = jwt2.NewNumericDate(expiresAt) claims.ExpiresAt = jwt2.NewNumericDate(expiresAt)
claims.IssuedAt = jwt2.NewNumericDate(time.Now()) 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 { if err != nil {
return nil, err 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{ refreshClaims := &jwt.UserClaims{
UserID: uint(user.ID.ID()), UserID: uint(user.ID.ID()),
KYCLevel: int(user.KYCLevel),
Sections: []string{"refresh"}, Sections: []string{"refresh"},
} }
refreshClaims.ExpiresAt = jwt2.NewNumericDate(refreshExpiresAt) refreshClaims.ExpiresAt = jwt2.NewNumericDate(refreshExpiresAt)
refreshClaims.IssuedAt = jwt2.NewNumericDate(time.Now()) 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 { if err != nil {
return nil, fmt.Errorf("failed to create refresh token: %w", err) 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(), ExpiresAt: expiresAt.Unix(),
}, nil }, 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)
}

View File

@ -5,5 +5,6 @@ import jwt2 "github.com/golang-jwt/jwt/v5"
type UserClaims struct { type UserClaims struct {
jwt2.RegisteredClaims jwt2.RegisteredClaims
UserID uint UserID uint
KYCLevel int
Sections []string Sections []string
} }

View File

@ -16,6 +16,13 @@ type Zohal struct {
cfg config.KYC 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) { func (z *Zohal) Shahkar(ctx context.Context, phone, nationalID string) (ZohalShahkarResp, error) {
var req ZohalShahkarReq var req ZohalShahkarReq
var resp ZohalShahkarResp var resp ZohalShahkarResp
@ -28,7 +35,6 @@ func (z *Zohal) Shahkar(ctx context.Context, phone, nationalID string) (ZohalSha
if !ok { if !ok {
return resp, err return resp, err
} }
header := make(map[string]string) header := make(map[string]string)
header["Content-Type"] = "application/json" header["Content-Type"] = "application/json"
header["Accept"] = "application/json" header["Accept"] = "application/json"
@ -58,6 +64,42 @@ func (z *Zohal) Shahkar(ctx context.Context, phone, nationalID string) (ZohalSha
if err != nil { if err != nil {
return resp, err return resp, err
} }
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
}