/
verify.go
1238 lines (1073 loc) · 49 KB
/
verify.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
package routes
import (
"bytes"
"context"
"encoding/gob"
"encoding/json"
"fmt"
"github.com/deso-smart/deso-backend/v2/countries"
"io"
"io/ioutil"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"time"
"github.com/sendgrid/sendgrid-go"
"github.com/sendgrid/sendgrid-go/helpers/mail"
"github.com/deso-smart/deso-core/v2/lib"
"github.com/golang/glog"
"github.com/nyaruka/phonenumbers"
)
type SendPhoneNumberVerificationTextRequest struct {
PublicKeyBase58Check string `safeForLogging:"true"`
PhoneNumber string
JWT string
}
type SendPhoneNumberVerificationTextResponse struct {
}
/*************************************************************
How verification works:
1. User inputs phone number and hits submit
2. Frontend hits SendPhoneNumberVerificationText. It uses Twilio to send a text to
the user with a verification code. Before sending the text, it validates that the
phone number isn't already in use by checking phoneNumberMetadata (explained below).
3. User inputs the code and hits submit
4. Frontend hits SubmitPhoneNumberVerificationCode. This verifies the code and updates
two mappings in global state.
A. userMetadata is updated to include the user's phone number
B. phoneNumberMetadata is created, which maps phone number => user's public key
*************************************************************/
func (fes *APIServer) SendPhoneNumberVerificationText(ww http.ResponseWriter, req *http.Request) {
decoder := json.NewDecoder(io.LimitReader(req.Body, MaxRequestBodySizeBytes))
requestData := SendPhoneNumberVerificationTextRequest{}
if err := decoder.Decode(&requestData); err != nil {
_AddBadRequestError(ww, fmt.Sprintf("SendPhoneNumberVerificationText: Problem parsing request body: %v", err))
return
}
if fes.Twilio == nil {
_AddBadRequestError(ww,
"SendPhoneNumberVerificationText: Error: You must set Twilio API keys to use this functionality")
return
}
// Validate their permissions
isValid, err := fes.ValidateJWT(requestData.PublicKeyBase58Check, requestData.JWT)
if err != nil {
_AddBadRequestError(ww, fmt.Sprintf("SendPhoneNumberVerificationText: Error validating JWT: %v", err))
}
if !isValid {
_AddBadRequestError(ww, fmt.Sprintf("SendPhoneNumberVerificationText: Invalid token: %v", err))
return
}
/**************************************************************/
// Validations
/**************************************************************/
if err = fes.validatePhoneNumberNotAlreadyInUse(
requestData.PhoneNumber, requestData.PublicKeyBase58Check); err != nil {
_AddBadRequestError(ww, fmt.Sprintf(
"SendPhoneNumberVerificationText: Error with validatePhoneNumberNotAlreadyInUse: %v", err))
return
}
/**************************************************************/
// Ensure the user-provided number is not a VOIP number
/**************************************************************/
phoneNumber := requestData.PhoneNumber
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
data := url.Values{}
data.Add("Type", "carrier")
lookup, err := fes.Twilio.Lookup.LookupPhoneNumbers.Get(ctx, phoneNumber, data)
if err != nil {
_AddBadRequestError(ww, fmt.Sprintf("SendPhoneNumberVerificationText: Problem with Lookup: %v", err))
return
}
if lookup.Carrier.Type == TwilioVoipCarrierType {
_AddBadRequestError(ww, fmt.Sprintf("SendPhoneNumberVerificationText: VOIP number not allowed"))
return
}
/**************************************************************/
// Send the actual verification text
/**************************************************************/
ctx, cancel = context.WithCancel(context.Background())
defer cancel()
data = url.Values{}
data.Add("To", phoneNumber)
data.Add("Channel", "sms")
_, err = fes.Twilio.Verify.Verifications.Create(ctx, fes.Config.TwilioVerifyServiceID, data)
if err != nil {
_AddBadRequestError(ww, fmt.Sprintf("SendPhoneNumberVerificationText: Error with SendSMS: %v", err))
return
}
}
func (fes *APIServer) canUserCreateProfile(userMetadata *UserMetadata, utxoView *lib.UtxoView) (_canUserCreateProfile bool, _err error) {
// If a user already has a profile, they can update their profile.
profileEntry := utxoView.GetProfileEntryForPublicKey(userMetadata.PublicKey)
if profileEntry != nil && len(profileEntry.Username) > 0 {
return true, nil
}
totalBalanceNanos, err := utxoView.GetDeSoBalanceNanosForPublicKey(userMetadata.PublicKey)
if err != nil {
return false, err
}
// User can create a profile if they have a phone number or if they have enough DeSo to cover the create profile fee.
// The PhoneNumber is only set if the user has passed phone number verification.
if userMetadata.PhoneNumber != "" || totalBalanceNanos >= utxoView.GlobalParamsEntry.CreateProfileFeeNanos {
return true, nil
}
// Users who have verified with Jumio can create a profile
if userMetadata.JumioVerified {
return true, nil
}
// If we reached here, the user can't create a profile
return false, nil
}
func (fes *APIServer) getMultiPhoneNumberMetadataFromGlobalState(phoneNumber string) (
_phoneNumberMetadata []*PhoneNumberMetadata, _err error) {
dbKey, err := GlobalStateKeyForPhoneNumberStringToMultiPhoneNumberMetadata(phoneNumber)
if err != nil {
return nil, fmt.Errorf(
"getPhoneNumberMetadataFromGlobalState: Problem with GlobalStateKeyForPhoneNumberStringToPhoneNumberMetadata %v", err)
}
multiPhoneNumberMetadataBytes, err := fes.GlobalState.Get(dbKey)
if err != nil {
return nil, fmt.Errorf(
"getPhoneNumberMetadataFromGlobalState: Problem with Get: %v", err)
}
multiPhoneNumberMetadata := []*PhoneNumberMetadata{}
if multiPhoneNumberMetadataBytes != nil {
if err = gob.NewDecoder(
bytes.NewReader(multiPhoneNumberMetadataBytes)).Decode(&multiPhoneNumberMetadata); err != nil {
return nil, fmt.Errorf(
"getPhoneNumberMetadataFromGlobalState: Problem with NewDecoder: %v", err)
}
}
return multiPhoneNumberMetadata, nil
}
func (fes *APIServer) getPhoneNumberMetadataFromGlobalState(phoneNumber string, publicKey []byte) (
_phoneNumberMetadata *PhoneNumberMetadata, _err error) {
multiPhoneNumberMetadata, err := fes.getMultiPhoneNumberMetadataFromGlobalState(phoneNumber)
if err != nil {
return nil, err
}
for _, phoneMetadata := range multiPhoneNumberMetadata {
if phoneMetadata != nil && bytes.Equal(phoneMetadata.PublicKey, publicKey) {
return phoneMetadata, nil
}
}
return nil, fmt.Errorf("Specified publicKey not found for provided phone number")
}
func (fes *APIServer) putPhoneNumberMetadataInGlobalState(multiPhoneNumberMetadata []*PhoneNumberMetadata, phoneNumber string) (_err error) {
dbKey, err := GlobalStateKeyForPhoneNumberStringToMultiPhoneNumberMetadata(phoneNumber)
if err != nil {
return fmt.Errorf(
"putPhoneNumberMetadataInGlobalState: Problem with GlobalStateKeyForPhoneNumberStringToPhoneNumberMetadata %v", err)
}
metadataDataBuf := bytes.NewBuffer([]byte{})
if err = gob.NewEncoder(metadataDataBuf).Encode(multiPhoneNumberMetadata); err != nil {
return fmt.Errorf(
"putPhoneNumberMetadataInGlobalState: Problem encoding slice of phone number metadata: %v", err)
}
if err = fes.GlobalState.Put(dbKey, metadataDataBuf.Bytes()); err != nil {
return fmt.Errorf(
"putPhoneNumberMetadataInGlobalState: Problem putting updated phone number metadata: %v", err)
}
return nil
}
func (fes *APIServer) validatePhoneNumberNotAlreadyInUse(phoneNumber string, userPublicKeyBase58Check string) (_err error) {
userPublicKeyBytes, _, err := lib.Base58CheckDecode(userPublicKeyBase58Check)
if err != nil {
return fmt.Errorf("validatePhoneNumberNotAlreadyInUse: Error decoding user public key: %v", err)
}
multiPhoneNumberMetadata, err := fes.getMultiPhoneNumberMetadataFromGlobalState(phoneNumber)
if err != nil {
return fmt.Errorf(
"validatePhoneNumberNotAlreadyInUse: Error with getPhoneNumberMetadataFromGlobalState: %v", err)
}
// TODO: this threshold should really be controlled by an admin on the node instead of via a flag.
if uint64(len(multiPhoneNumberMetadata)) >= fes.Config.PhoneNumberUseThreshold {
return fmt.Errorf(
"validatePhoneNumberNotAlreadyInUse: Phone number has been used over %v times",
fes.Config.PhoneNumberUseThreshold)
}
for _, phoneNumberMetadata := range multiPhoneNumberMetadata {
if bytes.Equal(userPublicKeyBytes, phoneNumberMetadata.PublicKey) {
return fmt.Errorf("validatePhoneNumberNotAlreadyInUse: Phone number already used by this public key")
}
}
return nil
}
type SubmitPhoneNumberVerificationCodeRequest struct {
JWT string
PublicKeyBase58Check string
PhoneNumber string
VerificationCode string
}
type SubmitPhoneNumberVerificationCodeResponse struct {
TxnHashHex string
}
func (fes *APIServer) SubmitPhoneNumberVerificationCode(ww http.ResponseWriter, req *http.Request) {
decoder := json.NewDecoder(io.LimitReader(req.Body, MaxRequestBodySizeBytes))
requestData := SubmitPhoneNumberVerificationCodeRequest{}
if err := decoder.Decode(&requestData); err != nil {
_AddBadRequestError(ww, fmt.Sprintf("SubmitPhoneNumberVerificationCode: Problem parsing request body: %v", err))
return
}
// Validate their permissions
isValid, err := fes.ValidateJWT(requestData.PublicKeyBase58Check, requestData.JWT)
if err != nil {
_AddBadRequestError(ww, fmt.Sprintf("SubmitPhoneNumberVerificaitonCodE: Error validating JWT: %v", err))
}
if !isValid {
_AddBadRequestError(ww, fmt.Sprintf("SubmitPhoneNumberVerificationCode: Invalid token: %v", err))
return
}
/**************************************************************/
// Validations
/**************************************************************/
if err = fes.validatePhoneNumberNotAlreadyInUse(
requestData.PhoneNumber, requestData.PublicKeyBase58Check); err != nil {
_AddBadRequestError(ww, fmt.Sprintf("SubmitPhoneNumberVerificationCode: Error with validatePhoneNumberNotAlreadyInUse: %v", err))
return
}
/**************************************************************/
// Actual logic
/**************************************************************/
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
data := url.Values{}
data.Add("Code", requestData.VerificationCode)
data.Add("To", requestData.PhoneNumber)
checkPhoneNumberResponse, err := fes.Twilio.Verify.Verifications.Check(ctx, fes.Config.TwilioVerifyServiceID, data)
if err != nil {
_AddBadRequestError(ww, fmt.Sprintf("SendPhoneNumberVerificationText: Error with SendSMS: %v", err))
return
}
if checkPhoneNumberResponse.Status != TwilioCheckPhoneNumberApproved {
// If the phone number has requested a code recently, and the code is well-formed (e.g. ~6 chars),
// but the code is incorrect, we end up here
_AddBadRequestError(ww, fmt.Sprintf("SendPhoneNumberVerificationText: Code is not valid"))
return
}
/**************************************************************/
// Save the phone number in global state
/**************************************************************/
// Update / save userMetadata in global state
userMetadata, err := fes.getUserMetadataFromGlobalState(requestData.PublicKeyBase58Check)
if err != nil {
_AddBadRequestError(ww, fmt.Sprintf("SubmitPhoneNumberVerificationCode: Problem with getUserMetadataFromGlobalState: %v", err))
return
}
settingPhoneNumberForFirstTime := userMetadata.PhoneNumber == ""
userMetadata.PhoneNumber = requestData.PhoneNumber
// TODO: do we want to require users who got money from twilio to go through the tutorial?
//userMetadata.MustPurchaseCreatorCoin = true
err = fes.putUserMetadataInGlobalState(userMetadata)
if err != nil {
_AddBadRequestError(ww, fmt.Sprintf("SubmitPhoneNumberVerificationCode: Error putting usermetadata in Global state: %v", err))
return
}
// Update / save phoneNumberMetadata in global state
multiPhoneNumberMetadata, err := fes.getMultiPhoneNumberMetadataFromGlobalState(requestData.PhoneNumber)
if err != nil {
_AddBadRequestError(ww, fmt.Sprintf("SubmitPhoneNumberVerificationCode: Error with getPhoneNumberMetadataFromGlobalState: %v", err))
return
}
phoneNumberMetadata := &PhoneNumberMetadata{
PublicKey: userMetadata.PublicKey,
PhoneNumber: requestData.PhoneNumber,
ShouldCompProfileCreation: true,
}
// Parse the raw phone number
parsedNumber, err := phonenumbers.Parse(phoneNumberMetadata.PhoneNumber, "")
if err != nil {
_AddBadRequestError(ww, fmt.Sprintf("GlobalStateKeyForPhoneNumberStringToPhoneNumberMetadata: Problem with phonenumbers.Parse: %v", err))
return
}
if parsedNumber.CountryCode != nil {
phoneNumberMetadata.PhoneNumberCountryCode =
phonenumbers.GetRegionCodeForCountryCode(int(*parsedNumber.CountryCode))
}
// Append the new phone number to the metadata
multiPhoneNumberMetadata = append(multiPhoneNumberMetadata, phoneNumberMetadata)
if err = fes.putPhoneNumberMetadataInGlobalState(multiPhoneNumberMetadata, requestData.PhoneNumber); err != nil {
_AddBadRequestError(ww, fmt.Sprintf("SubmitPhoneNumberVerificationCode: Problem with putPhoneNumberMetadataInGlobalState: %v", err))
return
}
/**************************************************************/
// Send the user starter DeSo, if we haven't already sent it
/**************************************************************/
if settingPhoneNumberForFirstTime && fes.Config.StarterDESOSeed != "" {
amountToSendNanos := fes.Config.StarterDESONanos
if len(requestData.PhoneNumber) == 0 || requestData.PhoneNumber[0] != '+' {
_AddBadRequestError(ww, fmt.Sprintf("SubmitPhoneNumberVerificationCode: Phone number must start with a plus sign"))
return
}
if requestData.PhoneNumber != "" {
// We sort the country codes by size, with the longest prefix
// first so that we match on the longest prefix when we iterate.
sortedPrefixExceptionMap := []string{}
for countryCodePrefix := range fes.Config.StarterPrefixNanosMap {
sortedPrefixExceptionMap = append(sortedPrefixExceptionMap, countryCodePrefix)
}
sort.Slice(sortedPrefixExceptionMap, func(ii, jj int) bool {
return len(sortedPrefixExceptionMap[ii]) > len(sortedPrefixExceptionMap[jj])
})
for _, countryPrefix := range sortedPrefixExceptionMap {
amountForPrefix := fes.Config.StarterPrefixNanosMap[countryPrefix]
if strings.Contains(requestData.PhoneNumber, countryPrefix) {
amountToSendNanos = amountForPrefix
break
}
}
}
var txnHash *lib.BlockHash
txnHash, err = fes.SendSeedDeSo(userMetadata.PublicKey, amountToSendNanos, false)
if err != nil {
_AddBadRequestError(ww, fmt.Sprintf("SubmitPhoneNumberVerificationCode: Error sending seed DeSo: %v", err))
return
}
res := SubmitPhoneNumberVerificationCodeResponse{
TxnHashHex: txnHash.String(),
}
if err = json.NewEncoder(ww).Encode(res); err != nil {
_AddBadRequestError(ww, fmt.Sprintf("SubmitPhoneNumberVerificationCode: Problem encoding response: %v", err))
return
}
}
}
type ResendVerifyEmailRequest struct {
PublicKey string
JWT string
}
func (fes *APIServer) ResendVerifyEmail(ww http.ResponseWriter, req *http.Request) {
decoder := json.NewDecoder(io.LimitReader(req.Body, MaxRequestBodySizeBytes))
requestData := ResendVerifyEmailRequest{}
if err := decoder.Decode(&requestData); err != nil {
_AddBadRequestError(ww, fmt.Sprintf("ResendVerifyEmail: Problem parsing request body: %v", err))
return
}
if !fes.IsConfiguredForSendgrid() {
_AddBadRequestError(ww, "ResendVerifyEmail: Sendgrid not configured")
return
}
isValid, err := fes.ValidateJWT(requestData.PublicKey, requestData.JWT)
if !isValid {
_AddBadRequestError(ww, fmt.Sprintf("ResendVerifyEmail: Invalid token: %v", err))
return
}
userPublicKeyBytes, _, err := lib.Base58CheckDecode(requestData.PublicKey)
if err != nil {
_AddBadRequestError(ww, fmt.Sprintf("ResendVerifyEmail: Invalid public key: %v", err))
return
}
userMetadata, err := fes.getUserMetadataFromGlobalState(lib.PkToString(userPublicKeyBytes, fes.Params))
if err != nil {
_AddBadRequestError(ww, fmt.Sprintf("ResendVerifyEmail: Problem with getUserMetadataFromGlobalState: %v", err))
return
}
if userMetadata.Email == "" {
_AddBadRequestError(ww, "ResendVerifyEmail: Email missing")
return
}
fes.sendVerificationEmail(userMetadata.Email, requestData.PublicKey)
}
type VerifyEmailRequest struct {
PublicKey string
EmailHash string
}
func (fes *APIServer) VerifyEmail(ww http.ResponseWriter, req *http.Request) {
decoder := json.NewDecoder(io.LimitReader(req.Body, MaxRequestBodySizeBytes))
requestData := VerifyEmailRequest{}
if err := decoder.Decode(&requestData); err != nil {
_AddBadRequestError(ww, fmt.Sprintf("VerifyEmail: Problem parsing request body: %v", err))
return
}
userPublicKeyBytes, _, err := lib.Base58CheckDecode(requestData.PublicKey)
if err != nil {
_AddBadRequestError(ww, fmt.Sprintf("VerifyEmail: Invalid public key: %v", err))
return
}
// Now that we have a public key, update the global state object.
userMetadata, err := fes.getUserMetadataFromGlobalState(lib.PkToString(userPublicKeyBytes, fes.Params))
if err != nil {
_AddBadRequestError(ww, fmt.Sprintf("VerifyEmail: Problem with getUserMetadataFromGlobalState: %v", err))
return
}
validHash := fes.verifyEmailHash(userMetadata.Email, requestData.PublicKey)
if requestData.EmailHash != validHash {
_AddBadRequestError(ww, fmt.Sprintf("VerifyEmail: Invalid hash: %s", requestData.EmailHash))
return
}
userMetadata.EmailVerified = true
err = fes.putUserMetadataInGlobalState(userMetadata)
if err != nil {
_AddBadRequestError(ww, fmt.Sprintf("VerifyEmail: Failed to save user: %v", err))
return
}
}
func (fes *APIServer) sendVerificationEmail(emailAddress string, publicKey string) {
email := mail.NewV3Mail()
email.SetTemplateID(fes.Config.SendgridConfirmEmailId)
from := mail.NewEmail(fes.Config.SendgridFromName, fes.Config.SendgridFromEmail)
email.SetFrom(from)
p := mail.NewPersonalization()
tos := []*mail.Email{
mail.NewEmail("", emailAddress),
}
p.AddTos(tos...)
hash := fes.verifyEmailHash(emailAddress, publicKey)
confirmUrl := fmt.Sprintf("%s/verify-email/%s/%s", fes.Config.SendgridDomain, publicKey, hash)
p.SetDynamicTemplateData("confirm_url", confirmUrl)
email.AddPersonalizations(p)
fes.sendEmail(email)
}
func (fes *APIServer) verifyEmailHash(emailAddress string, publicKey string) string {
hashBytes := []byte(emailAddress)
hashBytes = append(hashBytes, []byte(publicKey)...)
hashBytes = append(hashBytes, []byte(fes.Config.SendgridSalt)...)
return lib.Sha256DoubleHash(hashBytes).String()[:8]
}
func (fes *APIServer) sendEmail(email *mail.SGMailV3) {
if !fes.IsConfiguredForSendgrid() {
return
}
request := sendgrid.GetRequest(fes.Config.SendgridApiKey, "/v3/mail/send", "https://api.sendgrid.com")
request.Method = "POST"
request.Body = mail.GetRequestBody(email)
response, err := sendgrid.API(request)
if err != nil {
glog.Errorf("%v: %v", err, response)
}
}
func (fes *APIServer) IsConfiguredForSendgrid() bool {
return fes.Config.SendgridApiKey != ""
}
//
// JUMIO
//
func (fes *APIServer) IsConfiguredForJumio() bool {
return fes.Config.JumioToken != "" && fes.Config.JumioSecret != ""
}
type JumioInitRequest struct {
CustomerInternalReference string `json:"customerInternalReference"`
UserReference string `json:"userReference"`
SuccessURL string `json:"successUrl"`
ErrorURL string `json:"errorUrl"`
}
type JumioInitResponse struct {
RedirectURL string `json:"redirectUrl"`
TransactionReference string `json:"transactionReference"`
}
type JumioBeginRequest struct {
PublicKey string
ReferralHashBase58 string
SuccessURL string
ErrorURL string
JWT string
}
type JumioBeginResponse struct {
URL string
}
func (fes *APIServer) JumioBegin(ww http.ResponseWriter, req *http.Request) {
decoder := json.NewDecoder(io.LimitReader(req.Body, MaxRequestBodySizeBytes))
requestData := JumioBeginRequest{}
if err := decoder.Decode(&requestData); err != nil {
_AddBadRequestError(ww, fmt.Sprintf("JumioBegin: Problem parsing request body: %v", err))
return
}
isValid, err := fes.ValidateJWT(requestData.PublicKey, requestData.JWT)
if err != nil {
_AddBadRequestError(ww, fmt.Sprintf("JumioBegin: Error validating JWT: %v", err))
return
}
if !isValid {
_AddBadRequestError(ww, fmt.Sprintf("JumioBegin: Invalid token: %v", err))
return
}
// Get UserMetadata from global state and check that user has not already been through Jumio Verification flow
userMetadata, err := fes.getUserMetadataFromGlobalState(requestData.PublicKey)
if err != nil {
_AddBadRequestError(ww, fmt.Sprintf("JumioBegin: Problem getting user metadata from global state: %v", err))
return
}
if userMetadata.JumioVerified {
_AddBadRequestError(ww, fmt.Sprintf("JumioBegin: public key already went through jumio verification flow: %v", requestData.PublicKey))
return
}
if userMetadata.JumioFinishedTime > 0 && !userMetadata.JumioReturned {
_AddBadRequestError(ww, fmt.Sprintf("JumioBegin: please wait for Jumio to finish processing your existing verification attempt before retrying."))
return
}
if requestData.ReferralHashBase58 != "" {
var referralInfo *ReferralInfo
referralInfo, err = fes.getInfoForReferralHashBase58(requestData.ReferralHashBase58)
if err != nil {
glog.Errorf("JumioBegin: Error getting referral info: %v", err)
} else if referralInfo != nil {
userMetadata.ReferralHashBase58Check = requestData.ReferralHashBase58
referralInfo.NumJumioAttempts++
if err = fes.putReferralHashWithInfo(referralInfo.ReferralHashBase58, referralInfo); err != nil {
glog.Errorf("JumioBegin: Error updating referral info: %v", err)
}
}
}
tStampNanos := int(time.Now().UnixNano())
jumioInternalReference := requestData.PublicKey + strconv.Itoa(tStampNanos)
userMetadata.JumioInternalReference = jumioInternalReference
userMetadata.JumioFinishedTime = 0
userMetadata.JumioReturned = false
if err = fes.putUserMetadataInGlobalState(userMetadata); err != nil {
_AddBadRequestError(ww, fmt.Sprintf("JumioBegin: error putting jumio internal reference in global state: %v", err))
return
}
eventDataMap := make(map[string]interface{})
eventDataMap["referralCode"] = requestData.ReferralHashBase58
if err = fes.logAmplitudeEvent(requestData.PublicKey, "jumio : begin", eventDataMap); err != nil {
glog.Errorf("JumioBegin: Error logging Jumio Begin in amplitude: %v", err)
}
// CustomerInternalReference is Public Key + timestamp
// UserReference is just PublicKey
initData := &JumioInitRequest{
CustomerInternalReference: jumioInternalReference,
UserReference: requestData.PublicKey,
SuccessURL: requestData.SuccessURL,
ErrorURL: requestData.ErrorURL,
}
// Marshal the Jumio payload
jsonData, err := json.Marshal(initData)
if err != nil {
_AddBadRequestError(ww, fmt.Sprintf("JumioBegin: JSON invalid: %v", err))
return
}
// Create the request
req, err = http.NewRequest("POST", "https://netverify.com/api/v4/initiate", bytes.NewBuffer(jsonData))
if err != nil {
_AddBadRequestError(ww, fmt.Sprintf("JumioBegin: Request creation failed: %v", err))
return
}
// Set content-type and basic authentication (token, secret)
req.Header.Set("Content-Type", "application/json")
req.SetBasicAuth(fes.Config.JumioToken, fes.Config.JumioSecret)
// Make the request
postRes, err := http.DefaultClient.Do(req)
if err != nil {
_AddBadRequestError(ww, fmt.Sprintf("JumioBegin: Request failed: %v", err))
return
}
if postRes.StatusCode != 200 {
defer postRes.Body.Close()
// Decode the response into the appropriate struct.
body, _ := ioutil.ReadAll(postRes.Body)
_AddBadRequestError(ww, fmt.Sprintf("JumioBegin: Request returned non-200 status code: %v, %v", postRes.StatusCode, string(body)))
return
}
// Decode the response
jumioInit := JumioInitResponse{}
if err = json.NewDecoder(postRes.Body).Decode(&jumioInit); err != nil {
_AddBadRequestError(ww, fmt.Sprintf("JumioBegin: Decode failed: %v", err))
return
}
if err = postRes.Body.Close(); err != nil {
_AddBadRequestError(ww, fmt.Sprintf("JumioBegin: Closing post request body failed: %v", err))
return
}
res := JumioBeginResponse{
URL: jumioInit.RedirectURL,
}
if err = json.NewEncoder(ww).Encode(res); err != nil {
_AddBadRequestError(ww, fmt.Sprintf("JumioBegin: Encode failed: %v", err))
return
}
}
type JumioFlowFinishedRequest struct {
PublicKey string
JumioInternalReference string
JWT string
}
func (fes *APIServer) JumioFlowFinished(ww http.ResponseWriter, req *http.Request) {
decoder := json.NewDecoder(io.LimitReader(req.Body, MaxRequestBodySizeBytes))
requestData := JumioFlowFinishedRequest{}
if err := decoder.Decode(&requestData); err != nil {
_AddBadRequestError(ww, fmt.Sprintf("JumioFlowFinished: Problem parsing request body: %v", err))
return
}
isValid, err := fes.ValidateJWT(requestData.PublicKey, requestData.JWT)
if err != nil {
_AddBadRequestError(ww, fmt.Sprintf("JumioFlowFinished: Error validating JWT: %v", err))
return
}
if !isValid {
_AddBadRequestError(ww, fmt.Sprintf("JumioFlowFinished: Invalid token: %v", err))
return
}
// Get UserMetadata from global state and check internal reference matches and we haven't marked this user as finished already.
userMetadata, err := fes.getUserMetadataFromGlobalState(requestData.PublicKey)
if err != nil {
_AddBadRequestError(ww, fmt.Sprintf("JumioFlowFinished: Problem getting user metadata from global state: %v", err))
return
}
if userMetadata.JumioInternalReference != requestData.JumioInternalReference {
_AddBadRequestError(ww, fmt.Sprintf("JumioFlowFinished: UserMetadata's jumio internal reference (%v) does not match value from payload (%v)", userMetadata.JumioInternalReference, requestData.JumioInternalReference))
return
}
userMetadata.JumioFinishedTime = uint64(time.Now().UnixNano())
if err = fes.putUserMetadataInGlobalState(userMetadata); err != nil {
_AddBadRequestError(ww, fmt.Sprintf("JumioFlowFinished: Error putting jumio finish time in global state: %v", err))
return
}
}
type JumioIdentityVerification struct {
Similarity string `json:"similarity"`
Validity bool `json:"validity"`
Reason string `json:"reason"`
}
type JumioRejectReason struct {
RejectReasonCode string `json:"rejectReasonCode"`
RejectReasonDescription string `json:"rejectReasonDescription"`
RejectReasonDetails interface{} `json:"rejectReasonDetails"`
}
// Jumio webhook - If Jumio verified user is a human that we haven't paid already, pay them some starter DESO.
// Make sure you only allow access to jumio IPs for this endpoint, otherwise anybody can take all the funds from
// the public key that sends DeSo. WHITELIST JUMIO IPs.
func (fes *APIServer) JumioCallback(ww http.ResponseWriter, req *http.Request) {
if err := req.ParseForm(); err != nil {
_AddBadRequestError(ww, fmt.Sprintf("JumioCallback: Problem parsing form: %v", err))
return
}
// Convert the post form of the request into a map of string keys to string slice values
payloadMap := make(map[string][]string)
for k, v := range req.PostForm {
payloadMap[k] = v
}
// PASSPORT, DRIVING_LICENSE, ID_CARD, VISA
idType := req.PostFormValue("idType")
// More specific type of ID
idSubType := req.PostFormValue("idSubtype")
// Country of ID
idCountry := req.PostFormValue("idCountry")
// Identifier on ID - e.g. Driver's license number for DRIVING_LICENSE, Passport number for PASSPORT
idNumber := req.PostFormValue("idNumber")
// customerId maps to the userReference passed when creating the Jumio session. userReference represents Public Key
userReference := req.PostFormValue("customerId")
// Jumio TransactionID
jumioTransactionId := req.PostFormValue("jumioIdScanReference")
// Verification status
verificationStatus := req.FormValue("verificationStatus")
// Get Public key bytes and PKID
if userReference == "" {
_AddBadRequestError(ww, fmt.Sprintf("JumioCallback: Public key (customerId) is required"))
return
}
publicKeyBytes, _, err := lib.Base58CheckDecode(userReference)
if err != nil {
_AddBadRequestError(ww, fmt.Sprintf("JumioCallback: Problem decoding user public key (customerId): %v", err))
return
}
utxoView, err := fes.backendServer.GetMempool().GetAugmentedUniversalView()
if err != nil {
_AddBadRequestError(ww, fmt.Sprintf("JumioCallback: error getting utxoview: %v", err))
return
}
pkid := utxoView.GetPKIDForPublicKey(publicKeyBytes)
if pkid == nil {
_AddBadRequestError(ww, fmt.Sprintf("JumioCallback: No PKID found for public key: %v", userReference))
return
}
var userMetadata *UserMetadata
userMetadata, err = fes.getUserMetadataFromGlobalState(userReference)
if err != nil {
_AddBadRequestError(ww, fmt.Sprintf("JumioCallback: Error getting user metadata from global state: %v", err))
return
}
// If the user is already jumio verified, this is an error and we shouldn't pay them again.
if userMetadata.JumioVerified {
_AddBadRequestError(ww, fmt.Sprintf("JumioCallback: User already verified: %v", err))
return
}
// Always set JumioReturned so we know that Jumio callback has finished.
userMetadata.JumioReturned = true
// Map of data for amplitude
eventDataMap := make(map[string]interface{})
eventDataMap["referralCode"] = userMetadata.ReferralHashBase58Check
eventDataMap["verificationStatus"] = verificationStatus
// If verification status is DENIED_FRAUD or ERROR_NOT_READABLE_ID, parse the rejection reason
// See description of rejectReason here:
// https://github.com/Jumio/implementation-guides/blob/master/netverify/callback.md#parameters
if verificationStatus == "DENIED_FRAUD" || verificationStatus == "ERROR_NOT_READABLE_ID" {
rejectReason := req.FormValue("rejectReason")
var jumioRejectReason JumioRejectReason
if err = json.Unmarshal([]byte(rejectReason), &jumioRejectReason); err != nil {
glog.Errorf("JumioCallback: error unmarshaling reject reason: %v", err)
} else {
eventDataMap["rejectReason"] = jumioRejectReason
}
}
if req.FormValue("idScanStatus") != "SUCCESS" {
glog.Infof("JumioCallback: idScanStatus was %s, not paying user with public key %s", req.FormValue("idScanStatus"), userReference)
if err = fes.logAmplitudeEvent(userReference, "jumio : callback : scan : fail", eventDataMap); err != nil {
glog.Errorf("JumioCallback: Error logging failed scan in amplitude: %v", err)
}
// This means the scan failed. We save that Jumio returned and bail.
if err = fes.putUserMetadataInGlobalState(userMetadata); err != nil {
_AddBadRequestError(ww, fmt.Sprintf("JumioCallback: Error putting user metdata in global state: %v", err))
}
return
}
if len(req.Form["livenessImages"]) == 0 {
glog.Infof("JumioCallback: No liveness images, not paying user with public key %s", userReference)
if err = fes.logAmplitudeEvent(userReference, "jumio : callback : liveness : fail", eventDataMap); err != nil {
glog.Errorf("JumioCallback: Error logging failed scan in amplitude: %v", err)
}
// This means there wasn't a liveness check. We save that Jumio returned and bail.
if err = fes.putUserMetadataInGlobalState(userMetadata); err != nil {
_AddBadRequestError(ww, fmt.Sprintf("JumioCallback: Error putting user metdata in global state: %v", err))
}
return
}
identityVerification := req.FormValue("identityVerification")
if identityVerification == "" {
_AddBadRequestError(ww, fmt.Sprintf("JumioCallback: identityVerification must be present"))
return
}
var jumioIdentityVerification JumioIdentityVerification
if err = json.Unmarshal([]byte(identityVerification), &jumioIdentityVerification); err != nil {
_AddBadRequestError(ww, fmt.Sprintf("JumioCallback: error unmarshal identity verification"))
return
}
if jumioIdentityVerification.Validity != true || jumioIdentityVerification.Similarity != "MATCH" {
glog.Infof("JumioCallback: Validity %t and Similarity %s for public key %s",
jumioIdentityVerification.Validity, jumioIdentityVerification.Similarity, userReference)
// Don't raise an exception, but do not pay this user.
if err = fes.logAmplitudeEvent(userReference, "jumio : callback : verification : fail", eventDataMap); err != nil {
glog.Errorf("JumioCallback: Error logging failed verification in amplitude: %v", err)
}
// This means the verification failed. We've logged the payload in global state above, so now we bail.
if err = fes.putUserMetadataInGlobalState(userMetadata); err != nil {
_AddBadRequestError(ww, fmt.Sprintf("JumioCallback: Error putting user metdata in global state: %v", err))
}
return
}
// Make sure this id hasn't been verified before.
uniqueJumioKey := GlobalStateKeyForCountryIDDocumentTypeSubTypeDocumentNumber(idCountry, idType, idSubType, idNumber)
// We expect badger to return a key not found error if this document has not been verified before.
// If it does not return an error, this is a duplicate, so we skip ahead.
if val, _ := fes.GlobalState.Get(uniqueJumioKey); val == nil || userMetadata.RedoJumio {
if err = fes.logAmplitudeEvent(userReference, "jumio : callback : verified", eventDataMap); err != nil {
glog.Errorf("JumioCallback: Error logging successful verification in amplitude: %v", err)
}
userMetadata, err = fes.JumioVerifiedHandler(userMetadata, jumioTransactionId, idCountry, publicKeyBytes, utxoView)
if err != nil {
glog.Errorf("JumioCallback: Error in JumioVerifiedHandler: %v", err)
}
if err = fes.GlobalState.Put(uniqueJumioKey, []byte{1}); err != nil {
_AddBadRequestError(ww, fmt.Sprintf("JumioCallback: Error putting unique jumio key in global state: %v", err))
return
}
} else {
glog.Infof("JumioCallback: Duplicate detected for public key %s", userReference)
if err = fes.logAmplitudeEvent(userReference, "jumio : callback : verified : duplicate", eventDataMap); err != nil {
glog.Errorf("JumioCallback: Error logging duplicate verification in amplitude: %v", err)
}
}
if err = fes.putUserMetadataInGlobalState(userMetadata); err != nil {
_AddBadRequestError(ww, fmt.Sprintf("JumioCallback: Error updating user metadata in global state: %v", err))
return
}
}
// GetDefaultJumioCountrySignUpBonus returns the default sign-up bonus configuration.
func (fes *APIServer) GetDefaultJumioCountrySignUpBonus() CountryLevelSignUpBonus {
return CountryLevelSignUpBonus{
AllowCustomKickbackAmount: false,
AllowCustomReferralAmount: false,
ReferralAmountOverrideUSDCents: fes.JumioUSDCents,
KickbackAmountOverrideUSDCents: fes.JumioKickbackUSDCents,
}
}
// GetJumioCountrySignUpBonus gets the country level sign up bonus configuration for the provided country code. If
// there is an error or there is no sign up bonus configuration for a given country, return the default sign-up bonus
// configuration.
func (fes *APIServer) GetJumioCountrySignUpBonus(countryCode string) (_signUpBonus CountryLevelSignUpBonus, _err error) {
key := GlobalStateKeyForCountryCodeToCountrySignUpBonus(countryCode)
jumioCountrySignUpBonusMetadataBytes, err := fes.GlobalState.Get(key)
if err != nil {
return fes.GetDefaultJumioCountrySignUpBonus(), fmt.Errorf(
"GetJumioCountrySignUpBonus: error getting sign up bonus metadata from global state for %s: %v",
countryCode, err)
}
var signUpBonus CountryLevelSignUpBonus
if jumioCountrySignUpBonusMetadataBytes != nil {
if err = gob.NewDecoder(bytes.NewReader(jumioCountrySignUpBonusMetadataBytes)).Decode(
&signUpBonus); err != nil {
return fes.GetDefaultJumioCountrySignUpBonus(), fmt.Errorf(
"GetJumioCountrySignUpBonus: Failed decoding signup bonus metadata (%s): %v",
countryCode, err)
}
return signUpBonus, nil
} else {
// We were unable to find a country, return the default
return fes.GetDefaultJumioCountrySignUpBonus(), nil
}
}
func (fes *APIServer) GetCountryLevelSignUpBonusFromHeader(req *http.Request) (_signUpBonus CountryLevelSignUpBonus) {
// Extract CF-IPCountry header
countryCodeAlpha2 := req.Header.Get("CF-IPCountry")
// If we have a valid country code alpha 2 value, look up the sign up bonus config for the alpha2 code
// Note: XX is used for clients without country code data
// Note: T1 is used for clients using the tor network
if countryCodeAlpha2 != "" && countryCodeAlpha2 != "XX" && countryCodeAlpha2 != "T1" {
return fes.GetCountryLevelSignUpBonusFromAlpha2(countryCodeAlpha2)
}
return fes.GetDefaultJumioCountrySignUpBonus()
}
func (fes *APIServer) GetCountryLevelSignUpBonusFromAlpha2(countryCodeAlpha2 string) (_signUpBonus CountryLevelSignUpBonus) {
countrySignUpBonus := fes.GetDefaultJumioCountrySignUpBonus()
if alpha3, exists := countries.Alpha2ToAlpha3[countryCodeAlpha2]; exists {
countrySignUpBonus = fes.GetSingleCountrySignUpBonus(alpha3)
}
return countrySignUpBonus
}
// GetRefereeSignUpBonusAmount gets the amount the referee should get a sign-up bonus for verifying with Jumio based on
// the country of their ID.
func (fes *APIServer) GetRefereeSignUpBonusAmount(signUpBonus CountryLevelSignUpBonus, referralCodeUSDCents uint64) uint64 {
amount := signUpBonus.ReferralAmountOverrideUSDCents
if signUpBonus.AllowCustomReferralAmount && referralCodeUSDCents > amount {
amount = referralCodeUSDCents
}
return fes.GetNanosFromUSDCents(float64(amount), 0)
}
// GetReferrerSignUpBonusAmount gets the amount the referrer should get as a kickback for referring the user based
// on the country from which the referee signed up.
func (fes *APIServer) GetReferrerSignUpBonusAmount(signUpBonus CountryLevelSignUpBonus, referralCodeUSDCents uint64) uint64 {
amount := signUpBonus.KickbackAmountOverrideUSDCents
if signUpBonus.AllowCustomKickbackAmount && referralCodeUSDCents > amount {
amount = referralCodeUSDCents
}
return fes.GetNanosFromUSDCents(float64(amount), 0)
}
func (fes *APIServer) JumioVerifiedHandler(userMetadata *UserMetadata, jumioTransactionId string,
jumioCountryCode string, publicKeyBytes []byte, utxoView *lib.UtxoView) (_userMetadata *UserMetadata, err error) {
// Update the user metadata to show that user has been jumio verified and store jumio transaction id.
userMetadata.JumioVerified = true
userMetadata.JumioTransactionID = jumioTransactionId
userMetadata.JumioShouldCompProfileCreation = true
userMetadata.MustCompleteTutorial = true
userMetadata.RedoJumio = false
// We will always get a valid signUpBonusMetadataObject, so glog the error and proceed.
signUpBonusMetadata := fes.GetSingleCountrySignUpBonus(jumioCountryCode)
// Decide whether or not the user is going to get paid.