смена пароля, которая разлогинила меня из собственной сессии

9 сентября 2026 г. gosecurity

Я построил самообслуживаемую форму смены пароля, протестировал её как нормальный пользователь, и она разлогинила меня из точной сессии я сидел в пока я печатал новый пароль. Не какое-то другое устройство, не устаревшая сессия где-то. Я, прямо теперь, на вкладке я активно использовал менять мой собственный пароль.

что функция должна делать

Залогиненные пользователи могут менять их собственный пароль из вкладки /profile страницы, под безопасностью. Введи твой текущий пароль доказать это ты, введи новый, готово. Очевидный требование безопасности сидящий позади той формы это: если чья-то другая сессия или токен всё ещё плавает около, менять твой пароль должен убить это. Это не опционально, это весь смысл позволения пароля смены происходить первоначально, если это не делало недействительно старые сессии, украденный токен просто выжил бы прямо через «исправление».

дизайн что казался разумным

Мой первый инстинкт был «except-current». Отозвать каждый refresh токен для этого аккаунта кроме принадлежащего сессии делающей этот точный запрос, поскольку это предположительно законный, человек доказывая они знают текущий пароль в любом случае. Делать то, обработчик нужен знай какой refresh токен принадлежит этой сессии, так оно может пропустить это. Очевидное место найти то это 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 не ограничен способом я бы половину-предположил. Refresh 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 выключено входящий запрос фигурка вне какой сессии сберегать, это получило ничто, каждый один раз, для каждого законного пользователя, потому что браузер правильно никогда не посылал это там первоначально. Мой fail-safe для «не может идентифицировать текущую сессию» был падение назад к отозванию всего, что разумное fail-safe в изоляции. Это только значило fallback выпустил на буквально каждой реальной смене пароля, поскольку счастливый путь это表面上是例外никогда действительно произошло.

Я нашёл это тестируя функцию конца к концу вместо просто модуль-тестирование обработчика в изоляции, который бы дал это cookie что реальный браузер никогда не был бы послал.

фактическое исправление

Упасть идея спасения одна сессия читающая cookie что структурно не может быть там. Отозвать всё, никакие исключения, потом мгновенно монета брэнд новый refresh токен для сессии делающей запрос и рукой это обратно в то же ответ через 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 браузер был никогда собирался посылать к тому маршруту. Иногда исправление для «мой логик исключения не работает» удаляет исключение, не отлаживает это.

доказываю это со второй сессией, не только читая код

Я не доверял себе только читать новую версию и называть это исправленное, дано как уверенно неправильно первая версия ощущалась когда я писал это. Фактический тест открывает две сессии против реального работающего стека, регистрирует пользователя, логиит «текущую» сессию и ловит это refresh cookie, потом логиит второе, отдельное сессия для того же аккаунта и ловит то один cookie также. Менять пароль через текущую сессию, потом утверждают две вещи прямо против реальных конца точек: текущая сессия свежий повёрнутый cookie всё ещё успешно обмены для свежего доступа токена на /auth/refresh, и второе сессия старый cookie получает отклонена совсем. Оба утверждения должны держи в то же время для исправления быть действительно правильно, версия что случайно отозвала всё включая новый токен будет провалить первое, и версия что забыла отозвать другие сессии в всё будет провалить молчаливо выглядеть罚款из точки зрения текущей сессии хотя оставить каждый другой один широко открытый.

меньший исправление ездящий вместе с ним

То же обзор проход то поймал это также поймал связанное, тише bug в логине и пароль-менять rate limiter, что было кеying на адрес контейнера фронтенда нежели реального посетителя, в точной же форме ошибки как rate limiter один прыг выше в сервис блога (стоящий его собственного пост, поскольку исправление закончилось живя в обратном прокси для оба). ни один из тех не был заголовок 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' });
	...
}

Потому что новый refresh cookie получи установлено в точной то же ответ как пароль менять себя, на мгновение этот код путь работает снова браузер уже держит новый один. Никакой отдельный переправляется логин шаг, никакие «ты был разлогинен, пожалуйста логите назад» сообщение, сессия только молчаливо продолжает на новом токене под снизу юзер не замечая что-то изменилось. Проверь это правильно также, не только читая код: переменный пароль через реальную форму за Caddy, подтвердил текущую сессию вращающейся cookie всё ещё работало на следующий запрос, и подтвердил второго, отдельного сессия старый refresh токен получил отклонено. Простой-слова версия урока: cookie Path не формальность, это реальный доступ пограничный, и безопасность fallback то молчаливо выпускает на каждый запрос нежели редкий один это направлено для не fallback, это реальное поведение носящий маскировка.

0 комментариев

Войдите , чтобы оставить комментарий.

Войти

Забыли пароль?

Нет аккаунта?