delen ingen ser på
Alle som noen gang har sett på denne bloggen har sett headeren, pikselfjellet, fuglene. Ingen har noen gang sett på innloggingssiden og tenkt "fin." Det er et brukernavnfelt, et passordfelt, og en knapp. Det er greit, det skal være kjedelig. Det som ikke er kjedelig, i alle fall for meg, er alt som sitter bak den knappen.
Jeg bygde kontosystemet som sin egen separate Go-tjeneste, auth, som ikke vet noe om innlegg eller kommentarer, bare brukere, passord, og tokens. Blog-tjenesten spør den "har denne personen lov til å gjøre dette" og det er hele forholdet mellom de to. Å splitte det ut føltes som overkill for en enmanns-blogg med praktisk talt ingen brukere, men jeg ville faktisk forstå hvordan et ekte auth-system settes sammen i stedet for å importere en pakke og stole blindt på den.

passord: hva hashing faktisk gir deg
Første ekte avgjørelse: hvordan lagrer du et passord. Min første instinkt, den pinlige, var noe sånt som dette:
// what I almost did, first instinct
func hash(password string) string {
sum := sha256.Sum256([]byte(password))
return hex.EncodeToString(sum[:])
}Ser fornuftig ut hvis ingen noen gang har forklart deg dette. SHA-256 er en ekte hashfunksjon, rask, output med fast størrelse. Problemet er nettopp at den er rask. En ren SHA-256-hash blir brute-forcet med milliarder av gjetninger i sekundet på et anstendig grafikkort, fordi ingenting bremser angriperen ned, og hvis to brukere velger samme passord får de nøyaktig samme hash, noe som lekker det faktumet til hvem som helst som noen gang ser databasen.
Det du faktisk vil ha er noe med vilje tregt og minnesultent, tregt nok til at ekte innlogginger knapt merker det men brute force blir knust. Det er Argon2. Her er hva auth faktisk gjør:
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
}Hvert passord får sin egen tilfeldige salt slik at identiske passord aldri produserer identiske hasher, og minne/iterasjons-tallene stiller inn hvor dyrt ett gjett er, med vilje. Jeg skrev ikke selve Argon2-implementasjonen, den kommer rett fra golang.org/x/crypto, og det er med vilje det også. Kryptoprimitiver er nøyaktig den typen ting du ikke bygger selv med hånden selv når du forstår teorien, fordi teorien aldri var der buggene gjemmer seg, implementasjonen er det.
Delen som faktisk overrasket meg var dummy-hash-trikset i innlogging:
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
}
...
}Man skulle tro et manglende brukernavn bare kan avbrytes umiddelbart. Men hashing tar en målbar mengde tid, og hvis et feil brukernavn returnerer øyeblikkelig mens et feil passord tar 80ms fordi det kjørte den ekte Argon2-sjekken, kan en angriper time responsene dine og finne ut hvilke brukernavn som faktisk finnes på siden. Så selv en innlogging for et brukernavn som ikke er ekte brenner fortsatt CPU-en på å sjekke mot en falsk hash, bare slik at timingen ser identisk ut uansett. Ville aldri tenkt på det selv.
tokens og cookies, som forvirret meg en stund
Dette var delen der jeg stadig blandet ting sammen. Jeg visste JWT-er var "et token med greier kodet inn i det" men jeg skjønte ikke hvorfor du trengte to forskjellige typer, eller hvorfor den ene bor i en cookie som håndteres annerledes enn den andre.
Det auth faktisk utsteder ved innlogging er to ting: et kortlevd access-token (15 minutter, signert med en EdDSA-nøkkel slik at hvilken som helst tjeneste kan verifisere det uten å ringe auth tilbake over nettverket) og et langlevd refresh-token (30 dager) som bare auth selv noen gang sjekker mot databasen. Begge kjører hjem som httponly-cookies, som betyr at JavaScript i nettleseren ikke kan lese noen av dem, bare nettleseren sender dem tilbake automatisk.
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()),
}
}To tokens i stedet for ett handler helt om skadeomfang. Access-tokenet blir sendt på hver eneste forespørsel, så det må være noe hvilken som helst tjeneste kan sjekke billig: verifiser en signatur mot en offentlig nøkkel, ingen databasetur nødvendig. Men hvis det samme langlevde credentialet skulle lekke, ville det bety mye mer. Så tingen som faktisk varer en måned forlater aldri auths egen databasesjekk, og tingen som flyr rundt på hver forespørsel lever bare i 15 minutter.
refresh-tokens roterer, og gjenbruk betyr at noe er galt
Hver gang frontend kaller /auth/refresh, blir det gamle refresh-tokenet markert brukt og et helt nytt blir utstedt i stedet, en kjede. Dette er delen som fikk meg til å faktisk forstå hvorfor rotasjon betyr noe i stedet for bare å kjenne ordet for det: hvis noen stjeler en refresh-token-cookie og prøver å bruke den etter at den ekte eieren allerede har rotert forbi den, er ikke det bare et utløpt token, det er bevis på at tokenet ble stjålet.
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
}
...
}Hvis et tilbakekalt token fortsatt har en ReplacedBy, presenterer noen en lenke lenger tilbake i kjeden enn den den ekte sesjonen allerede har beveget seg forbi, og det kan ikke skje for en legitim enkeltbruker. Så svaret er ikke "logg inn igjen," det er "drep hver eneste sesjon denne kontoen har," som logger ut den ekte eieren også som følgeskade. Føltes hardt å skrive det første gangen, så tenkte jeg faktisk over det og det er nøyaktig poenget.
rate limiting, den kjedelige men nødvendige delen
Siste bit: innlogging og registrering sitter begge bak en enkel token-bucket i minnet, 10 forespørsler i minuttet per IP-adresse.
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
}Ingenting fancy, et map i minnet som nullstilles hvis prosessen restarter, som er helt greit for det det faktisk trenger å stoppe: noen som hamrer på innloggingsendepunktet og prøver passord. Bucket-nøkkelen er den kallendes IP, som bare fungerer riktig fordi frontend løser opp den ekte klient-IP-en og sender den gjennom rent i stedet for at hver besøkende dukker opp som frontend-containerens egen adresse. Tok meg irriterende lang tid å i det hele tatt legge merke til at den buggen fantes, alle som deler én bucket fordi de alle så ut som samme IP for auth, men det er egentlig et frontend-proxy-problem og hører hjemme i et annet innlegg.
Det er hele innloggingsknappen. Kjedelig med vilje.