Skip to content
This repository was archived by the owner on Jul 12, 2023. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pkg/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
86 changes: 54 additions & 32 deletions pkg/controller/issueapi/issue.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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
}
}
}
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions pkg/controller/verifyapi/verify.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
})
Expand Down
20 changes: 20 additions & 0 deletions pkg/database/migrations.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
},
},
})
}

Expand Down
46 changes: 40 additions & 6 deletions pkg/database/token.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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 {
Expand All @@ -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 {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Won't think break existing JWT tokens that have the old subject?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the old if statement was wrong - it should have always been 2 parts before.

Why I didn't write length != 2 the first time, we'll never know.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not that I doubt your code, but this feels like a test.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

^ what Jeremy said

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

funny enough - the 2 segment subject was the only thing under test before.
test coverage has been boosted to cover all scenarios.

return nil, fmt.Errorf("subject must contain 2 or 3 parts, got: %v", length)
}
var symptomDate *time.Time
if parts[1] != "" {
Expand All @@ -88,24 +96,44 @@ 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 ""
}
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,
}
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
45 changes: 41 additions & 4 deletions pkg/database/token_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,21 +39,58 @@ func TestSubject(t *testing.T) {
Error string
}{
{
Name: "normal parse",
Name: "legacy_version",
Sub: "confirmed.2020-07-07",
Want: &Subject{
TestType: "confirmed",
SymptomDate: &testDay,
},
},
{
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 {
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand Down
8 changes: 7 additions & 1 deletion pkg/database/vercode.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down
Loading