旧手机还能继续用
「回声」做了一次安全升级:用户在“账号安全”页面可以查看登录设备,并一键踢掉其他设备。上线后客服收到反馈:点了“踢出”之后,被踢的手机确实退回了登录页,但过了一分钟又自己恢复了,用户还能继续看订单。
研发排查后发现,客户端只是删掉了本地 token,服务端的会话记录还在,刷新接口也照常签发新 token。更麻烦的是,用户以为自己已经安全了,实际上旧设备只是短暂地显示了一次登录页。这个案例里的现象是构造的,但它对应的实现错误非常常见:把“退出登录”理解成“前端删字符串”,而没有理解成“服务端撤销一个会话”。
本节讨论三件事:会话到底保存什么,多端互踢在产品上意味着什么,以及服务端怎样做到“踢掉之后不能恢复”。案例中的设备数、时长都是案例假设值,只用于说明判断。
会话不是 token:一张表看清边界
token 是客户端携带的凭证,会话是服务端保存的状态。token 可以自包含,也可以只是一个随机串;会话则必须记录“谁、在哪个设备、从什么时候开始、是否还有效”。把两者混为一谈,就会出现“token 过期了但会话还在”“token 有效但会话已被踢”这类对不上的状态。
| 对象 | 存放位置 | 主要内容 | 失效方式 |
|---|---|---|---|
| token | 客户端 | 会话 ID 或签名声明 | 过期、客户端删除 |
| 会话 | 服务端 | 账号、设备、登录顺序、状态 | 主动撤销、设备上限、风控 |
| 设备 | 服务端 | 设备标识、首次/最近登录 | 用户移除、长期不活跃 |
| 踢出记录 | 服务端 | 会话 ID、原因、时间 | 审计保留,不参与续期 |
一个最小可用的会话至少要有三个字段:会话 ID、账号 ID、设备 ID。会话 ID 用来被 token 引用,账号 ID 用来做归属校验,设备 ID 用来实现“同一设备重复登录复用会话”。登录时间决定谁是最旧设备;踢出原因决定客户端提示什么,也决定排查时能不能解释清楚。
关键判断是:删除客户端 token 只是退出,撤销服务端会话才是踢出。退出可以由客户端发起,服务端只需要让当前会话失效;踢出必须由服务端执行,并且要让被踢设备手里的 token 在续期时被拒绝。
多端互踢的产品语义
“多端互踢”听起来像一个技术功能,实际是一组产品决策。常见的有四种语义。
第一种是设备上限淘汰:允许最多 N 台设备同时在线,登录第 N+1 台时自动踢掉最旧的一台。它适合大多数消费产品,用户不需要理解“踢出”按钮,系统按规则自动维持上限。
第二种是新登录踢旧登录:只允许一台设备在线,任何新登录都会让其他设备下线。它适合安全敏感或单会话计费的业务,但用户体验较差,手机和电脑不能同时使用。
第三种是手动踢出:用户可以在设备列表里选择某一台设备下线。它把控制权交给用户,服务端需要提供设备列表、最后活跃时间和踢出原因。
第四种是风控踢出:异地登录、异常设备、密码泄露时,系统主动撤销全部会话并要求重新认证。它通常和密码修改、账号找回绑定在一起。
| 语义 | 触发方 | 服务端动作 | 客户端表现 |
|---|---|---|---|
| 设备上限淘汰 | 系统 | 撤销最旧会话 | 旧设备下次请求被拒 |
| 新登录踢旧 | 系统 | 撤销其他会话 | 其他设备立即或下次请求下线 |
| 手动踢出 | 用户 | 撤销指定会话 | 被踢设备收到明确提示 |
| 风控踢出 | 风控 | 撤销全部会话 | 全部设备要求重新认证 |
这四种语义可以组合,但组合时必须明确优先级。例如设备上限是 3,用户手动踢掉一台后又登录一台,系统应该先按上限淘汰,再执行手动踢出;否则顺序不同,最终在线的设备集合也不同。
还有一个产品细节:被踢设备应该“立即”感知,还是“下次请求”感知。长连接可以推送踢出事件,HTTP 轮询只能在下次请求时发现。无论哪种,服务端都不能依赖客户端自觉退出,必须在续期接口上做校验。
失败场景:只删客户端 token,旧设备仍能续期
回到开头的事故。第一版实现里,踢出接口只做了一件事:把当前用户的其他 token 从客户端存储里删掉。服务端没有会话表,token 是自包含的 JWT,签名有效就能通过。于是旧设备只要还留着 refresh token,就能在过期前换到新的 access token,所谓“踢出”完全没有发生。
第二版加了会话表,但踢出时只删除了会话记录,没有保留撤销原因。旧设备拿着会话 ID 来续期,服务端查不到记录,返回“会话不存在”;客户端把这个错误当成网络问题,自动重试并重新走了一次静默登录,又创建了新会话。用户看到的现象就是“踢出后一分钟又恢复了”。
第三版才把状态补完整:会话被撤销时写入撤销记录和原因;续期接口先查撤销记录,再查活跃会话;撤销记录保留到 token 最长有效期之后。这样旧设备无论重试多少次,都只能得到“会话已被踢出”,而不是“会话不存在”这种可以被客户端当成临时故障的错误。
这个事故的教训是:撤销必须是服务端的、持久的、可解释的。只删记录不删原因,等于把“被踢”和“过期”混成同一种状态,客户端和无状态重试逻辑会把它当成可恢复错误。
Go 实现:用有序会话表表达设备上限
下面的实现来自 webapp-lab。SessionStore 用 order 保存登录先后顺序,用 sessions 保存活跃会话,用 revoked 保存已撤销会话及原因。Login 先按账号和设备查找,找到就复用;找不到就创建新会话,然后按账号统计数量,超过 MaxDevices 就淘汰最旧的一个。
// file: internal/account/session.go
// tier: L1
// verify: go test ./internal/account/
package account
import (
"fmt"
"strings"
)
const (
ReasonDeviceLimit = "device limit exceeded"
ReasonKicked = "kicked by another login"
)
// Session 是服务端保存的登录状态。客户端 token 只是它的引用,
// 删 token 不等于删会话。
type Session struct {
ID string
AccountID string
DeviceID string
}
// SessionStore 按登录顺序维护活跃会话;revoked 记录失效会话及原因。
type SessionStore struct {
MaxDevices int
sessions map[string]Session
order []string
revoked map[string]string
nextID int
}
func NewSessionStore(maxDevices int) (*SessionStore, error) {
if maxDevices <= 0 {
return nil, fmt.Errorf("account: max devices must be positive")
}
return &SessionStore{
MaxDevices: maxDevices,
sessions: make(map[string]Session),
revoked: make(map[string]string),
}, nil
}
func (s *SessionStore) init() error {
if s.MaxDevices <= 0 {
return fmt.Errorf("account: max devices must be positive")
}
if s.sessions == nil {
s.sessions = make(map[string]Session)
}
if s.revoked == nil {
s.revoked = make(map[string]string)
}
return nil
}
// Login 复用同设备会话;超过设备上限时踢掉最旧的会话。
func (s *SessionStore) Login(accountID, deviceID string) (Session, error) {
if strings.TrimSpace(accountID) == "" || strings.TrimSpace(deviceID) == "" {
return Session{}, fmt.Errorf("account: account id and device id are required")
}
if err := s.init(); err != nil {
return Session{}, err
}
for _, id := range s.order {
if sess := s.sessions[id]; sess.AccountID == accountID && sess.DeviceID == deviceID {
return sess, nil
}
}
s.nextID++
sess := Session{ID: fmt.Sprintf("sess-%04d", s.nextID), AccountID: accountID, DeviceID: deviceID}
s.sessions[sess.ID] = sess
s.order = append(s.order, sess.ID)
for s.count(accountID) > s.MaxDevices {
oldest := s.oldest(accountID)
delete(s.sessions, oldest)
s.removeOrder(oldest)
s.revoked[oldest] = ReasonDeviceLimit
}
return sess, nil
}
// KickOthers 保留 keepSessionID,撤销该账号其余所有会话。
func (s *SessionStore) KickOthers(accountID, keepSessionID string) ([]Session, error) {
if err := s.init(); err != nil {
return nil, err
}
keep, ok := s.sessions[keepSessionID]
if !ok || keep.AccountID != accountID {
return nil, fmt.Errorf("account: session %q does not belong to %q", keepSessionID, accountID)
}
var kicked []Session
remaining := make([]string, 0, len(s.order))
for _, id := range s.order {
sess := s.sessions[id]
if sess.AccountID != accountID || id == keepSessionID {
remaining = append(remaining, id)
continue
}
delete(s.sessions, id)
s.revoked[id] = ReasonKicked
kicked = append(kicked, sess)
}
s.order = remaining
return kicked, nil
}
// Active 按登录先后返回账号当前有效的会话。
func (s *SessionStore) Active(accountID string) []Session {
var out []Session
for _, id := range s.order {
if sess := s.sessions[id]; sess.AccountID == accountID {
out = append(out, sess)
}
}
return out
}
// Resume 校验会话是否仍可续期;被踢出的会话返回原因,不能恢复。
func (s *SessionStore) Resume(sessionID string) (Session, error) {
if reason, ok := s.revoked[sessionID]; ok {
return Session{}, fmt.Errorf("account: session %q revoked: %s", sessionID, reason)
}
sess, ok := s.sessions[sessionID]
if !ok {
return Session{}, fmt.Errorf("account: session %q not found", sessionID)
}
return sess, nil
}
func (s *SessionStore) count(accountID string) int {
n := 0
for _, id := range s.order {
if s.sessions[id].AccountID == accountID {
n++
}
}
return n
}
func (s *SessionStore) oldest(accountID string) string {
for _, id := range s.order {
if s.sessions[id].AccountID == accountID {
return id
}
}
return ""
}
func (s *SessionStore) removeOrder(sessionID string) {
for i, id := range s.order {
if id == sessionID {
s.order = append(s.order[:i], s.order[i+1:]...)
return
}
}
}
这段代码有几个刻意保留的边界。
order是登录顺序的唯一事实来源。会话 ID 自增,但“谁最旧”不靠 ID 大小判断,而靠切片顺序,这样以后换成时间戳或数据库自增都不会改变语义。Login的复用检查在创建之前。同一设备重复登录不会产生新会话,也不会触发设备上限淘汰,否则用户刷新一次页面就可能把另一台设备踢掉。- 淘汰循环用
for而不是if。正常情况只会超出一个,但如果配置被调小或数据被人工修复过,循环能让状态收敛回上限。 revoked与sessions分离。撤销记录必须在会话删除后继续存在,否则续期接口只能返回“未找到”,客户端容易把它当成临时错误并重试。KickOthers校验保留会话的归属。传错账号或传了别人的会话 ID 时必须报错,不能默默保留一个不属于该账号的会话。
代码没有处理持久化、并发和 token 版本。生产环境里,会话表要落库,踢出动作要带事务或版本号;如果续期请求在踢出之前已经读到旧状态,仍可能签发一次新 token,需要靠会话版本号或撤销时间戳做二次校验。
测试:复用、上限与不可恢复
测试覆盖三组行为:同设备重复登录复用会话;超过上限时按顺序淘汰最旧设备;主动踢出后旧会话不能续期,且不影响其他账号。最后一组还会检查 Resume 返回的错误里包含原因,避免只返回一个布尔值。
// file: internal/account/session_test.go
// tier: L1
// verify: go test ./internal/account/
package account
import (
"strings"
"testing"
)
func TestLoginReusesSessionPerDevice(t *testing.T) {
store, err := NewSessionStore(3)
if err != nil {
t.Fatal(err)
}
first, err := store.Login("a1", "phone")
if err != nil {
t.Fatal(err)
}
again, err := store.Login("a1", "phone")
if err != nil {
t.Fatal(err)
}
if again.ID != first.ID {
t.Fatalf("session id = %s, want reuse of %s", again.ID, first.ID)
}
if got := len(store.Active("a1")); got != 1 {
t.Fatalf("active sessions = %d, want 1", got)
}
}
func TestLoginKicksOldestDevice(t *testing.T) {
store, err := NewSessionStore(2)
if err != nil {
t.Fatal(err)
}
oldest, _ := store.Login("a1", "phone")
middle, _ := store.Login("a1", "tablet")
newest, err := store.Login("a1", "laptop")
if err != nil {
t.Fatal(err)
}
active := store.Active("a1")
if len(active) != 2 || active[0].ID != middle.ID || active[1].ID != newest.ID {
t.Fatalf("active = %+v, want middle then newest", active)
}
if _, err := store.Resume(oldest.ID); err == nil || !strings.Contains(err.Error(), ReasonDeviceLimit) {
t.Fatalf("resume oldest error = %v, want device limit", err)
}
if _, err := store.Resume(middle.ID); err != nil {
t.Fatalf("middle session should stay valid: %v", err)
}
}
func TestKickOthersRevokesSessions(t *testing.T) {
store, err := NewSessionStore(3)
if err != nil {
t.Fatal(err)
}
s1, _ := store.Login("a1", "phone")
s2, _ := store.Login("a1", "tablet")
s3, _ := store.Login("a1", "laptop")
other, _ := store.Login("a2", "phone")
kicked, err := store.KickOthers("a1", s2.ID)
if err != nil {
t.Fatal(err)
}
if len(kicked) != 2 || kicked[0].ID != s1.ID || kicked[1].ID != s3.ID {
t.Fatalf("kicked = %+v, want s1 and s3", kicked)
}
if active := store.Active("a1"); len(active) != 1 || active[0].ID != s2.ID {
t.Fatalf("active = %+v, want only s2", active)
}
if _, err := store.Resume(s1.ID); err == nil || !strings.Contains(err.Error(), ReasonKicked) {
t.Fatalf("resume kicked error = %v, want kicked reason", err)
}
if _, err := store.Resume(other.ID); err != nil {
t.Fatalf("other account must not be touched: %v", err)
}
}
func TestSessionStoreValidatesInput(t *testing.T) {
if _, err := NewSessionStore(0); err == nil {
t.Fatal("want error for zero device limit")
}
store, err := NewSessionStore(1)
if err != nil {
t.Fatal(err)
}
if _, err := store.Login("", "phone"); err == nil {
t.Fatal("want error for empty account")
}
sess, _ := store.Login("a1", "phone")
if _, err := store.KickOthers("a2", sess.ID); err == nil {
t.Fatal("want error when session belongs to another account")
}
if _, err := store.Resume("sess-9999"); err == nil {
t.Fatal("want error for unknown session")
}
}
测试没有覆盖“续期与踢出并发”的竞态。这个包是内存模型,单测只能证明语义;真正的并发安全要靠数据库事务、会话版本号或 Redis 的原子操作。测试也没有覆盖多进程部署,SessionStore 一旦从内存换成多实例,order 和 revoked 必须变成共享存储,不能每个实例各存一份。
运行命令与真实输出
在仓库根目录执行:
cd .local/checks/webapp-lab && go test ./internal/account/ -run 'TestLogin|TestKick|TestSession' -count=1
一次本机运行的真实输出如下,耗时随机器变化:
ok webapp/internal/account 0.500s
ok 表示筛选出的用例全部通过。加 -v 可以看到 TestLoginKicksOldestDevice 里最旧会话被撤销、TestKickOthersRevokesSessions 里只保留指定会话;判定标准仍然是退出码为 0 且没有 FAIL。
小结
会话不是 token,踢出也不是前端删字符串。服务端要保存账号、设备、登录顺序和撤销原因,登录时复用同设备会话,超过上限时淘汰最旧设备,主动踢出时写入不可恢复的撤销记录。被踢设备下一次续期必须得到明确的“已撤销”,而不是可以被重试掩盖的“未找到”。
多端互踢的产品语义要在实现前定清楚:是设备上限淘汰、新登录踢旧、手动踢出,还是风控踢出;它们可以组合,但要有明确优先级。实现只是把产品规则翻译成状态变化。
下一节讨论登录入口本身的安全:撞库、风控和账号找回,为什么只按账号限流挡不住分布式攻击。
阅读导航:上一节:3.1 注册登录与第三方身份 · 下一节:3.3 账号安全:撞库、风控与找回 。
常见错误与边界
- 把退出登录当成踢出。客户端删除 token 只影响当前设备,服务端会话仍然有效。
- 撤销时只删会话不记原因。续期接口返回“未找到”,客户端会当成网络错误自动重试。
- 用会话 ID 大小判断新旧。ID 可能来自随机数或数据库,顺序必须显式保存。
- 同设备重复登录不查旧会话。每次刷新都新建会话,会误伤其他设备。
- 设备 ID 用客户端随机生成且不持久化。卸载重装就变成新设备,上限很快被占满。
- 只做单实例内存会话。多实例部署后,用户在 A 实例登录、B 实例续期会互相看不见。
- 踢出与续期没有版本校验。并发下旧请求可能在撤销后仍签发一次新 token。
- 撤销记录过早清理。至少要保留到该会话最长 token 有效期之后,否则旧 token 会被当成未知会话。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。