160 lines
4.9 KiB
Go
160 lines
4.9 KiB
Go
package identity
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"strings"
|
|
"time"
|
|
|
|
customer "git.sendico.io/tech/sxchnge/product/customers/model"
|
|
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// Profile holds unverified customer assertions. Email and customer reference are
|
|
// always supplied by the authenticated backend, never accepted from the browser.
|
|
type Profile struct {
|
|
FullName string
|
|
DateOfBirth string
|
|
ResidenceCountry string
|
|
}
|
|
|
|
func (p Profile) Validate() error {
|
|
birth, err := time.Parse("2006-01-02", p.DateOfBirth)
|
|
if strings.TrimSpace(p.FullName) == "" || len(p.FullName) > 800 || err != nil || !birth.Before(time.Now()) || len(p.ResidenceCountry) != 2 || p.ResidenceCountry[0] < 'A' || p.ResidenceCountry[0] > 'Z' || p.ResidenceCountry[1] < 'A' || p.ResidenceCountry[1] > 'Z' {
|
|
return merrors.InvalidArgument("complete identity profile required")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type Verification struct {
|
|
Status string
|
|
UpgradePending bool
|
|
Reason string
|
|
SubmissionID string
|
|
Profile Profile
|
|
}
|
|
|
|
// VerificationProvider consumes reviewed state through the platform public API.
|
|
// It cannot accept a browser-selected subject, policy profile or approval.
|
|
type VerificationProvider interface {
|
|
Verification(context.Context, string) (Verification, error)
|
|
SubmitVerification(context.Context, string, string, Profile, Facts, []byte, []byte) (Verification, error)
|
|
}
|
|
|
|
// Decimal amounts are transported losslessly in their currency's major unit.
|
|
// No fiat scale or illustrative client caps are applied to provider policy.
|
|
type Limit struct {
|
|
Operation string
|
|
Period string
|
|
Currency string
|
|
Cap string
|
|
Remaining string
|
|
Used string
|
|
Reserved string
|
|
}
|
|
type Limits struct {
|
|
Tier string
|
|
ProfileRef string
|
|
Entries []Limit
|
|
}
|
|
type LimitsProvider interface {
|
|
Limits(context.Context, string) (Limits, error)
|
|
}
|
|
|
|
func (s *Service) Limits(ctx context.Context, a *customer.Account) (Limits, error) {
|
|
b, err := s.binding(ctx, a)
|
|
if err != nil {
|
|
return Limits{}, err
|
|
}
|
|
p, ok := s.Provider.(LimitsProvider)
|
|
if !ok {
|
|
return Limits{}, unavailable()
|
|
}
|
|
return p.Limits(ctx, b.SubjectRef)
|
|
}
|
|
|
|
func (s *Service) binding(ctx context.Context, a *customer.Account) (*Binding, error) {
|
|
if s == nil || s.Provider == nil || s.Store == nil {
|
|
return nil, merrors.ServiceUnavailable("identity verification unavailable")
|
|
}
|
|
if a == nil || a.ID.IsZero() {
|
|
return nil, merrors.InvalidArgument("customer required")
|
|
}
|
|
b, err := s.Store.Get(ctx, a.ID)
|
|
if errors.Is(err, merrors.ErrNoData) || err == nil && b.Receipt == nil {
|
|
if err = s.Ensure(ctx, a); err != nil {
|
|
return nil, err
|
|
}
|
|
b, err = s.Store.Get(ctx, a.ID)
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if b.Namespace != s.Provider.Namespace() || b.SubjectRef == "" || b.Receipt == nil || b.Receipt.SubjectRef != b.SubjectRef {
|
|
return nil, merrors.FailedPrecondition("identity binding requires reconciliation")
|
|
}
|
|
return b, nil
|
|
}
|
|
|
|
func (s *Service) Verification(ctx context.Context, a *customer.Account) (Verification, error) {
|
|
b, err := s.binding(ctx, a)
|
|
if err != nil {
|
|
return Verification{}, err
|
|
}
|
|
provider, ok := s.Provider.(VerificationProvider)
|
|
if !ok {
|
|
return Verification{}, unavailable()
|
|
}
|
|
v, err := provider.Verification(ctx, b.SubjectRef)
|
|
if err == nil && v.Profile.FullName == "" {
|
|
v.Profile.FullName = a.Name
|
|
}
|
|
return v, err
|
|
}
|
|
|
|
func (s *Service) SubmitVerification(ctx context.Context, a *customer.Account, key string, p Profile, document, selfie []byte) (Verification, error) {
|
|
if len(key) < 16 || len(key) > 128 {
|
|
return Verification{}, merrors.InvalidArgument("valid idempotency key required")
|
|
}
|
|
if err := p.Validate(); err != nil {
|
|
return Verification{}, err
|
|
}
|
|
for _, photo := range [][]byte{document, selfie} {
|
|
if err := ValidatePhoto(photo); err != nil {
|
|
return Verification{}, err
|
|
}
|
|
}
|
|
b, err := s.binding(ctx, a)
|
|
if err != nil {
|
|
return Verification{}, err
|
|
}
|
|
provider, ok := s.Provider.(VerificationProvider)
|
|
if !ok {
|
|
return Verification{}, unavailable()
|
|
}
|
|
digest := sha256.Sum256([]byte(a.ID.Hex() + "\x00" + key))
|
|
facts := b.Facts
|
|
facts.Email = a.Login
|
|
facts.CustomerRef = a.ID.Hex()
|
|
v, err := provider.SubmitVerification(ctx, b.SubjectRef, "sxchnge:identity:"+hex.EncodeToString(digest[:]), p, facts, document, selfie)
|
|
if err != nil {
|
|
s.Logger.Warn("Identity proof submission incomplete", zap.String("account_ref", a.ID.Hex()))
|
|
return Verification{}, err
|
|
}
|
|
s.Logger.Info("Identity proofs accepted for review", zap.String("account_ref", a.ID.Hex()), zap.String("submission_ref", v.SubmissionID))
|
|
return v, nil
|
|
}
|
|
|
|
// Subject resolves the authenticated customer's confirmed provider binding.
|
|
// Only server-side services may use this resource; clients cannot select it.
|
|
func (s *Service) Subject(ctx context.Context, a *customer.Account) (string, error) {
|
|
b, err := s.binding(ctx, a)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return b.SubjectRef, nil
|
|
}
|