Submit customer identity proofs and show reviewed limits
This commit is contained in:
1 parent
7bf8b8087a
commit
1354551ae8
50 files changed
+1777
-67
No files matched your search
@@ -0,0 +1,48 @@
|
||||
package identityhttp
|
||||
|
||||
import "git.sendico.io/tech/sxchnge/product/identity"
|
||||
|
||||
type profileDTO struct {
|
||||
FullName string `json:"fullName"`
|
||||
DateOfBirth string `json:"dateOfBirth"`
|
||||
ResidenceCountry string `json:"residenceCountry"`
|
||||
}
|
||||
|
||||
func (p profileDTO) model() identity.Profile {
|
||||
return identity.Profile{FullName: p.FullName, DateOfBirth: p.DateOfBirth, ResidenceCountry: p.ResidenceCountry}
|
||||
}
|
||||
|
||||
type verificationDTO struct {
|
||||
Status string `json:"status"`
|
||||
UpgradePending bool `json:"upgradePending"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
SubmissionID string `json:"submissionId,omitempty"`
|
||||
Profile profileDTO `json:"profile"`
|
||||
}
|
||||
|
||||
func mapVerification(v identity.Verification) verificationDTO {
|
||||
return verificationDTO{Status: v.Status, UpgradePending: v.UpgradePending, Reason: v.Reason, SubmissionID: v.SubmissionID, Profile: profileDTO{FullName: v.Profile.FullName, DateOfBirth: v.Profile.DateOfBirth, ResidenceCountry: v.Profile.ResidenceCountry}}
|
||||
}
|
||||
|
||||
type limitDTO struct {
|
||||
Operation string `json:"operation"`
|
||||
Period string `json:"period"`
|
||||
Currency string `json:"currency"`
|
||||
Cap string `json:"cap"`
|
||||
Remaining string `json:"remaining"`
|
||||
Used string `json:"used"`
|
||||
Reserved string `json:"reserved"`
|
||||
}
|
||||
type limitsDTO struct {
|
||||
Tier string `json:"tier"`
|
||||
ProfileRef string `json:"profileRef"`
|
||||
Entries []limitDTO `json:"entries"`
|
||||
}
|
||||
|
||||
func mapLimits(v identity.Limits) limitsDTO {
|
||||
entries := make([]limitDTO, 0, len(v.Entries))
|
||||
for _, r := range v.Entries {
|
||||
entries = append(entries, limitDTO{Operation: r.Operation, Period: r.Period, Currency: r.Currency, Cap: r.Cap, Remaining: r.Remaining, Used: r.Used, Reserved: r.Reserved})
|
||||
}
|
||||
return limitsDTO{Tier: v.Tier, ProfileRef: v.ProfileRef, Entries: entries}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package identityhttp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
sresponse "git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
||||
customer "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||
"git.sendico.io/tech/sxchnge/product/identity"
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
api "git.sendico.io/tech/sxchnge/shared/http"
|
||||
"git.sendico.io/tech/sxchnge/shared/http/response"
|
||||
mlogger "git.sendico.io/tech/sxchnge/shared/logging"
|
||||
mservice "git.sendico.io/tech/sxchnge/shared/service"
|
||||
)
|
||||
|
||||
type Registrar interface {
|
||||
AccountHandler(mservice.Type, string, api.HTTPMethod, sresponse.AccountHandlerFunc)
|
||||
}
|
||||
type router struct {
|
||||
service *identity.Service
|
||||
logger mlogger.Logger
|
||||
}
|
||||
|
||||
func Install(reg Registrar, service *identity.Service, logger mlogger.Logger) {
|
||||
h := &router{service, logger.Named("identity_http")}
|
||||
reg.AccountHandler(mservice.Accounts, "/identity", api.Get, h.get)
|
||||
reg.AccountHandler(mservice.Accounts, "/identity", api.Post, h.submit)
|
||||
reg.AccountHandler(mservice.Accounts, "/limits", api.Get, h.limits)
|
||||
}
|
||||
|
||||
func (h *router) limits(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
|
||||
v, err := h.service.Limits(r.Context(), a)
|
||||
return h.result(mapLimits(v), err)
|
||||
}
|
||||
func (h *router) result(data any, err error) http.HandlerFunc {
|
||||
if err == nil {
|
||||
return response.Ok(h.logger, data)
|
||||
}
|
||||
if throttle, ok := merrors.ThrottleDetailsFrom(err); ok {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Retry-After", strconv.Itoa(max(1, int(throttle.RetryAfter.Seconds()))))
|
||||
response.TooManyRequests(h.logger, mservice.Accounts, "Wait before retrying identity submission")(w, r)
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, merrors.ErrInvalidArg):
|
||||
return response.BadRequest(h.logger, mservice.Accounts, "invalid_request", "Check profile and use readable JPEG/PNG photos, 600×600 minimum, 10 MiB maximum each")
|
||||
case errors.Is(err, merrors.ErrDataConflict):
|
||||
return response.DataConflict(h.logger, mservice.Accounts, "Identity submission changed; refresh its status")
|
||||
case errors.Is(err, merrors.ErrAccessDenied):
|
||||
return response.AccessDenied(h.logger, mservice.Accounts, "Identity submission is unavailable")
|
||||
default:
|
||||
return response.Error(h.logger, mservice.Accounts, http.StatusServiceUnavailable, "unavailable", "Identity verification is temporarily unavailable; retry")
|
||||
}
|
||||
}
|
||||
func (h *router) get(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
|
||||
v, err := h.service.Verification(r.Context(), a)
|
||||
return h.result(mapVerification(v), err)
|
||||
}
|
||||
func (h *router) submit(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
|
||||
// Read multipart as a bounded stream. Never spill identity documents to disk.
|
||||
r.Body = http.MaxBytesReader(nil, r.Body, 2*identity.MaxPhotoBytes+16384)
|
||||
reader, err := r.MultipartReader()
|
||||
if err != nil {
|
||||
return h.result(nil, merrors.InvalidArgument("multipart required"))
|
||||
}
|
||||
fields := map[string][]byte{}
|
||||
for {
|
||||
part, readErr := reader.NextPart()
|
||||
if readErr == io.EOF {
|
||||
break
|
||||
}
|
||||
if readErr != nil {
|
||||
return h.result(nil, merrors.InvalidArgument("invalid multipart"))
|
||||
}
|
||||
name := part.FormName()
|
||||
limit := int64(8192)
|
||||
switch name {
|
||||
case "document", "selfie":
|
||||
limit = identity.MaxPhotoBytes
|
||||
case "profile", "idempotencyKey":
|
||||
default:
|
||||
_ = part.Close()
|
||||
return h.result(nil, merrors.InvalidArgument("unexpected field"))
|
||||
}
|
||||
if _, exists := fields[name]; exists {
|
||||
_ = part.Close()
|
||||
return h.result(nil, merrors.InvalidArgument("duplicate field"))
|
||||
}
|
||||
data, readErr := io.ReadAll(io.LimitReader(part, limit+1))
|
||||
_ = part.Close()
|
||||
if readErr != nil || int64(len(data)) > limit {
|
||||
return h.result(nil, merrors.InvalidArgument("field too large"))
|
||||
}
|
||||
fields[name] = data
|
||||
}
|
||||
var p profileDTO
|
||||
if err = json.Unmarshal(fields["profile"], &p); err != nil {
|
||||
return h.result(nil, merrors.InvalidArgument("profile required"))
|
||||
}
|
||||
v, err := h.service.SubmitVerification(r.Context(), a, string(fields["idempotencyKey"]), p.model(), fields["document"], fields["selfie"])
|
||||
return h.result(mapVerification(v), err)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package identityhttp
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestThrottleDoesNotBecomeGenericUnavailable(t *testing.T) {
|
||||
h := router{logger: zap.NewNop()}
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("POST", "/accounts/identity", nil)
|
||||
h.result(nil, merrors.WithThrottle(merrors.ErrServiceUnavailable, "identity", 120*time.Second, "temporary limit"))(w, r)
|
||||
require.Equal(t, 429, w.Code)
|
||||
require.Equal(t, "120", w.Header().Get("Retry-After"))
|
||||
}
|
||||
Reference in new issue
Block a user