Files
bare/internal/auth/session.go
T
mayatnikovandClaude Opus 5 597c55301c Этап 1: аккаунты — argon2id, сессии, ключевой блоб, вход и регистрация
Сервер: миграция 001 со всей схемой storage.md, store на modernc.org/sqlite
(WAL, foreign_keys, один писатель), фоновая чистка раз в час, argon2id
с параметрами ADR-021 и сверкой constant-time, сессии по SHA-256 токена,
cookie bare_session, глобальная проверка Origin, девять эндпоинтов аккаунта.
Ник в журнал не попадает: для /api/ пишется шаблон маршрута.

Клиент: crypto.js по crypto.md построчно — мастер из пароля, два независимых
ключа из мастера, ключевой блоб с ником в AAD, отпечаток от сырой точки;
db.js со всеми хранилищами версии 1; экран входа и регистрации, настройки
со сменой пароля, выходом и удалением аккаунта.

Пароль не покидает клиент: проверено на боевом сервере — ни пароля, ни priv.d
ни в одном теле запроса, вход на втором устройстве даёт тот же отпечаток.

ADR-027: код internal для 500, причина только в журнале.
ADR-028: тексты состояний клиента сведены в ui.md.
ADR-029: вход под другим ником стирает историю только после подтверждения.
ADR-030: верхняя граница итераций KDF, проверка границ на обеих сторонах.
ADR-031: служебный выход перед повторным входом не заканчивает сеанс.
ADR-032: каталог состояния 0700, файлы базы 0600.

Прямые зависимости: modernc.org/sqlite, golang.org/x/crypto.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015DbCjVfTFq4ZFG8juD45YJ
2026-08-22 14:06:07 +03:00

140 lines
4.8 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package auth
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"net/http"
"time"
"github.com/xmatic-squad/bare/internal/store"
)
// Сессия по ADR-021: токен 32 случайных байта, в базе SHA-256 от него,
// в cookie — base64url. Срок 90 дней без продления.
const (
CookieName = "bare_session"
TokenLen = 32
TTL = 90 * 24 * time.Hour
)
// NewToken выдаёт токен для cookie и его SHA-256 для базы.
func NewToken() (token string, hash []byte, err error) {
raw := make([]byte, TokenLen)
if _, err := rand.Read(raw); err != nil {
return "", nil, fmt.Errorf("auth: токен: %w", err)
}
sum := sha256.Sum256(raw)
return base64.RawURLEncoding.EncodeToString(raw), sum[:], nil
}
// TokenHash разбирает токен из cookie в его SHA-256. Мусор — false.
func TokenHash(token string) ([]byte, bool) {
raw, err := base64.RawURLEncoding.DecodeString(token)
if err != nil || len(raw) != TokenLen {
return nil, false
}
sum := sha256.Sum256(raw)
return sum[:], true
}
// SetCookie ставит cookie сессии. Secure стоит всегда: браузеры считают
// localhost и 127.0.0.1 доверенным происхождением, поэтому локальная
// разработка по http этим не ломается.
func SetCookie(w http.ResponseWriter, token string, expires time.Time) {
http.SetCookie(w, &http.Cookie{
Name: CookieName,
Value: token,
Path: "/",
Expires: expires,
MaxAge: int(time.Until(expires).Seconds()),
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
})
}
// ClearCookie стирает cookie сессии.
func ClearCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: CookieName,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
})
}
// Fail — как auth отвечает на отказ. Тело ошибки в форме протокола
// собирает internal/api (ADR-026), а импортировать его отсюда нельзя:
// api импортирует auth. Поэтому хелперы передаются значениями.
type Fail struct {
// Error пишет ошибку протокола: статус, код, текст для человека.
Error func(w http.ResponseWriter, status int, code, message string)
// Internal пишет 500 и кладёт причину в журнал сервера.
Internal func(w http.ResponseWriter, r *http.Request, err error)
}
// Origin — второй барьер CSRF рядом с SameSite=Strict (ADR-021).
// На всех методах кроме GET и HEAD заголовок Origin обязан равняться
// origin сервера; отсутствующий Origin — тоже отказ.
func Origin(origin string, fail Fail) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
if r.Header.Get("Origin") != origin {
fail.Error(w, http.StatusForbidden, "bad_origin", "запрос не с этого сайта")
return
}
}
next.ServeHTTP(w, r)
})
}
}
// Require пропускает дальше только запросы с живой сессией и кладёт её
// в контекст. Без сессии — 401 unauthenticated.
func Require(st *store.Store, fail Fail) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sess, err := session(r, st)
if errors.Is(err, store.ErrNotFound) {
fail.Error(w, http.StatusUnauthorized, "unauthenticated", "нужен вход")
return
}
if err != nil {
fail.Internal(w, r, err)
return
}
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), sessionKey{}, sess)))
})
}
}
// session читает cookie и находит сессию. Нет cookie, мусор в ней
// и истёкшая сессия неразличимы: store.ErrNotFound.
func session(r *http.Request, st *store.Store) (store.Session, error) {
c, err := r.Cookie(CookieName)
if err != nil {
return store.Session{}, store.ErrNotFound
}
hash, ok := TokenHash(c.Value)
if !ok {
return store.Session{}, store.ErrNotFound
}
return st.Session(r.Context(), hash, time.Now().UnixMilli())
}
type sessionKey struct{}
// From отдаёт сессию из контекста. Её кладёт Require.
func From(r *http.Request) (store.Session, bool) {
sess, ok := r.Context().Value(sessionKey{}).(store.Session)
return sess, ok
}