Compare commits

...
Author SHA1 Message Date
ila 591f816687 fix: 登录有效期由 8 小时改为 30 天 (#42)
- 有效期提取为具名常量 SessionLifetime(绝对有效期,不滑动续期),学习端与管理端共用
  同一登录接口,一处生效;数据库结构与接口字段不变,旧会话按各自 expires_at 自然过渡。
- 过期断言改为按常量计算(临期仍可用、超过后被拒),新增测试锁定 30 天常量、
  登录响应与库内 expires_at 一致、库内只存 SHA-256 摘要。
- 撤销规则不变:退出撤销当前会话,改密码/停用/重置撤销该账号全部会话。
- Wiki 已在线上更新(会话规则数字、验证说明、Home/Project-Profile 记录);镜像导出留到
  验收时执行,避免把尚未合入的 #40 段落带进本分支。
2026-09-16 21:05:50 +08:00
2 changed files with 62 additions and 2 deletions
+56 -1
View File
@@ -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")
+6 -1
View File
@@ -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
}