Этап 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
This commit is contained in:
+126
-13
@@ -1,14 +1,21 @@
|
||||
// Package api собирает маршруты и общие для всех ответов правила:
|
||||
// заголовки безопасности (ADR-021), лимит тела запроса, лог в stdout.
|
||||
// заголовки безопасности (ADR-021), проверку Origin, лимит тела запроса,
|
||||
// лог в stdout.
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/xmatic-squad/bare/internal/auth"
|
||||
"github.com/xmatic-squad/bare/internal/config"
|
||||
"github.com/xmatic-squad/bare/internal/store"
|
||||
)
|
||||
|
||||
// MaxBody — предел тела запроса, 32 КиБ (ADR-021).
|
||||
@@ -20,13 +27,42 @@ const maxLogPath = 256
|
||||
// csp — политика из ADR-021. HSTS ставит nginx, здесь его нет.
|
||||
const csp = "default-src 'self'; img-src 'self' data:; frame-ancestors 'none'; base-uri 'none'; form-action 'self'"
|
||||
|
||||
// New собирает обработчик: /healthz, всё остальное — статика.
|
||||
// log — куда писать строки запросов; nil отключает лог.
|
||||
func New(static http.Handler, logw io.Writer) http.Handler {
|
||||
// server — общее для обработчиков: настройки, база, куда писать журнал.
|
||||
type server struct {
|
||||
cfg *config.Config
|
||||
st *store.Store
|
||||
logw io.Writer
|
||||
}
|
||||
|
||||
// New собирает обработчик: /api/, /healthz, всё остальное — статика.
|
||||
// logw — куда писать строки запросов и причины отказов; nil отключает лог.
|
||||
func New(cfg *config.Config, st *store.Store, static http.Handler, logw io.Writer) http.Handler {
|
||||
s := &server{cfg: cfg, st: st, logw: logw}
|
||||
fail := auth.Fail{Error: Error, Internal: s.internal}
|
||||
// Сессия проверяется на всех непубличных маршрутах (docs/protocol.md).
|
||||
private := auth.Require(st, fail)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /healthz", healthz)
|
||||
|
||||
mux.HandleFunc("GET /api/config", s.config)
|
||||
mux.HandleFunc("GET /api/kdf", s.kdf)
|
||||
mux.HandleFunc("POST /api/register", s.register)
|
||||
mux.HandleFunc("POST /api/login", s.login)
|
||||
|
||||
mux.Handle("GET /api/me", private(http.HandlerFunc(s.me)))
|
||||
mux.Handle("DELETE /api/me", private(http.HandlerFunc(s.deleteMe)))
|
||||
mux.Handle("POST /api/logout", private(http.HandlerFunc(s.logout)))
|
||||
mux.Handle("POST /api/password", private(http.HandlerFunc(s.password)))
|
||||
mux.Handle("GET /api/users/{nick}", private(http.HandlerFunc(s.user)))
|
||||
|
||||
// Всё прочее под /api/ — 404, включая неподдерживаемый метод известного
|
||||
// пути: кода 405 в протоколе нет (ADR-026). Этот маршрут заодно не даёт
|
||||
// запросам к /api/ уходить в обработчик статики.
|
||||
mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) { NotFound(w) })
|
||||
mux.Handle("/", static)
|
||||
return logging(logw, headers(limitBody(mux)))
|
||||
|
||||
return logging(logw, headers(auth.Origin(cfg.Origin, fail)(limitBody(mux))))
|
||||
}
|
||||
|
||||
func healthz(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -36,17 +72,23 @@ func healthz(w http.ResponseWriter, r *http.Request) {
|
||||
io.WriteString(w, "ok")
|
||||
}
|
||||
|
||||
// errorBody — единственная форма ошибки в протоколе.
|
||||
type errorBody struct {
|
||||
Error string `json:"error"`
|
||||
Field string `json:"field,omitempty"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// Error пишет ошибку в форме протокола: {"error": код, "message": текст}.
|
||||
// Единственное место, где эта форма собирается, — коды берутся из
|
||||
// перечня в docs/protocol.md.
|
||||
func Error(w http.ResponseWriter, status int, code, message string) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": code,
|
||||
"message": message,
|
||||
})
|
||||
writeJSON(w, status, errorBody{Error: code, Message: message})
|
||||
}
|
||||
|
||||
// Invalid — 400 invalid с полем, на котором остановилась валидация.
|
||||
func Invalid(w http.ResponseWriter, field, message string) {
|
||||
writeJSON(w, http.StatusBadRequest, errorBody{Error: "invalid", Field: field, Message: message})
|
||||
}
|
||||
|
||||
// NotFound — ответ на неизвестный путь и на неподдерживаемый метод
|
||||
@@ -55,6 +97,50 @@ func NotFound(w http.ResponseWriter) {
|
||||
Error(w, http.StatusNotFound, "not_found", "такого пути нет")
|
||||
}
|
||||
|
||||
// internal — 500: сбой на нашей стороне. Клиенту уходит только код,
|
||||
// причина — в журнал сервера (ADR-027).
|
||||
func (s *server) internal(w http.ResponseWriter, r *http.Request, err error) {
|
||||
s.report(r, err)
|
||||
Error(w, http.StatusInternalServerError, "internal", "сервер не справился, попробуйте позже")
|
||||
}
|
||||
|
||||
// report кладёт причину в журнал. Ник в строку не попадает: пишется
|
||||
// шаблон маршрута (docs/deploy.md, «Логи»).
|
||||
func (s *server) report(r *http.Request, err error) {
|
||||
if s.logw == nil {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(s.logw, "%s %s %s ошибка: %v\n",
|
||||
time.Now().Format(time.RFC3339), r.Method, logTarget(r), err)
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func noContent(w http.ResponseWriter) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// decode разбирает тело запроса в v. Ответ об ошибке уже написан,
|
||||
// если вернулось false.
|
||||
func decode(w http.ResponseWriter, r *http.Request, v any) bool {
|
||||
if err := json.NewDecoder(r.Body).Decode(v); err != nil {
|
||||
var large *http.MaxBytesError
|
||||
if errors.As(err, &large) {
|
||||
Error(w, http.StatusRequestEntityTooLarge, "too_large", "тело запроса больше 32 КиБ")
|
||||
return false
|
||||
}
|
||||
Error(w, http.StatusBadRequest, "bad_json", "тело запроса — не json")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// headers ставит заголовки безопасности на каждый ответ, включая ошибки.
|
||||
func headers(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -94,12 +180,39 @@ func logging(out io.Writer, next http.Handler) http.Handler {
|
||||
fmt.Fprintf(out, "%s %s %s %d %s\n",
|
||||
start.Format(time.RFC3339),
|
||||
r.Method,
|
||||
logPath(r.URL),
|
||||
logTarget(r),
|
||||
rec.status,
|
||||
time.Since(start).Round(time.Microsecond))
|
||||
})
|
||||
}
|
||||
|
||||
// logTarget — что пишется в журнал вместо пути. Для маршрутов /api/ —
|
||||
// шаблон, а не путь: ник из GET /api/users/{nick} в журнал попадать
|
||||
// не должен (docs/deploy.md, «Логи»). Для статики — сам путь: там
|
||||
// пользовательских данных нет, а знать, какой файл не нашёлся, полезно.
|
||||
// Шаблон известен после маршрутизации, поэтому вызывается после ответа.
|
||||
//
|
||||
// Шаблона может не быть вовсе: проверка Origin и предел тела отвечают
|
||||
// раньше маршрутизации. Тогда для /api/ пишется голое "/api/" — путь
|
||||
// с ником в журнал не уходит и в этом случае.
|
||||
func logTarget(r *http.Request) string {
|
||||
if p := patternPath(r.Pattern); strings.HasPrefix(p, "/api/") {
|
||||
return p
|
||||
}
|
||||
if strings.HasPrefix(r.URL.Path, "/api/") {
|
||||
return "/api/"
|
||||
}
|
||||
return logPath(r.URL)
|
||||
}
|
||||
|
||||
// patternPath отрезает от шаблона метод: "GET /api/users/{nick}" → путь.
|
||||
func patternPath(pattern string) string {
|
||||
if i := strings.LastIndexByte(pattern, ' '); i >= 0 {
|
||||
return pattern[i+1:]
|
||||
}
|
||||
return pattern
|
||||
}
|
||||
|
||||
// logPath даёт путь в percent-форме: перевод строки, escape-последовательности
|
||||
// и прочие управляющие байты в журнал не попадают — иначе любой запрос
|
||||
// подделывал бы строки в journald. Длинный путь обрезается.
|
||||
|
||||
Reference in New Issue
Block a user