Skip to content
This repository was archived by the owner on Jul 12, 2023. It is now read-only.

Commit b0f3c28

Browse files
committed
Require region codes are globally unique
This is required for the ENX redirector, but we've also always made the assumption that these need to be globally unique. Note that this requires changing the column to citext (for case-insensitive matching) and to NULL out the default of '', since '' is a duplicate of ''. It also handles any existing duplicates by appending -N to them before applying the database-level unique constraint.
1 parent 0525fd6 commit b0f3c28

File tree

9 files changed

+172
-65
lines changed

9 files changed

+172
-65
lines changed

cmd/server/assets/realmadmin/_form_abuse_prevention.html

+1-3
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
<form method="POST" action="/realm/settings#abuse-prevention" class="floating-form">
66
{{ .csrfField }}
7+
<input type="hidden" name="abuse_prevention" value="1" />
78

89
<p>
910
Abuse prevention uses the historical record of your realm's past
@@ -18,9 +19,6 @@
1819
</p>
1920

2021
<div class="form-group form-check">
21-
<!-- This forces the form to send a value of false (instead of no value at
22-
all), when abuse prevention is unchecked. -->
23-
<input type="hidden" name="abuse_prevention_enabled" value="0" />
2422
<input type="checkbox" name="abuse_prevention_enabled" id="abuse-prevention-enabled" class="form-check-input" value="1" {{if $realm.AbusePreventionEnabled}} checked{{end}}>
2523
<label class="form-check-label" for="abuse-prevention-enabled">
2624
Enable abuse prevention

cmd/server/assets/realmadmin/_form_codes.html

+1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
<form method="POST" action="/realm/settings#codes" class="floating-form">
1212
{{ .csrfField }}
13+
<input type="hidden" name="codes" value="1" />
1314

1415
<div class="form-group">
1516
<label>Allowed test types</label>

cmd/server/assets/realmadmin/_form_general.html

+4-1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
<form method="POST" action="/realm/settings#general" class="floating-form">
1111
{{ .csrfField }}
12+
<input type="hidden" name="general" value="1" />
1213

1314
<div class="form-label-group">
1415
<input type="text" name="name" id="name" class="form-control{{if $realm.ErrorsFor "name"}} is-invalid{{end}}"
@@ -22,11 +23,12 @@
2223
<small class="form-text text-muted">
2324
The realm name is displayed on the realm selection page and in the header
2425
when selected. Choose a descriptive name that your team will recognize.
26+
This value must be globally unique in the system.
2527
</small>
2628
</div>
2729

2830
<div class="form-label-group">
29-
<input type="text" name="region_code" id="region-code" class="form-control{{if $realm.ErrorsFor "regionCode"}} is-invalid{{end}}"
31+
<input type="text" name="region_code" id="region-code" class="form-control text-uppercase{{if $realm.ErrorsFor "regionCode"}} is-invalid{{end}}"
3032
value="{{$realm.RegionCode}}" placeholder="Region code" />
3133
<label for="region-code">Region code</label>
3234
{{if $realm.ErrorsFor "regionCode"}}
@@ -41,6 +43,7 @@
4143
<a href="https://en.wikipedia.org/wiki/List_of_ISO_3166_country_codes">ISO
4244
3166-1 country codes and ISO 3166-2 subdivision codes</a> where
4345
applicable. For example, Washington state would be <code>US-WA</code>.
46+
This value must globally unique in the system.
4447
</small>
4548
</div>
4649

cmd/server/assets/realmadmin/_form_security.html

+1
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
<form method="POST" action="/realm/settings#security" class="floating-form">
1010
{{ .csrfField }}
11+
<input type="hidden" name="security" value="1" />
1112

1213
<div class="form-group">
1314
<label for="email-verified-mode">Email verification</label>

cmd/server/assets/realmadmin/_form_sms.html

+1-1
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212
<form method="POST" action="/realm/settings#sms" class="floating-form">
1313
{{ .csrfField }}
14-
<input type="hidden" name="twilio_sms_config" value="1" />
14+
<input type="hidden" name="sms" value="1" />
1515

1616
<div class="form-label-group">
1717
<input type="text" name="twilio_account_sid" id="twilio-account-sid" class="form-control text-monospace"

pkg/controller/realmadmin/settings.go

+32-52
Original file line numberDiff line numberDiff line change
@@ -46,29 +46,33 @@ func init() {
4646

4747
func (c *Controller) HandleSettings() http.Handler {
4848
type FormData struct {
49+
General bool `form:"general"`
4950
Name string `form:"name"`
5051
RegionCode string `form:"region_code"`
5152
WelcomeMessage string `form:"welcome_message"`
5253

54+
Codes bool `form:"codes"`
5355
AllowedTestTypes database.TestType `form:"allowed_test_types"`
54-
RequireDate *bool `form:"require_date"`
56+
RequireDate bool `form:"require_date"`
5557
CodeLength uint `form:"code_length"`
5658
CodeDurationMinutes int64 `form:"code_duration"`
5759
LongCodeLength uint `form:"long_code_length"`
5860
LongCodeDurationHours int64 `form:"long_code_duration"`
5961
SMSTextTemplate string `form:"sms_text_template"`
6062

61-
TwilioSMSConfig bool `form:"twilio_sms_config"`
63+
SMS bool `form:"sms"`
6264
TwilioAccountSid string `form:"twilio_account_sid"`
6365
TwilioAuthToken string `form:"twilio_auth_token"`
6466
TwilioFromNumber string `form:"twilio_from_number"`
6567

66-
MFAMode *int16 `form:"mfa_mode"`
67-
EmailVerifiedMode *int16 `form:"email_verified_mode"`
68-
PasswordRotationPeriodDays uint `form:"password_rotation_period_days"`
69-
PasswordRotationWarningDays uint `form:"password_rotation_warning_days"`
68+
Security bool `form:"security"`
69+
MFAMode int16 `form:"mfa_mode"`
70+
EmailVerifiedMode int16 `form:"email_verified_mode"`
71+
PasswordRotationPeriodDays uint `form:"password_rotation_period_days"`
72+
PasswordRotationWarningDays uint `form:"password_rotation_warning_days"`
7073

71-
AbusePreventionEnabled *bool `form:"abuse_prevention_enabled"`
74+
AbusePrevention bool `form:"abuse_prevention"`
75+
AbusePreventionEnabled bool `form:"abuse_prevention_enabled"`
7276
AbusePreventionLimitFactor float32 `form:"abuse_prevention_limit_factor"`
7377
AbusePreventionBurst uint64 `form:"abuse_prevention_burst"`
7478
}
@@ -110,59 +114,35 @@ func (c *Controller) HandleSettings() http.Handler {
110114
}
111115

112116
// General
113-
if v := form.Name; v != "" {
114-
realm.Name = v
115-
}
116-
if v := form.RegionCode; v != "" {
117-
realm.RegionCode = v
118-
}
119-
if v := form.WelcomeMessage; v != "" {
120-
realm.WelcomeMessage = v
117+
if form.General {
118+
realm.Name = form.Name
119+
realm.RegionCode = form.RegionCode
120+
realm.WelcomeMessage = form.WelcomeMessage
121121
}
122122

123123
// Codes
124-
if v := form.AllowedTestTypes; v > 0 {
125-
realm.AllowedTestTypes = v
126-
}
127-
if v := form.RequireDate; v != nil {
128-
realm.RequireDate = *v
129-
}
130-
if v := form.CodeLength; v > 0 {
131-
realm.CodeLength = v
132-
}
133-
if v := form.CodeDurationMinutes; v > 0 {
134-
realm.CodeDuration.Duration = time.Duration(v) * time.Minute
135-
}
136-
if v := form.LongCodeLength; v > 0 {
137-
realm.LongCodeLength = v
138-
}
139-
if v := form.LongCodeDurationHours; v > 0 {
140-
realm.LongCodeDuration.Duration = time.Duration(v) * time.Minute
141-
}
142-
if v := form.SMSTextTemplate; v != "" {
143-
realm.SMSTextTemplate = v
124+
if form.Codes {
125+
realm.AllowedTestTypes = form.AllowedTestTypes
126+
realm.RequireDate = form.RequireDate
127+
realm.CodeLength = form.CodeLength
128+
realm.CodeDuration.Duration = time.Duration(form.CodeDurationMinutes) * time.Minute
129+
realm.LongCodeLength = form.LongCodeLength
130+
realm.LongCodeDuration.Duration = time.Duration(form.LongCodeDurationHours) * time.Hour
131+
realm.SMSTextTemplate = form.SMSTextTemplate
144132
}
145133

146134
// Security
147-
if v := form.EmailVerifiedMode; v != nil {
148-
realm.EmailVerifiedMode = database.AuthRequirement(*v)
149-
}
150-
if v := form.MFAMode; v != nil {
151-
realm.MFAMode = database.AuthRequirement(*v)
152-
}
153-
if v := form.PasswordRotationPeriodDays; v > 0 {
154-
realm.PasswordRotationPeriodDays = v
155-
}
156-
if v := form.PasswordRotationWarningDays; v > 0 {
157-
realm.PasswordRotationWarningDays = v
135+
if form.Security {
136+
realm.EmailVerifiedMode = database.AuthRequirement(form.EmailVerifiedMode)
137+
realm.MFAMode = database.AuthRequirement(form.MFAMode)
138+
realm.PasswordRotationPeriodDays = form.PasswordRotationPeriodDays
139+
realm.PasswordRotationWarningDays = form.PasswordRotationWarningDays
158140
}
159141

160142
// Abuse prevention
161-
if v := form.AbusePreventionEnabled; v != nil {
162-
realm.AbusePreventionEnabled = *v
163-
}
164-
if v := form.AbusePreventionLimitFactor; v > 0 {
165-
realm.AbusePreventionLimitFactor = v
143+
if form.AbusePrevention {
144+
realm.AbusePreventionEnabled = form.AbusePreventionEnabled
145+
realm.AbusePreventionLimitFactor = form.AbusePreventionLimitFactor
166146
}
167147

168148
// Save
@@ -172,7 +152,7 @@ func (c *Controller) HandleSettings() http.Handler {
172152
return
173153
}
174154

175-
if form.TwilioSMSConfig {
155+
if form.SMS {
176156
// Process SMS settings
177157
smsConfig, err := realm.SMSConfig(c.db)
178158
if err != nil && !database.IsNotFound(err) {

pkg/database/database.go

+17-1
Original file line numberDiff line numberDiff line change
@@ -415,7 +415,7 @@ func callbackKMSEncrypt(ctx context.Context, keyManager keys.KeyManager, keyID,
415415
}
416416
}
417417

418-
// callback HMAC alters HMACs the value with the given key before saving.
418+
// callbackHMAC alters HMACs the value with the given key before saving.
419419
func callbackHMAC(ctx context.Context, hashFunc func(string) (string, error), table, column string) func(scope *gorm.Scope) {
420420
return func(scope *gorm.Scope) {
421421
// Do nothing if not the target table
@@ -486,3 +486,19 @@ func withRetries(ctx context.Context, f retry.RetryFunc) error {
486486

487487
return retry.Do(ctx, b, f)
488488
}
489+
490+
// stringValue gets the value of the string pointer, returning "" for nil.
491+
func stringValue(s *string) string {
492+
if s == nil {
493+
return ""
494+
}
495+
return *s
496+
}
497+
498+
// stringPtr converts the string value to a pointer, returning nil for "".
499+
func stringPtr(s string) *string {
500+
if s == "" {
501+
return nil
502+
}
503+
return &s
504+
}

pkg/database/migrations.go

+91
Original file line numberDiff line numberDiff line change
@@ -1131,6 +1131,97 @@ func (db *Database) getMigrations(ctx context.Context) *gormigrate.Gormigrate {
11311131
return nil
11321132
},
11331133
},
1134+
{
1135+
ID: "00049-MakeRegionCodeUnique",
1136+
Migrate: func(tx *gorm.DB) error {
1137+
sqls := []string{
1138+
// Make region code case insensitive and unique.
1139+
"ALTER TABLE realms ALTER COLUMN region_code TYPE CITEXT",
1140+
"ALTER TABLE realms ALTER COLUMN region_code DROP DEFAULT",
1141+
"ALTER TABLE realms ALTER COLUMN region_code DROP NOT NULL",
1142+
}
1143+
for _, sql := range sqls {
1144+
if err := tx.Exec(sql).Error; err != nil {
1145+
return err
1146+
}
1147+
}
1148+
1149+
// Make any existing empty string region codes to NULL. Without this,
1150+
// the new unique constraint will fail.
1151+
if err := tx.Exec("UPDATE realms SET region_code = NULL WHERE TRIM(region_code) = ''").Error; err != nil {
1152+
return err
1153+
}
1154+
1155+
// Make all region codes uppercase.
1156+
if err := tx.Exec("UPDATE realms SET region_code = UPPER(region_code) WHERE region_code IS NOT NULL").Error; err != nil {
1157+
return err
1158+
}
1159+
1160+
// Find any existing duplicate region codes - this could be combined
1161+
// into a much larger SQL statement with the next thing, but I'm
1162+
// optimizing for readability here.
1163+
var dupRegionCodes []string
1164+
if err := tx.Model(&Realm{}).
1165+
Unscoped().
1166+
Select("UPPER(region_code) AS region_code").
1167+
Where("region_code IS NOT NULL").
1168+
Group("region_code").
1169+
Having("COUNT(*) > 1").
1170+
Pluck("region_code", &dupRegionCodes).
1171+
Error; err != nil {
1172+
return err
1173+
}
1174+
1175+
// Update any duplicate regions to not be duplicate anymore.
1176+
for _, dupRegionCode := range dupRegionCodes {
1177+
logger.Warn("de-duplicating region code %q", dupRegionCode)
1178+
1179+
// I call this the "Microsoft method". For each duplicate realm,
1180+
// append -N, starting with 1. If there are 3 realms with the region
1181+
// code "PA", their new values will be "PA", "PA-1", and "PA-2"
1182+
// respectively.
1183+
sql := `
1184+
UPDATE
1185+
realms
1186+
SET region_code = CONCAT(realms.region_code, '-', (z-1)::text)
1187+
FROM (
1188+
SELECT
1189+
id,
1190+
region_code,
1191+
ROW_NUMBER() OVER (ORDER BY id ASC) AS z
1192+
FROM realms
1193+
WHERE UPPER(region_code) = UPPER($1)
1194+
) AS sq
1195+
WHERE realms.id = sq.id AND sq.z > 1`
1196+
if err := tx.Exec(sql, dupRegionCode).Error; err != nil {
1197+
return err
1198+
}
1199+
}
1200+
1201+
sqls = []string{
1202+
// There's already a runtime constraint and validation on names, this
1203+
// is just an extra layer of protection at the database layer.
1204+
"ALTER TABLE realms ALTER COLUMN name SET NOT NULL",
1205+
1206+
// Alter the unique index on realm names to be a column constraint.
1207+
"DROP INDEX IF EXISTS uix_realms_name",
1208+
"ALTER TABLE realms ADD CONSTRAINT uix_realms_name UNIQUE (name)",
1209+
1210+
// Now finally add a unique constraint on region codes.
1211+
"ALTER TABLE realms ADD CONSTRAINT uix_realms_region_code UNIQUE (region_code)",
1212+
}
1213+
1214+
for _, sql := range sqls {
1215+
if err := tx.Exec(sql).Error; err != nil {
1216+
return err
1217+
}
1218+
}
1219+
return nil
1220+
},
1221+
Rollback: func(tx *gorm.DB) error {
1222+
return nil
1223+
},
1224+
},
11341225
})
11351226
}
11361227

pkg/database/realm.go

+24-7
Original file line numberDiff line numberDiff line change
@@ -80,20 +80,26 @@ type Realm struct {
8080
// Name is the name of the realm.
8181
Name string `gorm:"type:varchar(200);unique_index"`
8282

83+
// RegionCode is both a display attribute and required field for ENX. To
84+
// handle NULL and uniqueness, the field is converted from it's ptr type to a
85+
// concrete type in callbacks. Do not modify RegionCodePtr directly.
86+
RegionCode string `gorm:"-"`
87+
RegionCodePtr *string `gorm:"column:region_code; type:varchar(10);"`
88+
89+
// WelcomeMessage is arbitrary realm-defined data to display to users after
90+
// selecting this realm. If empty, nothing is displayed. The format is
91+
// markdown. Do not modify WelcomeMessagePtr directly.
92+
WelcomeMessage string `gorm:"-"`
93+
WelcomeMessagePtr *string `gorm:"column:welcome_message; type:text;"`
94+
8395
// Code configuration
84-
RegionCode string `gorm:"type:varchar(10); not null; default: ''"`
8596
CodeLength uint `gorm:"type:smallint; not null; default: 8"`
8697
CodeDuration DurationSeconds `gorm:"type:bigint; not null; default: 900"` // default 15m (in seconds)
8798
LongCodeLength uint `gorm:"type:smallint; not null; default: 16"`
8899
LongCodeDuration DurationSeconds `gorm:"type:bigint; not null; default: 86400"` // default 24h
89100
// SMS Content
90101
SMSTextTemplate string `gorm:"type:varchar(400); not null; default: 'This is your Exposure Notifications Verification code: [longcode] Expires in [longexpires] hours'"`
91102

92-
// WelcomeMessage is arbitrary realm-defined data to display to users after
93-
// selecting this realm. If empty, nothing is displayed. The format is
94-
// markdown.
95-
WelcomeMessage string `gorm:"type:text"`
96-
97103
// MFAMode represents the mode for Multi-Factor-Authorization requirements for the realm.
98104
MFAMode AuthRequirement `gorm:"type:smallint; not null; default: 0"`
99105

@@ -187,6 +193,14 @@ func (r *Realm) SigningKeyID() string {
187193
return fmt.Sprintf("realm-%d", r.ID)
188194
}
189195

196+
// AfterFind runs after a realm is found.
197+
func (r *Realm) AfterFind(tx *gorm.DB) error {
198+
r.RegionCode = stringValue(r.RegionCodePtr)
199+
r.WelcomeMessage = stringValue(r.WelcomeMessagePtr)
200+
201+
return nil
202+
}
203+
190204
// BeforeSave runs validations. If there are errors, the save fails.
191205
func (r *Realm) BeforeSave(tx *gorm.DB) error {
192206
r.Name = strings.TrimSpace(r.Name)
@@ -195,10 +209,13 @@ func (r *Realm) BeforeSave(tx *gorm.DB) error {
195209
}
196210

197211
r.RegionCode = strings.ToUpper(strings.TrimSpace(r.RegionCode))
198-
199212
if len(r.RegionCode) > 10 {
200213
r.AddError("regionCode", "cannot be more than 10 characters")
201214
}
215+
r.RegionCodePtr = stringPtr(r.RegionCode)
216+
217+
r.WelcomeMessage = strings.TrimSpace(r.WelcomeMessage)
218+
r.WelcomeMessagePtr = stringPtr(r.WelcomeMessage)
202219

203220
if r.EnableENExpress {
204221
if r.RegionCode == "" {

0 commit comments

Comments
 (0)