diff --git a/pkg/api/api.go b/pkg/api/api.go index ce78eafa0..61c5d7fd5 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -290,6 +290,7 @@ type VerifyCodeResponse struct { TestType string `json:"testtype,omitempty"` SymptomDate string `json:"symptomDate,omitempty"` // ISO 8601 formatted date, YYYY-MM-DD + TestDate string `json:"testDate,omitempty"` // ISO 8601 formatted date, YYYY-MM-DD VerificationToken string `json:"token,omitempty"` // JWT - signed, not encrypted. Error string `json:"error,omitempty"` ErrorCode string `json:"errorCode,omitempty"` diff --git a/pkg/controller/issueapi/issue.go b/pkg/controller/issueapi/issue.go index f5f0bcb0c..2ad45428f 100644 --- a/pkg/controller/issueapi/issue.go +++ b/pkg/controller/issueapi/issue.go @@ -34,8 +34,27 @@ import ( "go.opencensus.io/tag" ) -// Cache the UTC time.Location, to speed runtime. -var utc *time.Location +type dateParseSettings struct { + Name string + ParseError string + ValidateError string +} + +var ( + // Cache the UTC time.Location, to speed runtime. + utc *time.Location + + onsetSettings = dateParseSettings{ + Name: "symptom onset", + ParseError: "FAILED_TO_PROCESS_SYMPTOM_ONSET_DATE", + ValidateError: "SYMPTOM_ONSET_DATE_NOT_IN_VALID_RANGE", + } + testSettings = dateParseSettings{ + Name: "test", + ParseError: "FAILED_TO_PROCESS_TEST_DATE", + ValidateError: "TEST_DATE_NOT_IN_VALID_RANGE", + } +) func init() { var err error @@ -103,11 +122,6 @@ func (c *Controller) HandleIssue() http.Handler { return } - // Use the symptom onset date if given, otherwise fallback to test date. - if request.SymptomDate == "" { - request.SymptomDate = request.TestDate - } - authApp, user, err := c.getAuthorizationFromContext(r) if err != nil { c.h.RenderJSON(w, http.StatusUnauthorized, api.Error(err)) @@ -140,15 +154,14 @@ func (c *Controller) HandleIssue() http.Handler { ctx = observability.WithRealmID(ctx, realm.ID) // If this realm requires a date but no date was specified, return an error. - if request.SymptomDate == "" && realm.RequireDate { + if realm.RequireDate && request.SymptomDate == "" && request.TestDate == "" { c.h.RenderJSON(w, http.StatusBadRequest, api.Errorf("missing either test or symptom date").WithCode(api.ErrMissingDate)) blame = observability.BlameClient result = observability.ResultError("MISSING_REQUIRED_FIELDS") return } - // Validate that the request with the provided test type is valid for this - // realm. + // Validate that the request with the provided test type is valid for this realm. if !realm.ValidTestType(request.TestType) { c.h.RenderJSON(w, http.StatusBadRequest, api.Errorf("unsupported test type: %v", request.TestType)) @@ -186,29 +199,37 @@ func (c *Controller) HandleIssue() http.Handler { return } - var symptomDate *time.Time - if request.SymptomDate != "" { - if parsed, err := time.Parse("2006-01-02", request.SymptomDate); err != nil { - c.h.RenderJSON(w, http.StatusBadRequest, api.Errorf("failed to process symptom onset date: %v", err)) - blame = observability.BlameClient - result = observability.ResultError("FAILED_TO_PROCESS_SYMPTOM_ONSET_DATE") - return - } else { - // Max date is today (UTC time) and min date is AllowedTestAge ago, truncated. - maxDate := timeutils.UTCMidnight(time.Now()) - minDate := timeutils.Midnight(maxDate.Add(-1 * c.config.GetAllowedSymptomAge())) - - symptomDate, err = validateDate(parsed, minDate, maxDate, int(request.TZOffset)) - if err != nil { - err := fmt.Errorf("symptom onset date must be on/after %v and on/before %v %v", - minDate.Format("2006-01-02"), - maxDate.Format("2006-01-02"), - parsed.Format("2006-01-02"), - ) - c.h.RenderJSON(w, http.StatusBadRequest, api.Error(err)) + // Set up parallel arrays to leverage the observability reporting and connect the parse / valdiation errors + // to the correct date. + parsedDates := make([]*time.Time, 2) + input := []string{request.SymptomDate, request.TestDate} + dateSettings := []*dateParseSettings{&onsetSettings, &testSettings} + for i, d := range input { + if d != "" { + if parsed, err := time.Parse("2006-01-02", d); err != nil { + c.h.RenderJSON(w, http.StatusBadRequest, api.Errorf("failed to process %s date: %v", dateSettings[i].Name, err)) blame = observability.BlameClient - result = observability.ResultError("SYMPTOM_ONSET_DATE_NOT_IN_VALID_RANGE") + result = observability.ResultError(dateSettings[i].ParseError) return + } else { + // Max date is today (UTC time) and min date is AllowedTestAge ago, truncated. + maxDate := timeutils.UTCMidnight(time.Now()) + minDate := timeutils.Midnight(maxDate.Add(-1 * c.config.GetAllowedSymptomAge())) + + validatedDate, err := validateDate(parsed, minDate, maxDate, int(request.TZOffset)) + if err != nil { + err := fmt.Errorf("%s date must be on/after %v and on/before %v %v", + dateSettings[i].Name, + minDate.Format("2006-01-02"), + maxDate.Format("2006-01-02"), + parsed.Format("2006-01-02"), + ) + c.h.RenderJSON(w, http.StatusBadRequest, api.Error(err)) + blame = observability.BlameClient + result = observability.ResultError(dateSettings[i].ValidateError) + return + } + parsedDates[i] = validatedDate } } } @@ -264,7 +285,8 @@ func (c *Controller) HandleIssue() http.Handler { LongLength: realm.LongCodeLength, LongExpiresAt: longExpiryTime, TestType: request.TestType, - SymptomDate: symptomDate, + SymptomDate: parsedDates[0], + TestDate: parsedDates[1], MaxSymptomAge: c.config.GetAllowedSymptomAge(), IssuingUser: user, IssuingApp: authApp, diff --git a/pkg/controller/verifyapi/verify.go b/pkg/controller/verifyapi/verify.go index 70abf334f..402c05fd6 100644 --- a/pkg/controller/verifyapi/verify.go +++ b/pkg/controller/verifyapi/verify.go @@ -143,6 +143,7 @@ func (c *Controller) HandleVerify() http.Handler { c.h.RenderJSON(w, http.StatusOK, api.VerifyCodeResponse{ TestType: verificationToken.TestType, SymptomDate: verificationToken.FormatSymptomDate(), + TestDate: verificationToken.FormatTestDate(), VerificationToken: signedJWT, }) }) diff --git a/pkg/database/migrations.go b/pkg/database/migrations.go index 31e633e9b..bb490da18 100644 --- a/pkg/database/migrations.go +++ b/pkg/database/migrations.go @@ -1544,6 +1544,26 @@ func (db *Database) getMigrations(ctx context.Context) *gormigrate.Gormigrate { return nil }, }, + { + ID: "00062-AddTestDate", + Migrate: func(tx *gorm.DB) error { + logger.Debugw("adding verification code test date") + return tx.AutoMigrate(&VerificationCode{}).Error + }, + Rollback: func(tx *gorm.DB) error { + return tx.Exec("ALTER TABLE verification_codes DROP COLUMN IF EXISTS test_date").Error + }, + }, + { + ID: "00063-AddTokenTestDate", + Migrate: func(tx *gorm.DB) error { + logger.Debugw("adding token test date") + return tx.AutoMigrate(&Token{}).Error + }, + Rollback: func(tx *gorm.DB) error { + return tx.Exec("ALTER TABLE tokens DROP COLUMN IF EXISTS test_date").Error + }, + }, }) } diff --git a/pkg/database/token.go b/pkg/database/token.go index c4b3a4573..76aa692bb 100644 --- a/pkg/database/token.go +++ b/pkg/database/token.go @@ -50,6 +50,7 @@ type Token struct { TokenID string `gorm:"type:varchar(200); unique_index"` TestType string `gorm:"type:varchar(20)"` SymptomDate *time.Time + TestDate *time.Time Used bool `gorm:"default:false"` ExpiresAt time.Time } @@ -58,14 +59,21 @@ type Token struct { type Subject struct { TestType string SymptomDate *time.Time + TestDate *time.Time } func (s *Subject) String() string { - datePortion := "" + parts := make([]string, 3) + + parts[0] = s.TestType if s.SymptomDate != nil { - datePortion = s.SymptomDate.Format("2006-01-02") + parts[1] = s.SymptomDate.Format("2006-01-02") + } + if s.TestDate != nil { + parts[2] = s.TestDate.Format("2006-01-02") } - return s.TestType + "." + datePortion + + return strings.Join(parts, ".") } func (s *Subject) SymptomInterval() uint32 { @@ -77,8 +85,8 @@ func (s *Subject) SymptomInterval() uint32 { func ParseSubject(sub string) (*Subject, error) { parts := strings.Split(sub, ".") - if length := len(parts); length < 1 || length > 2 { - return nil, fmt.Errorf("subject must contain 2 parts, got: %v", length) + if length := len(parts); length < 2 || length > 3 { + return nil, fmt.Errorf("subject must contain 2 or 3 parts, got: %v", length) } var symptomDate *time.Time if parts[1] != "" { @@ -88,13 +96,24 @@ func ParseSubject(sub string) (*Subject, error) { } symptomDate = &parsedDate } + + var testDate *time.Time + if len(parts) == 3 && parts[2] != "" { + parsedDate, err := time.Parse("2006-01-02", parts[2]) + if err != nil { + return nil, fmt.Errorf("subject contains invalid test date: %w", err) + } + testDate = &parsedDate + } + return &Subject{ TestType: parts[0], SymptomDate: symptomDate, + TestDate: testDate, }, nil } -// FormatSymptomDate returns YYYY-MM-DD formatted test date, or "" if nil. +// FormatSymptomDate returns YYYY-MM-DD formatted symptom date, or "" if nil. func (t *Token) FormatSymptomDate() string { if t.SymptomDate == nil { return "" @@ -102,10 +121,19 @@ func (t *Token) FormatSymptomDate() string { return t.SymptomDate.Format("2006-01-02") } +// FormatTestDate returns YYYY-MM-DD formatted test date, or "" if nil. +func (t *Token) FormatTestDate() string { + if t.TestDate == nil { + return "" + } + return t.TestDate.Format("2006-01-02") +} + func (t *Token) Subject() *Subject { return &Subject{ TestType: t.TestType, SymptomDate: t.SymptomDate, + TestDate: t.TestDate, } } @@ -140,6 +168,11 @@ func (db *Database) ClaimToken(realmID uint, tokenID string, subject *Subject) e (tok.SymptomDate != nil && !tok.SymptomDate.Equal(*subject.SymptomDate)) { return ErrTokenMetadataMismatch } + if (tok.TestDate == nil && subject.TestDate != nil) || + (tok.TestDate != nil && subject.TestDate == nil) || + (tok.TestDate != nil && !tok.TestDate.Equal(*subject.TestDate)) { + return ErrTokenMetadataMismatch + } tok.Used = true return tx.Save(&tok).Error @@ -222,6 +255,7 @@ func (db *Database) VerifyCodeAndIssueToken(realmID uint, verCode string, accept TokenID: tokenID, TestType: vc.TestType, SymptomDate: vc.SymptomDate, + TestDate: vc.TestDate, Used: false, ExpiresAt: time.Now().UTC().Add(expireAfter), RealmID: realmID, diff --git a/pkg/database/token_test.go b/pkg/database/token_test.go index 863a45838..7ced2fd5f 100644 --- a/pkg/database/token_test.go +++ b/pkg/database/token_test.go @@ -39,7 +39,7 @@ func TestSubject(t *testing.T) { Error string }{ { - Name: "normal parse", + Name: "legacy_version", Sub: "confirmed.2020-07-07", Want: &Subject{ TestType: "confirmed", @@ -47,13 +47,50 @@ func TestSubject(t *testing.T) { }, }, { - Name: "no date", + Name: "legacy_version_no_date", Sub: "confirmed.", Want: &Subject{ TestType: "confirmed", SymptomDate: nil, }, }, + { + Name: "current_version_no_test_date", + Sub: "confirmed.2020-07-07.", + Want: &Subject{ + TestType: "confirmed", + SymptomDate: &testDay, + }, + }, + { + Name: "current_version_no_symptom_date", + Sub: "confirmed..2020-07-07", + Want: &Subject{ + TestType: "confirmed", + TestDate: &testDay, + }, + }, + { + Name: "all_fields", + Sub: "confirmed.2020-07-07.2020-07-07", + Want: &Subject{ + TestType: "confirmed", + SymptomDate: &testDay, + TestDate: &testDay, + }, + }, + { + Name: "invalid_segments", + Sub: "confirmed", + Want: nil, + Error: "subject must contain 2 or 3 parts, got: 1", + }, + { + Name: "too_many_segments", + Sub: "confirmed.date.date.whomp", + Want: nil, + Error: "subject must contain 2 or 3 parts, got: 4", + }, } for _, tc := range cases { @@ -196,7 +233,7 @@ func TestIssueToken(t *testing.T) { Accept: acceptConfirmed, ClaimError: ErrTokenMetadataMismatch.Error(), TokenAge: time.Hour, - Subject: &Subject{"negative", nil}, + Subject: &Subject{"negative", nil, nil}, }, { Name: "wrong_test_date", @@ -214,7 +251,7 @@ func TestIssueToken(t *testing.T) { Accept: acceptConfirmed, ClaimError: ErrTokenMetadataMismatch.Error(), TokenAge: time.Hour, - Subject: &Subject{"confirmed", &wrongSymptomDate}, + Subject: &Subject{"confirmed", &wrongSymptomDate, nil}, }, { Name: "unsupported_test_type", diff --git a/pkg/database/vercode.go b/pkg/database/vercode.go index 47e66f839..78518c95b 100644 --- a/pkg/database/vercode.go +++ b/pkg/database/vercode.go @@ -59,6 +59,7 @@ type VerificationCode struct { Claimed bool `gorm:"default:false"` TestType string `gorm:"type:varchar(20)"` SymptomDate *time.Time + TestDate *time.Time ExpiresAt time.Time LongExpiresAt time.Time IssuingUserID uint @@ -189,12 +190,17 @@ func (v *VerificationCode) Validate(maxAge time.Duration) error { if _, ok := ValidTestTypes[v.TestType]; !ok { return ErrInvalidTestType } + minSymptomDate := timeutils.UTCMidnight(now.Add(-1 * maxAge)) if v.SymptomDate != nil { - minSymptomDate := now.Add(-1 * maxAge).Truncate(oneDay) if minSymptomDate.After(*v.SymptomDate) { return ErrTestTooOld } } + if v.TestDate != nil { + if minSymptomDate.After(*v.TestDate) { + return ErrTestTooOld + } + } if now.After(v.ExpiresAt) || now.After(v.LongExpiresAt) { return ErrCodeAlreadyExpired } diff --git a/pkg/database/vercode_test.go b/pkg/database/vercode_test.go index 5aea7de4c..00a40201e 100644 --- a/pkg/database/vercode_test.go +++ b/pkg/database/vercode_test.go @@ -207,7 +207,7 @@ func TestVerCodeValidate(t *testing.T) { Err: ErrInvalidTestType, }, { - Name: "invalid_test_date", + Name: "invalid_symptom_date", Code: VerificationCode{ Code: "123456", LongCode: "123456", @@ -216,6 +216,16 @@ func TestVerCodeValidate(t *testing.T) { }, Err: ErrTestTooOld, }, + { + Name: "invalid_test_date", + Code: VerificationCode{ + Code: "123456", + LongCode: "123456", + TestType: "negative", + TestDate: &oldTest, + }, + Err: ErrTestTooOld, + }, { Name: "already_expired", Code: VerificationCode{ diff --git a/pkg/otp/code.go b/pkg/otp/code.go index ca5772e63..db7d48f7f 100644 --- a/pkg/otp/code.go +++ b/pkg/otp/code.go @@ -84,6 +84,7 @@ type Request struct { LongExpiresAt time.Time TestType string SymptomDate *time.Time + TestDate *time.Time MaxSymptomAge time.Duration IssuingUser *database.User IssuingApp *database.AuthorizedApp @@ -127,6 +128,7 @@ func (o *Request) Issue(ctx context.Context, retryCount uint) (string, string, s LongCode: longCode, TestType: strings.ToLower(o.TestType), SymptomDate: o.SymptomDate, + TestDate: o.TestDate, ExpiresAt: o.ShortExpiresAt, LongExpiresAt: o.LongExpiresAt, IssuingUserID: issuingUserID,