把我登出自己会话的密码更改

2026年9月9日 gosecurity

我构建了一个自助密码更改表单,像正常用户一样测试它,而且它登出了我正坐的确切会话,我输入新密码。不是某个其他设备,不是某处的陈旧会话。我,现在,在我积极使用改变我自己的密码的标签。

功能应该做什么

登录的用户可以从一个标签式/profile页面改变他们的密码,在安全下。输入你的当前密码证明它是你,输入一个新的,完成。明显的安全要求坐在那个表单后:如果某人其他的会话或令牌仍然在外面浮动,改变你的密码应该杀死它。那不是可选的,它是让密码改变发生的全部要点在第一位,如果它没有使旧的会话失效,一个被偷的令牌就会生存正好通过「修复」。

看起来合理的设计

我的第一个本能是「except-current」。撤销每个刷新令牌为这个账户除了属于提出这个确切请求的会话的,既然那是表面上合法的,那个人证明他们知道当前密码毕竟。为了做那个,处理器需要知道哪个刷新令牌属于这个会话,所以它可以跳过它。明显的地方找到那是refresh_token cookie骑在请求上:

// first attempt: read the current session's own refresh cookie so we
// know which one to spare
currentToken := r.CookieValue("refresh_token")
if err := qtx.RevokeAllUserRefreshTokensExcept(ctx, userID, currentToken); err != nil {
	writeError(w, r, http.StatusInternalServerError, "internal_error", "failed to revoke sessions")
	return
}

除外cookie不是我会有半假设的方式限定。刷新cookies这里被设置与Path=/auth

func (s *Server) newRefreshCookie(rawToken string, maxAge time.Duration) *http.Cookie {
	return &http.Cookie{
		Name:     "refresh_token",
		Value:    rawToken,
		Path:     "/auth",
		HttpOnly: true,
		Secure:   s.CookieSecure,
		SameSite: http.SameSiteStrictMode,
		MaxAge:   int(maxAge.Seconds()),
	}
}

Path=/auth意思浏览器只附着那个cookie到请求那个路径真的开始与/auth。一个表单帖子从/profile/security不是那些中的一个。所以时刻我的except-current逻辑试图读refresh_token off传入请求算出哪个会话备用,它得到没什么,每一次,对于每个合法的用户,因为浏览器正确地从未发送它那边在第一位。我的失败安全为「不能识别当前会话」是回退到撤销一切,那是一个合理的失败安全在隔离。它只是意味着回退在字面上每真实密码改变发生,既然幸福路径它表面上是一个例外从未真的发生。

我发现了这个通过测试功能端到端而不是只单位-测试处理器在隔离,那会给了它一个cookie一个真实的浏览器从未会发送。

实际的修复

放弃想法的备用一个会话通过读一个cookie那结构上不能在那边。撤销一切,没例外,然后立刻铸造一个全新的刷新令牌为提出请求的会话以及在相同响应中通过Set-Cookie手它回。用户停留登录,只是在一个新鲜发行的令牌而不是他们的旧一个,而且每其他会话,那些可能属于某人谁偷了一个令牌的,得到核爆一起与它:

if err := qtx.RevokeAllUserRefreshTokens(ctx, userID); err != nil {
	writeError(w, r, http.StatusInternalServerError, "internal_error", "failed to revoke sessions")
	return
}

rawRefreshToken, _, err := s.issueRefreshTokenWith(ctx, qtx, userID)
if err != nil {
	writeError(w, r, http.StatusInternalServerError, "internal_error", "failed to issue refresh token")
	return
}

if err := tx.Commit(ctx); err != nil {
	writeError(w, r, http.StatusInternalServerError, "internal_error", "failed to commit transaction")
	return
}

http.SetCookie(w, s.newRefreshCookie(rawRefreshToken, s.RefreshTTL))
w.WriteHeader(http.StatusNoContent)

撤销全部,发行一个新鲜令牌,在一个交易内设置它,所以没窗口其中每个会话死亡而且不新的存在呢。更简单比except-current版本而且它不依赖一个cookie浏览器从不会发送到那个路由。有时「我的例外逻辑不工作」的修复是删除例外,不调试它。

证明它与第二个会话,不只是读代码

我不相信自己只读新版本而且叫它固定,鉴于我如何自信地错误第一版本已经感觉当我写它。实际的测试打开两个会话对抗真实运行堆栈,注册一个用户,日志一个「当前」会话并抓一个它的刷新cookie,然后日志一个第二,分开会话相同的账户并抓那一个的cookie也。改变密码通过当前会话,然后断言两件东西直接对抗真实端点:当前会话的新旋转的cookie仍然成功交换为新鲜访问令牌在/auth/refresh,而且第二会话的旧cookie得到拒绝彻底。两个断言必须同时保持修复被真的正确,一个意外地撤销了一切包括新令牌的版本将失败第一,而且一个版本那忘记撤销其他会话在所有会无音地失败看起来罚款从当前会话的角度虽然离开每其他一个宽打开。

比较小的修复骑沿它

相同的审查通道那抓住这个也抓住了一个相关,更静的bug在登录以及密码-改变速率限制,那已一直关键于前端容器的地址而不是真实访客的,在确切的同样形状的错误作为速率限制一个跳进在博客服务(值得它自己的帖子,既然修复最后生活在反向代理为两者)。这两者都不是标题bug进去。两者来自相同的纪律:追踪什么一个请求真的携带在每跳,cookie路径包括,而不是相信那个代码那编译而且通过一个狭窄的单位测试做什么它看起来像一旦真实浏览器和真实代理被涉及。

部分那做了它点击

前端部分这个停留几乎无聊通过比较,默默的会话-刷新逻辑在hooks.server.ts已经读无论哪个refresh_token cookie当前存在而且交换它为一个新鲜访问令牌短期生活一个期满:

const refreshToken = event.cookies.get('refresh_token');
if (refreshToken) {
	const response = await event.fetch('/auth/refresh', { method: 'POST' });
	...
}

因为新的刷新cookie得到设置在确切的同样响应作为密码改变它自己,通过时刻这个代码路径再运行浏览器已经持有新的一个。没分离的重新登录步骤,没「你已被登出,请登记回在」消息,会话只是静悄悄地继续在新令牌下面不用用户注意任何改变。验证它正确地也,不只是通过读代码:改变了密码通过真实形式后面Caddy,确认当前会话的旋转的cookie仍然工作在下次请求,而且确认一个第二,分开会话的旧刷新令牌得到拒绝。普通-词版本的教训:一个cookie的Path不是形式上的,它是一个实际的访问边界,而且安全回退那默默火上每个请求而不是罕见一个它是意思为不是一个回退,它是真实行为穿一个伪装。

0 条评论

登录 后即可评论。

登录

忘记密码?

还没有账号?