Сервер: миграция 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
140 lines
4.8 KiB
Go
140 lines
4.8 KiB
Go
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
|
||
}
|