Skip to content

Commit

Permalink
Handle document expiration web-hook and updates verification status (#19
Browse files Browse the repository at this point in the history
)

* handle document expiration webhook and updates verification status

* log parsed clientID

* use const to prevents typos when using common headers and query parameters

* fix processClientID false positive in suffix validation

* log plain requets body not the base64 version

* verifiy twinId query paramter early in the handler

* prevent logging sensitive data from the Verification struct

* use timeout for handling ProcessDocExpirationNotification

* implement toOutcome on Verification struct

* refactor Verification.ToOutcome
  • Loading branch information
sameh-farouk authored Nov 12, 2024
1 parent d9040bf commit e0883d7
Show file tree
Hide file tree
Showing 9 changed files with 258 additions and 100 deletions.
1 change: 1 addition & 0 deletions internal/clients/idenfy/idenfy.go
Original file line number Diff line number Diff line change
Expand Up @@ -158,5 +158,6 @@ func (c *Idenfy) createVerificationSessionRequestBody(clientID string, devMode b
RequestBody.ExpiryTime = TokenExpiryDevModeSeconds
RequestBody.DummyStatus = "APPROVED"
}
c.logger.Debug("Creating verification session", "request", RequestBody)
return RequestBody
}
81 changes: 58 additions & 23 deletions internal/handlers/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,10 @@ This layer is responsible for handling the requests and responses, in more detai
package handlers

import (
"bytes"
"context"
"encoding/json"
"fmt"
"log/slog"
"strconv"
"time"

"github.com/gofiber/fiber/v2"
Expand All @@ -28,6 +27,20 @@ import (
"github.com/threefoldtech/tf-kyc-verifier/internal/services"
)

const (
// Authentication headers
HeaderClientID = "X-Client-ID"

// iDenfy webhook headers
HeaderIdenfySignature = "Idenfy-Signature"

// Query parameters
QueryParamClientID = "client_id"
QueryParamTwinID = "twin_id"

HandlerTimeout = 5 * time.Second
)

type Handler struct {
kycService *services.KYCService
config *config.Config
Expand Down Expand Up @@ -66,8 +79,8 @@ func NewHandler(kycService *services.KYCService, config *config.Config, logger *
// @Router /api/v1/token [post]
func (h *Handler) GetOrCreateVerificationToken() fiber.Handler {
return func(c *fiber.Ctx) error {
clientID := c.Get("X-Client-ID")
ctx, cancel := context.WithTimeout(c.Context(), 5*time.Second)
clientID := c.Get(HeaderClientID)
ctx, cancel := context.WithTimeout(c.Context(), HandlerTimeout)
defer cancel()
token, isNewToken, err := h.kycService.GetOrCreateVerificationToken(ctx, clientID)
if err != nil {
Expand Down Expand Up @@ -97,8 +110,8 @@ func (h *Handler) GetOrCreateVerificationToken() fiber.Handler {
// @Router /api/v1/data [get]
func (h *Handler) GetVerificationData() fiber.Handler {
return func(c *fiber.Ctx) error {
clientID := c.Get("X-Client-ID")
ctx, cancel := context.WithTimeout(c.Context(), 5*time.Second)
clientID := c.Get(HeaderClientID)
ctx, cancel := context.WithTimeout(c.Context(), HandlerTimeout)
defer cancel()
verification, err := h.kycService.GetVerificationData(ctx, clientID)
if err != nil {
Expand Down Expand Up @@ -127,21 +140,26 @@ func (h *Handler) GetVerificationData() fiber.Handler {
// @Router /api/v1/status [get]
func (h *Handler) GetVerificationStatus() fiber.Handler {
return func(c *fiber.Ctx) error {
clientID := c.Query("client_id")
twinID := c.Query("twin_id")
clientID := c.Query(QueryParamClientID)
twinID := c.Query(QueryParamTwinID)

if clientID == "" && twinID == "" {
h.logger.Warn("Bad request: missing client_id and twin_id")
return responses.RespondWithError(c, fiber.StatusBadRequest, fmt.Errorf("either client_id or twin_id must be provided"))
}
var verification *models.VerificationOutcome
var err error
ctx, cancel := context.WithTimeout(c.Context(), 5*time.Second)
ctx, cancel := context.WithTimeout(c.Context(), HandlerTimeout)
defer cancel()
if clientID != "" {
verification, err = h.kycService.GetVerificationStatus(ctx, clientID)
} else {
verification, err = h.kycService.GetVerificationStatusByTwinID(ctx, twinID)
twinIDUint64, parseErr := strconv.ParseUint(twinID, 10, 32)
if parseErr != nil {
h.logger.Error("Error parsing twinID", "twinID", twinID, "error", parseErr)
return responses.RespondWithError(c, fiber.StatusBadRequest, fmt.Errorf("invalid twinID"))
}
verification, err = h.kycService.GetVerificationStatusByTwinID(ctx, uint32(twinIDUint64))
}
if err != nil {
h.logger.Error("Failed to get verification status", "clientID", clientID, "twinID", twinID, "error", err)
Expand All @@ -166,26 +184,22 @@ func (h *Handler) GetVerificationStatus() fiber.Handler {
func (h *Handler) ProcessVerificationResult() fiber.Handler {
return func(c *fiber.Ctx) error {
h.logger.Debug("Received verification update",
"body", string(c.Body()),
"headers", &c.Request().Header,
)
sigHeader := c.Get("Idenfy-Signature")
sigHeader := c.Get(HeaderIdenfySignature)
if len(sigHeader) < 1 {
h.logger.Error("Missing signature header", "headers", string(c.Request().Header.Header()))
return responses.RespondWithError(c, fiber.StatusBadRequest, fmt.Errorf("no signature provided"))
}
body := c.Body()
var result models.Verification
decoder := json.NewDecoder(bytes.NewReader(body))
err := decoder.Decode(&result)
if err != nil {
if err := c.BodyParser(&result); err != nil {
h.logger.Error("Error decoding verification update", "error", err)
return responses.RespondWithError(c, fiber.StatusBadRequest, err)
}
h.logger.Debug("Verification update after decoding", "result", result)
ctx, cancel := context.WithTimeout(c.Context(), 5*time.Second)
ctx, cancel := context.WithTimeout(c.Context(), HandlerTimeout)
defer cancel()
err = h.kycService.ProcessVerificationResult(ctx, body, sigHeader, result)
if err != nil {
if err := h.kycService.ProcessVerificationResult(ctx, body, sigHeader, result); err != nil {
return HandleError(c, err)
}
return responses.RespondWithData(c, fiber.StatusOK, nil)
Expand All @@ -201,9 +215,30 @@ func (h *Handler) ProcessVerificationResult() fiber.Handler {
// @Router /webhooks/idenfy/id-expiration [post]
func (h *Handler) ProcessDocExpirationNotification() fiber.Handler {
return func(c *fiber.Ctx) error {
// TODO: implement
h.logger.Error("Received ID expiration notification but not implemented")
return c.SendStatus(fiber.StatusNotImplemented)
h.logger.Debug("Received ID expiration update",
"body", string(c.Body()),
"headers", &c.Request().Header,
)

// Verify signature
sigHeader := c.Get(HeaderIdenfySignature)
if len(sigHeader) < 1 {
h.logger.Error("Missing signature header", "headers", string(c.Request().Header.Header()))
return responses.RespondWithError(c, fiber.StatusBadRequest, fmt.Errorf("missing signature header"))
}
body := c.Body()
var notification models.DocExpirationNotification
if err := c.BodyParser(&notification); err != nil {
h.logger.Error("Error decoding verification update", "error", err)
return responses.RespondWithError(c, fiber.StatusBadRequest, fmt.Errorf("invalid request body"))
}
ctx, cancel := context.WithTimeout(c.Context(), HandlerTimeout)
defer cancel()
if err := h.kycService.ProcessDocExpirationNotification(ctx, body, sigHeader, notification); err != nil {
return HandleError(c, err)
}

return c.SendStatus(fiber.StatusOK)
}
}

Expand All @@ -214,7 +249,7 @@ func (h *Handler) ProcessDocExpirationNotification() fiber.Handler {
// @Router /api/v1/health [get]
func (h *Handler) HealthCheck(dbClient *mongo.Client) fiber.Handler {
return func(c *fiber.Ctx) error {
ctx, cancel := context.WithTimeout(c.Context(), 5*time.Second)
ctx, cancel := context.WithTimeout(c.Context(), HandlerTimeout)
defer cancel()
err := dbClient.Ping(ctx, readpref.Primary())
if err != nil {
Expand Down
13 changes: 10 additions & 3 deletions internal/middleware/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,19 @@ import (
"github.com/vedhavyas/go-subkey/v2/sr25519"
)

const (
// Authentication headers
HeaderClientID = "X-Client-ID"
HeaderChallenge = "X-Challenge"
HeaderSignature = "X-Signature"
)

// AuthMiddleware is a middleware that validates the authentication credentials
func AuthMiddleware(config config.Challenge) fiber.Handler {
return func(c *fiber.Ctx) error {
clientID := c.Get("X-Client-ID")
signature := c.Get("X-Signature")
challenge := c.Get("X-Challenge")
clientID := c.Get(HeaderClientID)
signature := c.Get(HeaderSignature)
challenge := c.Get(HeaderChallenge)

if clientID == "" || signature == "" || challenge == "" {
return responses.RespondWithError(c, fiber.StatusBadRequest, fmt.Errorf("missing authentication credentials"))
Expand Down
17 changes: 17 additions & 0 deletions internal/models/doc_expiration.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package models

type ExpirationThreshold string

const (
ExpiresWithin30Days ExpirationThreshold = "DOCUMENT_EXPIRES_WITHIN_30_DAYS"
ExpiresWithin7Days ExpirationThreshold = "DOCUMENT_EXPIRES_WITHIN_7_DAYS"
ExpiresWithin1Day ExpirationThreshold = "DOCUMENT_EXPIRES_WITHIN_1_DAY"
DocumentExpired ExpirationThreshold = "DOCUMENT_EXPIRED"
)

type DocExpirationNotification struct {
ScanRef string `json:"scanRef"`
ClientID string `json:"clientId"`
ExpirationThreshold ExpirationThreshold `json:"expirationThreshold"`
DocumentExpiration string `json:"documentExpiration"`
}
122 changes: 91 additions & 31 deletions internal/models/verification.go
Original file line number Diff line number Diff line change
@@ -1,39 +1,98 @@
package models

import (
"log/slog"
"time"

"github.com/threefoldtech/tf-kyc-verifier/internal/config"
"go.mongodb.org/mongo-driver/bson/primitive"
)

type Verification struct {
ID primitive.ObjectID `bson:"_id,omitempty" json:"-"`
CreatedAt time.Time `bson:"createdAt" json:"-"`
Final *bool `bson:"final" json:"final"` // required
Platform Platform `bson:"platform" json:"platform"` // required
Status Status `bson:"status" json:"status"` // required
Data PersonData `bson:"data" json:"data"` // required
FileUrls map[string]string `bson:"fileUrls" json:"fileUrls"` // required
IdenfyRef string `bson:"scanRef" json:"scanRef"` // required
ClientID string `bson:"clientId" json:"clientId"` // required
StartTime int64 `bson:"startTime" json:"startTime"` // required
FinishTime int64 `bson:"finishTime" json:"finishTime"` // required
ClientIP string `bson:"clientIp" json:"clientIp"` // required
ClientIPCountry string `bson:"clientIpCountry" json:"clientIpCountry"` // required
ClientLocation string `bson:"clientLocation" json:"clientLocation"` // required
CompanyID string `bson:"companyId" json:"companyId"` // required
BeneficiaryID string `bson:"beneficiaryId" json:"beneficiaryId"` // required
RegistryCenterCheck interface{} `json:"registryCenterCheck,omitempty"`
AddressVerification interface{} `json:"addressVerification,omitempty"`
QuestionnaireAnswers interface{} `json:"questionnaireAnswers,omitempty"`
AdditionalSteps map[string]string `json:"additionalSteps,omitempty"`
UtilityData []string `json:"utilityData,omitempty"`
AdditionalStepPdfUrls map[string]string `json:"additionalStepPdfUrls,omitempty"`
AML []AMLCheck `bson:"AML" json:"AML,omitempty"`
LID []LID `bson:"LID" json:"LID,omitempty"`
ExternalRef string `bson:"externalRef" json:"externalRef,omitempty"`
ManualAddress string `bson:"manualAddress" json:"manualAddress,omitempty"`
ManualAddressMatch *bool `bson:"manualAddressMatch" json:"manualAddressMatch,omitempty"`
ID primitive.ObjectID `bson:"_id,omitempty" json:"-"`
CreatedAt time.Time `bson:"createdAt" json:"-"`
Final *bool `bson:"final" json:"final"` // required
Platform Platform `bson:"platform" json:"platform"` // required
Status Status `bson:"status" json:"status"` // required
Data PersonData `bson:"data" json:"data"` // required
FileUrls map[string]string `bson:"fileUrls" json:"fileUrls"` // required
IdenfyRef string `bson:"scanRef" json:"scanRef"` // required
ClientID string `bson:"clientId" json:"clientId"` // required
StartTime int64 `bson:"startTime" json:"startTime"` // required
FinishTime int64 `bson:"finishTime" json:"finishTime"` // required
ClientIP string `bson:"clientIp" json:"clientIp"` // required
ClientIPCountry string `bson:"clientIpCountry" json:"clientIpCountry"` // required
ClientLocation string `bson:"clientLocation" json:"clientLocation"` // required
CompanyID string `bson:"companyId" json:"companyId"` // required
BeneficiaryID string `bson:"beneficiaryId" json:"beneficiaryId"` // required
RegistryCenterCheck interface{} `json:"registryCenterCheck,omitempty"`
AddressVerification interface{} `json:"addressVerification,omitempty"`
QuestionnaireAnswers interface{} `json:"questionnaireAnswers,omitempty"`
AdditionalSteps map[string]string `json:"additionalSteps,omitempty"`
UtilityData []string `json:"utilityData,omitempty"`
AdditionalStepPdfUrls map[string]string `json:"additionalStepPdfUrls,omitempty"`
AML []AMLCheck `bson:"AML" json:"AML,omitempty"`
LID []LID `bson:"LID" json:"LID,omitempty"`
ExternalRef string `bson:"externalRef" json:"externalRef,omitempty"`
ManualAddress string `bson:"manualAddress" json:"manualAddress,omitempty"`
ManualAddressMatch *bool `bson:"manualAddressMatch" json:"manualAddressMatch,omitempty"`
ExpirationStatus *ExpirationThreshold `bson:"expirationStatus,omitempty" json:"expirationStatus,omitempty"`
}

// implements slog.LogValuer to control how Verification is logged
func (v Verification) LogValue() slog.Value {
// Create a copy without sensitive data
sanitized := &Verification{
Final: v.Final,
Platform: v.Platform,
Status: v.Status,
IdenfyRef: v.IdenfyRef,
ClientID: v.ClientID,
StartTime: v.StartTime,
FinishTime: v.FinishTime,
ExpirationStatus: v.ExpirationStatus,
}

// Convert to a map for logging
return slog.GroupValue(
slog.Any("final", sanitized.Final),
slog.String("platform", string(sanitized.Platform)),
slog.Any("status", sanitized.Status),
slog.String("idenfyRef", sanitized.IdenfyRef),
slog.String("clientId", sanitized.ClientID),
slog.Int64("startTime", sanitized.StartTime),
slog.Int64("finishTime", sanitized.FinishTime),
slog.Any("expirationStatus", sanitized.ExpirationStatus),
)
}

// ToOutcome converts a Verification to a VerificationOutcome based on the service configuration
func (v Verification) ToOutcome(config config.Verification) *VerificationOutcome {
outcome := OutcomeRejected
// First check if Overall status exists
if v.Status.Overall != nil {
// Then evaluate if it's either:
// 1. Overall status is Approved, or
// 2. Overall status is Suspected AND config allows suspicious cases to be approved
if *v.Status.Overall == OverallApproved ||
(*v.Status.Overall == OverallSuspected && config.SuspiciousVerificationOutcome == "APPROVED") {

// If either condition is met, set outcome to Approved
outcome = OutcomeApproved

// Finally check if document is expired - this overrides the Approved outcome based on config
if v.ExpirationStatus != nil && *v.ExpirationStatus == DocumentExpired {
outcome = Outcome(config.ExpiredDocumentOutcome)
}
}
}
return &VerificationOutcome{
Final: v.Final,
ClientID: v.ClientID,
IdenfyRef: v.IdenfyRef,
ExpirationThreshold: v.ExpirationStatus,
Outcome: outcome,
}
}

type Platform string
Expand Down Expand Up @@ -237,10 +296,11 @@ type ServiceStatus struct {
}

type VerificationOutcome struct {
Final *bool `bson:"final"`
ClientID string `bson:"clientId"`
IdenfyRef string `bson:"idenfyRef"`
Outcome Outcome `bson:"outcome"`
Final *bool `bson:"final"`
ClientID string `bson:"clientId"`
IdenfyRef string `bson:"idenfyRef"`
ExpirationThreshold *ExpirationThreshold `bson:"expirationThreshold"`
Outcome Outcome `bson:"outcome"`
}

type Outcome string
Expand Down
1 change: 1 addition & 0 deletions internal/repository/mongo.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ type TokenRepository interface {
type VerificationRepository interface {
SaveVerification(ctx context.Context, verification *models.Verification) error
GetVerification(ctx context.Context, clientID string) (*models.Verification, error)
UpdateExpirationStatus(ctx context.Context, clientID string, scanRef string, status models.ExpirationThreshold) error
}

func NewMongoClient(ctx context.Context, mongoURI string) (*mongo.Client, error) {
Expand Down
21 changes: 21 additions & 0 deletions internal/repository/verification_repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package repository

import (
"context"
"fmt"
"log/slog"
"time"

Expand Down Expand Up @@ -56,3 +57,23 @@ func (r *MongoVerificationRepository) GetVerification(ctx context.Context, clien
}
return &verification, nil
}

func (r *MongoVerificationRepository) UpdateExpirationStatus(ctx context.Context, clientID string, scanRef string, status models.ExpirationThreshold) error {
filter := bson.M{"clientId": clientID, "scanRef": scanRef}
update := bson.M{
"$set": bson.M{
"expirationStatus": status,
},
}

result, err := r.collection.UpdateOne(ctx, filter, update)
if err != nil {
return fmt.Errorf("updating expiration status: %w", err)
}

if result.MatchedCount == 0 {
return fmt.Errorf("verification not found for client: %s", clientID)
}

return nil
}
Loading

0 comments on commit e0883d7

Please sign in to comment.