185 lines
7.2 KiB
Go
185 lines
7.2 KiB
Go
package sendico
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
|
|
"git.sendico.io/tech/sxchnge/product/identity"
|
|
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
|
)
|
|
|
|
type profileDTO struct {
|
|
FullName string `json:"full_name"`
|
|
DateOfBirth string `json:"date_of_birth"`
|
|
ResidenceCountry string `json:"residence_country"`
|
|
Email string `json:"email"`
|
|
CustomerRef string `json:"external_customer_ref"`
|
|
}
|
|
type verificationDTO struct {
|
|
SubjectRef string `json:"subject_ref"`
|
|
SubmissionRef string `json:"submission_ref"`
|
|
Status string `json:"status"`
|
|
Reason string `json:"reason"`
|
|
Profile profileDTO `json:"profile"`
|
|
UpgradePending bool `json:"upgrade_pending"`
|
|
}
|
|
type policyDTO struct {
|
|
Tier string `json:"tier"`
|
|
ProfileRef string `json:"profile_ref"`
|
|
Entries []limitDTO `json:"entries"`
|
|
}
|
|
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"`
|
|
}
|
|
|
|
func (c *Client) identityRequest(ctx context.Context, subject, suffix, method, contentType string, body []byte, dst any) error {
|
|
if !validRef(subject) {
|
|
return merrors.InvalidArgument("invalid bound subject")
|
|
}
|
|
token, err := c.token()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, method, c.base+"/identity_facts/"+c.config.OrganizationID+"/proofs/"+subject+suffix, bytes.NewReader(body))
|
|
if err != nil {
|
|
return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "create identity request")
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
if contentType != "" {
|
|
req.Header.Set("Content-Type", contentType)
|
|
}
|
|
// Uploads need a longer bound than small sign-up receipts. The base client is
|
|
// copied so concurrent sign-up operations keep their own four-second timeout.
|
|
client := *c.http
|
|
client.Timeout = 60 * time.Second
|
|
res, err := client.Do(req)
|
|
if err != nil {
|
|
return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "identity provider request failed")
|
|
}
|
|
defer func() { _ = res.Body.Close() }()
|
|
switch res.StatusCode {
|
|
case http.StatusOK:
|
|
case http.StatusBadRequest, http.StatusRequestEntityTooLarge:
|
|
return merrors.InvalidArgument("identity profile or photos were not accepted")
|
|
case http.StatusConflict:
|
|
return merrors.DataConflict("identity submission changed; refresh")
|
|
case http.StatusForbidden:
|
|
return merrors.PermissionDenied("identity resource unavailable")
|
|
case http.StatusTooManyRequests:
|
|
retry := 60
|
|
if seconds, parseErr := strconv.Atoi(res.Header.Get("Retry-After")); parseErr == nil && seconds > 0 && seconds <= 86400 {
|
|
retry = seconds
|
|
}
|
|
return merrors.WithThrottle(merrors.ErrServiceUnavailable, "identity", time.Duration(retry)*time.Second, "identity submission temporarily limited")
|
|
default:
|
|
return merrors.ServiceUnavailable("identity provider unavailable")
|
|
}
|
|
var envelope struct {
|
|
Status string `json:"status"`
|
|
Data json.RawMessage `json:"data"`
|
|
}
|
|
decoder := json.NewDecoder(io.LimitReader(res.Body, 256*1024+1))
|
|
if err := decoder.Decode(&envelope); err != nil {
|
|
return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "decode identity response")
|
|
}
|
|
var extra any
|
|
if decoder.Decode(&extra) != io.EOF || envelope.Status != "success" {
|
|
return merrors.ServiceUnavailable("invalid identity response")
|
|
}
|
|
if err := json.Unmarshal(envelope.Data, dst); err != nil {
|
|
return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "decode identity payload")
|
|
}
|
|
return nil
|
|
}
|
|
func mapVerification(dto verificationDTO, subject string) (identity.Verification, error) {
|
|
if dto.SubjectRef != subject {
|
|
return identity.Verification{}, merrors.ServiceUnavailable("identity response scope mismatch")
|
|
}
|
|
switch dto.Status {
|
|
case "unverified", "pending", "approved", "changesRequested", "denied":
|
|
default:
|
|
return identity.Verification{}, merrors.ServiceUnavailable("invalid identity status")
|
|
}
|
|
if dto.Status == "approved" && dto.UpgradePending {
|
|
dto.Status = "pending"
|
|
}
|
|
if dto.Status != "unverified" && !validRef(dto.SubmissionRef) {
|
|
return identity.Verification{}, merrors.ServiceUnavailable("invalid identity receipt")
|
|
}
|
|
profile := identity.Profile{FullName: dto.Profile.FullName, DateOfBirth: dto.Profile.DateOfBirth, ResidenceCountry: dto.Profile.ResidenceCountry}
|
|
if dto.Status != "unverified" {
|
|
if err := profile.Validate(); err != nil {
|
|
return identity.Verification{}, merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "invalid identity profile response")
|
|
}
|
|
}
|
|
return identity.Verification{Status: dto.Status, UpgradePending: dto.UpgradePending, Reason: dto.Reason, SubmissionID: dto.SubmissionRef, Profile: profile}, nil
|
|
}
|
|
func (c *Client) Verification(ctx context.Context, subject string) (identity.Verification, error) {
|
|
var dto verificationDTO
|
|
if err := c.identityRequest(ctx, subject, "", http.MethodGet, "", nil, &dto); err != nil {
|
|
return identity.Verification{}, err
|
|
}
|
|
return mapVerification(dto, subject)
|
|
}
|
|
func (c *Client) SubmitVerification(ctx context.Context, subject, key string, p identity.Profile, f identity.Facts, document, selfie []byte) (identity.Verification, error) {
|
|
var body bytes.Buffer
|
|
writer := multipart.NewWriter(&body)
|
|
profile, err := json.Marshal(profileDTO{FullName: p.FullName, DateOfBirth: p.DateOfBirth, ResidenceCountry: p.ResidenceCountry, Email: f.Email, CustomerRef: f.CustomerRef})
|
|
if err != nil {
|
|
return identity.Verification{}, merrors.OpaqueWrap(merrors.ErrInternal, err, "encode identity profile")
|
|
}
|
|
if err = writer.WriteField("profile", string(profile)); err != nil {
|
|
return identity.Verification{}, err
|
|
}
|
|
if err = writer.WriteField("source_submission_ref", key); err != nil {
|
|
return identity.Verification{}, err
|
|
}
|
|
for i, data := range [][]byte{document, selfie} {
|
|
name := "document"
|
|
if i == 1 {
|
|
name = "selfie"
|
|
}
|
|
part, writeErr := writer.CreateFormFile(name, name)
|
|
if writeErr != nil {
|
|
return identity.Verification{}, writeErr
|
|
}
|
|
if _, writeErr = part.Write(data); writeErr != nil {
|
|
return identity.Verification{}, writeErr
|
|
}
|
|
}
|
|
if err = writer.Close(); err != nil {
|
|
return identity.Verification{}, err
|
|
}
|
|
var dto verificationDTO
|
|
if err = c.identityRequest(ctx, subject, "", http.MethodPost, writer.FormDataContentType(), body.Bytes(), &dto); err != nil {
|
|
return identity.Verification{}, err
|
|
}
|
|
return mapVerification(dto, subject)
|
|
}
|
|
func (c *Client) Limits(ctx context.Context, subject string) (identity.Limits, error) {
|
|
var dto policyDTO
|
|
if err := c.identityRequest(ctx, subject, "/limits", http.MethodGet, "", nil, &dto); err != nil {
|
|
return identity.Limits{}, err
|
|
}
|
|
if (dto.Tier != "basic" && dto.Tier != "verified") || dto.ProfileRef == "" || dto.Entries == nil {
|
|
return identity.Limits{}, merrors.ServiceUnavailable("invalid limits response")
|
|
}
|
|
entries := make([]identity.Limit, 0, len(dto.Entries))
|
|
for _, v := range dto.Entries {
|
|
entries = append(entries, identity.Limit{Operation: v.Operation, Period: v.Period, Currency: v.Currency, Cap: v.Cap, Remaining: v.Remaining, Used: v.Used, Reserved: v.Reserved})
|
|
}
|
|
return identity.Limits{Tier: dto.Tier, ProfileRef: dto.ProfileRef, Entries: entries}, nil
|
|
}
|