та часть блога, которую ты не видишь

22 июня 2026 г. devloggo

та часть блога, которую ты не видишь

Любой, кто смотрел этот блог, видел заголовок, пиксельную гору, птиц. Никто никогда не смотрел на страницу входа и не подумал «красиво». Это поле для имени, поле для пароля и кнопка. Это нормально, так оно и должно быть скучно. Не скучно по крайней мере для меня, это всё, что сидит за этой кнопкой.

Я построил систему учётных записей как отдельный сервис на Go auth, который ничего не знает о постах или комментариях, только о пользователях, паролях и токенах. Сервис блога спрашивает его «может ли этот человек сделать это» и вот вся их связь. Разделение казалось чрезмерным для однопользовательского блога по сути без пользователей, но я хотел действительно понять как собирается настоящая система аутентификации вместо того, чтобы импортировать какой-то пакет и слепо ему доверять.

request path into the auth service

пароли, что на самом деле даёт хеширование

Первое настоящее решение: как хранить пароль. Мой первый инстинкт, стыдный, был вроде этого:

// what I almost did, first instinct
func hash(password string) string {
	sum := sha256.Sum256([]byte(password))
	return hex.EncodeToString(sum[:])
}

Выглядит разумно, если никто никогда этого не объяснял. SHA-256 это настоящая хеш-функция, быстрая, выход фиксированного размера. Проблема как раз в том, что она быстрая. Обычный хеш SHA-256 взламывается перебором со скоростью миллиарды попыток в секунду на приличной GPU, потому что ничто не замедляет атакующего, и если два пользователя выбирают один пароль они получают точно одинаковый хеш, что разглашает этот факт кому угодно, кто когда-либо видел базу данных.

Что тебе нужно, это что-то намеренно медленное и прожорливое по памяти, достаточно медленное чтобы легальные логины едва замечали, но перебор получал истинку. Это Argon2. Вот что auth на самом деле делает:

const (
	argonMemory      = 64 * 1024
	argonIterations  = 3
	argonParallelism = 2
	saltLength       = 16
	keyLength        = 32
)

func Hash(plain string) (string, error) {
	salt := make([]byte, saltLength)
	if _, err := rand.Read(salt); err != nil {
		return "", err
	}
	key := argon2.IDKey([]byte(plain), salt, argonIterations, argonMemory, argonParallelism, keyLength)
	encoded := fmt.Sprintf(
		"$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
		argon2.Version, argonMemory, argonIterations, argonParallelism,
		base64.RawStdEncoding.EncodeToString(salt),
		base64.RawStdEncoding.EncodeToString(key),
	)
	return encoded, nil
}

Каждый пароль получает свою случайную соль, поэтому идентичные пароли никогда не производят идентичные хеши, и параметры памяти/итерации регулируют сколько стоит одна попытка, намеренно. Я не написал саму имплементацию Argon2, это прямо из golang.org/x/crypto, и это тоже намеренно. Криптографические примитивы это именно то, что ты не пишешь вручную даже если ты понимаешь теорию, потому что баги никогда не скрывались в теории, они скрывались в имплементации.

То, что меня действительно удивило, это трюк с фиктивным хешем при входе:

user, err := s.Queries.GetUserByUsername(ctx, req.Username)
if err != nil {
	if errors.Is(err, pgx.ErrNoRows) {
		// Verify password against dummy hash to prevent timing-based username enumeration.
		_, _ = password.Verify(req.Password, dummyHash)
		writeError(w, r, http.StatusUnauthorized, "invalid_credentials", "invalid username or password")
		return
	}
	...
}

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

токены и cookies, что запутало меня на некоторое время

Это была часть где я продолжал смешивать вещи. Я знал JWT это «токен с закодированным материалом» но я не понимал почему тебе нужно два разных вида, или почему один живёт в cookie обработанном по-другому чем другой.

Что auth на самом деле выдаёт при входе, это две вещи: короткоживущий access token (15 минут, подписан EdDSA ключом, так что любой сервис может его проверить без сетевого звонка back в auth) и долгоживущий refresh token (30 дней), который только сам auth когда-либо проверяет против базы данных. Оба приходят домой как httponly cookies, что означает JavaScript в браузере не может прочитать ни один, только браузер отправляет их обратно автоматически.

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()),
	}
}

Два токена вместо одного это полностью про радиус взрыва. Access token отправляется на каждом запросе, поэтому он должен быть чем-то что любой сервис может дёшево проверить: проверить сигнатуру против публичного ключа, никаких обращений к базе данных. Но если тот же долгоживущий крайний доступ утёк, это будет иметь намного большее значение. Так что вещь, которая действительно живёт месяц, никогда не покидает проверку собственной базы данных auth, а вещь, которая летает вокруг на каждом запросе, живёт только 15 минут.

refresh токены ротируются, и переиспользование означает что-то не так

Каждый раз когда фронтенд вызывает /auth/refresh, старый refresh token помечается как использованный и совершенно новый выдаётся на его место, цепь. Это та часть, что заставила меня действительно понять почему ротация важна вместо того чтобы просто знать слово для этого: если кто-то украл refresh token cookie и пытается использовать его после того как настоящий владелец уже ротировал дальше, это не просто истёкший токен, это доказательство что токен был украден.

if row.RevokedAt.Valid {
	if row.ReplacedBy.Valid {
		// This token was rotated away by a normal refresh, and someone
		// is now replaying that old link in the chain. Assume the
		// chain is compromised and kill every active refresh token for
		// this user.
		if err := s.Queries.RevokeAllUserRefreshTokens(ctx, row.UserID); err != nil {
			...
		}
		http.SetCookie(w, s.clearRefreshCookie())
		writeError(w, r, http.StatusUnauthorized, "invalid_refresh_token", "refresh token reuse detected")
		return
	}
	...
}

Если отозванный токен всё ещё имеет ReplacedBy, кто-то представляет ссылку дальше в цепи чем та что реальная сессия уже прошла, и это не может случиться для единственного законного пользователя. Так что ответ не «залогинься снова», это «убей каждую сессию этого аккаунта», логируя реального владельца как попутный ущерб. Чувствовалось жестоко писать это в первый раз, потом я действительно об этом подумал и это в точности момент.

rate limiting, скучная но необходимая часть

Последний кусок: логин и регистрация оба сидят за простым in-memory token bucket, 10 запросов в минуту на IP адрес.

func New(perMinute int) *Limiter {
	return &Limiter{
		buckets: make(map[string]*bucket),
		rate:    float64(perMinute) / 60.0,
		burst:   float64(perMinute),
	}
}

func (l *Limiter) Allow(key string) bool {
	l.mu.Lock()
	defer l.mu.Unlock()

	now := time.Now()
	b, ok := l.buckets[key]
	if !ok {
		l.buckets[key] = &bucket{tokens: l.burst - 1, lastSeen: now}
		return true
	}

	elapsed := now.Sub(b.lastSeen).Seconds()
	b.tokens += elapsed * l.rate
	if b.tokens > l.burst {
		b.tokens = l.burst
	}
	b.lastSeen = now

	if b.tokens < 1 {
		return false
	}
	b.tokens--
	return true
}

Ничего причудливого, map в памяти, что сбрасывается если процесс перезагружается, что полностью в порядке для того что ему действительно нужно останавливать: кто-то молотящий endpoint логина пытаясь пароли. Ключ bucket это адрес вызывающего, что работает правильно только если фронтенд разбирает настоящий IP клиента и передаёт его чистенько, вместо того чтобы каждый посетитель показывался как собственный адрес контейнера фронтенда. Мне потребовалось раздражающе долго вообще заметить что тот баг существует, все выглядели как один IP для auth потому что все выглядели как один адрес, но это действительно проблема proxy фронтенда и принадлежит другой статье.

Вот весь кнопка логина. Скучная намеренно.

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

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

Войти

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

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