From 591f8166877969cf6b6315fe47b6ee46c854b291 Mon Sep 17 00:00:00 2001 From: QiuSW Date: Wed, 16 Sep 2026 21:05:50 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E7=99=BB=E5=BD=95=E6=9C=89=E6=95=88?= =?UTF-8?q?=E6=9C=9F=E7=94=B1=208=20=E5=B0=8F=E6=97=B6=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=2030=20=E5=A4=A9=20(#42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 有效期提取为具名常量 SessionLifetime(绝对有效期,不滑动续期),学习端与管理端共用 同一登录接口,一处生效;数据库结构与接口字段不变,旧会话按各自 expires_at 自然过渡。 - 过期断言改为按常量计算(临期仍可用、超过后被拒),新增测试锁定 30 天常量、 登录响应与库内 expires_at 一致、库内只存 SHA-256 摘要。 - 撤销规则不变:退出撤销当前会话,改密码/停用/重置撤销该账号全部会话。 - Wiki 已在线上更新(会话规则数字、验证说明、Home/Project-Profile 记录);镜像导出留到 验收时执行,避免把尚未合入的 #40 段落带进本分支。 --- server/app/lexgo/integration_test.go | 57 +++++++++++++++++++++++++++- server/app/lexgo/service.go | 7 +++- 2 files changed, 62 insertions(+), 2 deletions(-) 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 } -- 2.34.1