diff --git a/server/app/lexgo/integration_test.go b/server/app/lexgo/integration_test.go index ea237ab..6f44991 100644 --- a/server/app/lexgo/integration_test.go +++ b/server/app/lexgo/integration_test.go @@ -163,6 +163,55 @@ func TestMySQLLoginUsesStoredPasswordWithoutChangingCreationPolicy(t *testing.T) } } +// TestMySQLSessionLifetimeIsThirtyDays pins the login lifetime that both clients share. It checks +// the API response, the stored row and the privacy rule (only a digest is persisted) so changing the +// value without updating this test is impossible. +func TestMySQLSessionLifetimeIsThirtyDays(t *testing.T) { + if SessionLifetime != 30*24*time.Hour { + t.Fatalf("session lifetime %s, want 30 days", SessionLifetime) + } + db := testDB(t) + u := admin.SysUser{Username: randomName("sesslife"), Password: fixturePassword, RoleId: 2, Status: "2"} + if err := db.Create(&u).Error; err != nil { + t.Fatal(err) + } + r := Router(db, time.Now) + before := time.Now() + code, data := callAPI(t, r, "POST", "/api/v1/login", "", map[string]string{"username": u.Username, "password": fixturePassword}) + if code != 200 { + t.Fatalf("login status %d", code) + } + var login struct { + Token string `json:"token"` + ExpiresAt time.Time `json:"expiresAt"` + } + if err := json.Unmarshal(data, &login); err != nil { + t.Fatal(err) + } + granted := login.ExpiresAt.Sub(before) + if granted < SessionLifetime-time.Minute || granted > SessionLifetime+time.Minute { + t.Fatalf("granted %s, want about %s", granted, SessionLifetime) + } + var stored Session + if err := db.Where("token_hash = ?", digest(login.Token)).First(&stored).Error; err != nil { + t.Fatal("session row missing") + } + if stored.ExpiresAt.Sub(stored.ExpiresAt.Truncate(time.Millisecond)) != 0 { + t.Fatal("stored expiry lost precision") + } + if delta := login.ExpiresAt.Sub(stored.ExpiresAt); delta > time.Millisecond || delta < -time.Millisecond { + t.Fatalf("stored expiry %s differs from the response by %s", stored.ExpiresAt, delta) + } + var raw int64 + db.Table("lexgo_sessions").Where("token_hash = ?", login.Token).Count(&raw) + if raw != 0 { + t.Fatal("raw token persisted") + } + if code, _ = callAPI(t, r, "GET", "/api/v1/me", login.Token, nil); code != 200 { + t.Fatalf("fresh session rejected: %d", code) + } +} + func TestMySQLAccountIsolationAndRevocation(t *testing.T) { db := testDB(t) if err := Migrate(db); err != nil { @@ -281,7 +330,13 @@ func TestMySQLAccountIsolationAndRevocation(t *testing.T) { t.Fatal("old password accepted") } loginToken(t, r, users[0], "replacement-fixture-pass") - clock = clock.Add(9 * time.Hour) + // The lifetime is absolute, so a session is still valid just before it and gone right after; + // asserting against SessionLifetime keeps this test honest when the value changes. + clock = clock.Add(SessionLifetime - time.Hour) + if code, _ = callAPI(t, r, "GET", "/api/v1/me", tokenB, nil); code != 200 { + t.Fatal("session must still be valid before its lifetime ends", code) + } + clock = clock.Add(2 * time.Hour) code, _ = callAPI(t, r, "GET", "/api/v1/me", tokenB, nil) if code != 401 { t.Fatal("expired session valid") diff --git a/server/app/lexgo/service.go b/server/app/lexgo/service.go index da44037..fd953a1 100644 --- a/server/app/lexgo/service.go +++ b/server/app/lexgo/service.go @@ -38,6 +38,11 @@ type Space struct { func (Space) TableName() string { return "lexgo_spaces" } +// SessionLifetime is how long a login lasts. It is absolute, not sliding: using the app does not +// extend it, so a stolen token cannot be kept alive forever. Both the learner and the admin client +// authenticate through the same login endpoint, so one value covers them. +const SessionLifetime = 30 * 24 * time.Hour + type Session struct { TokenHash string `gorm:"primaryKey"` OwnerID int @@ -162,7 +167,7 @@ func login(db *gorm.DB, now time.Time, name, password string) (LoginResult, erro return err } token := hex.EncodeToString(b) - expiry := now.Add(8 * time.Hour) + expiry := now.Add(SessionLifetime) if err = tx.Where("owner_id = ? AND expires_at <= ?", u.UserId, now).Delete(&Session{}).Error; err != nil { return err }