Этап 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:
@@ -0,0 +1,344 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/xmatic-squad/bare/internal/auth"
|
||||
"github.com/xmatic-squad/bare/internal/config"
|
||||
"github.com/xmatic-squad/bare/internal/store"
|
||||
)
|
||||
|
||||
// GET /api/config — то, что клиенту нужно знать до входа.
|
||||
func (s *server) config(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, struct {
|
||||
InviteRequired bool `json:"inviteRequired"`
|
||||
VAPIDPublicKey string `json:"vapidPublicKey"`
|
||||
KDFIterations int `json:"kdfIterations"`
|
||||
MaxMessageChars int `json:"maxMessageChars"`
|
||||
}{
|
||||
InviteRequired: s.cfg.InviteCode != "",
|
||||
VAPIDPublicKey: s.cfg.VAPIDPublic,
|
||||
KDFIterations: config.KDFIterations,
|
||||
MaxMessageChars: config.MaxMessageChars,
|
||||
})
|
||||
}
|
||||
|
||||
// GET /api/kdf?nick= — сколько итераций PBKDF2 брать для этого ника.
|
||||
//
|
||||
// Значение лежит открытым полем iter в ключевом блобе: другого места
|
||||
// у него нет (docs/crypto.md). Неизвестный ник получает целевое значение
|
||||
// тем же статусом 200 — ответ не раскрывает, существует ли ник (ADR-015).
|
||||
func (s *server) kdf(w http.ResponseWriter, r *http.Request) {
|
||||
iterations := config.KDFIterations
|
||||
if nick := r.URL.Query().Get("nick"); validNick(nick) {
|
||||
u, err := s.st.User(r.Context(), nick)
|
||||
switch {
|
||||
case err == nil:
|
||||
if iter, err := blobIterations(u.KeyBlob); err == nil {
|
||||
iterations = iter
|
||||
}
|
||||
case errors.Is(err, store.ErrNotFound):
|
||||
// молча: целевое значение
|
||||
default:
|
||||
s.internal(w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, struct {
|
||||
Iterations int `json:"iterations"`
|
||||
}{iterations})
|
||||
}
|
||||
|
||||
// POST /api/register — регистрация. Сервер проверяет только форму:
|
||||
// содержимое блоба и стойкость пароля ему недоступны by design.
|
||||
func (s *server) register(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
Nick string `json:"nick"`
|
||||
AuthKey string `json:"authKey"`
|
||||
PublicKey json.RawMessage `json:"publicKey"`
|
||||
Blob string `json:"blob"`
|
||||
Invite string `json:"invite"`
|
||||
}
|
||||
if !decode(w, r, &in) {
|
||||
return
|
||||
}
|
||||
if code := s.cfg.InviteCode; code != "" {
|
||||
if in.Invite == "" {
|
||||
Error(w, http.StatusForbidden, "invite_required", "нужен инвайт-код")
|
||||
return
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(code), []byte(in.Invite)) != 1 {
|
||||
Error(w, http.StatusForbidden, "invalid_invite", "инвайт-код не подходит")
|
||||
return
|
||||
}
|
||||
}
|
||||
if !validNick(in.Nick) {
|
||||
Error(w, http.StatusBadRequest, "invalid_nick", "ник: 2–32 символа, a–z, 0–9, _")
|
||||
return
|
||||
}
|
||||
key, ok := authKey(in.AuthKey)
|
||||
if !ok {
|
||||
Invalid(w, "authKey", "authKey — не 32 байта base64url")
|
||||
return
|
||||
}
|
||||
public, err := publicKeyJSON(in.PublicKey)
|
||||
if err != nil {
|
||||
Invalid(w, "publicKey", err.Error())
|
||||
return
|
||||
}
|
||||
if _, err := blobIterations(in.Blob); err != nil {
|
||||
Invalid(w, "blob", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
cred, err := auth.Hash(key)
|
||||
if err != nil {
|
||||
s.internal(w, r, err)
|
||||
return
|
||||
}
|
||||
err = s.st.CreateUser(r.Context(), store.User{
|
||||
Nick: in.Nick,
|
||||
Cred: cred,
|
||||
PublicKey: public,
|
||||
KeyBlob: in.Blob,
|
||||
CreatedAt: time.Now().UnixMilli(),
|
||||
})
|
||||
if errors.Is(err, store.ErrNickTaken) {
|
||||
Error(w, http.StatusConflict, "nick_taken", "ник занят")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
s.internal(w, r, err)
|
||||
return
|
||||
}
|
||||
if err := s.startSession(w, r, in.Nick); err != nil {
|
||||
s.internal(w, r, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, struct {
|
||||
Nick string `json:"nick"`
|
||||
}{in.Nick})
|
||||
}
|
||||
|
||||
// POST /api/login — вход. Ошибка одна на все случаи: неверный ник,
|
||||
// неверный authKey и кривая форма неразличимы снаружи.
|
||||
func (s *server) login(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
Nick string `json:"nick"`
|
||||
AuthKey string `json:"authKey"`
|
||||
}
|
||||
if !decode(w, r, &in) {
|
||||
return
|
||||
}
|
||||
key, ok := authKey(in.AuthKey)
|
||||
if !ok || !validNick(in.Nick) {
|
||||
invalidCredentials(w)
|
||||
return
|
||||
}
|
||||
u, err := s.st.User(r.Context(), in.Nick)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
// Считаем впустую: вход с несуществующим ником не должен
|
||||
// отвечать заметно быстрее входа с неверным authKey.
|
||||
auth.Waste(key)
|
||||
invalidCredentials(w)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
s.internal(w, r, err)
|
||||
return
|
||||
}
|
||||
valid, rehash := auth.Verify(key, u.Cred)
|
||||
if !valid {
|
||||
invalidCredentials(w)
|
||||
return
|
||||
}
|
||||
if rehash {
|
||||
// Параметры отстали от текущих (ADR-021). Не удалось перехешировать —
|
||||
// не повод отказывать во входе: старый хеш остаётся рабочим.
|
||||
if cred, err := auth.Hash(key); err != nil {
|
||||
s.report(r, err)
|
||||
} else if err := s.st.SetAuth(r.Context(), u.Nick, cred); err != nil {
|
||||
s.report(r, err)
|
||||
}
|
||||
}
|
||||
if err := s.startSession(w, r, u.Nick); err != nil {
|
||||
s.internal(w, r, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, struct {
|
||||
Nick string `json:"nick"`
|
||||
PublicKey json.RawMessage `json:"publicKey"`
|
||||
Blob string `json:"blob"`
|
||||
}{u.Nick, json.RawMessage(u.PublicKey), u.KeyBlob})
|
||||
}
|
||||
|
||||
// GET /api/me — кто вошёл.
|
||||
func (s *server) me(w http.ResponseWriter, r *http.Request) {
|
||||
u, ok := s.self(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, struct {
|
||||
Nick string `json:"nick"`
|
||||
PublicKey json.RawMessage `json:"publicKey"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
}{u.Nick, json.RawMessage(u.PublicKey), u.CreatedAt})
|
||||
}
|
||||
|
||||
// POST /api/logout — выход на этом устройстве.
|
||||
func (s *server) logout(w http.ResponseWriter, r *http.Request) {
|
||||
sess, _ := auth.From(r)
|
||||
if err := s.st.DeleteSession(r.Context(), sess.TokenHash); err != nil {
|
||||
s.internal(w, r, err)
|
||||
return
|
||||
}
|
||||
auth.ClearCookie(w)
|
||||
noContent(w)
|
||||
}
|
||||
|
||||
// POST /api/password — смена пароля и повышение итераций: одна операция
|
||||
// (ADR-015). Хеш и блоб меняются в одной транзакции.
|
||||
func (s *server) password(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
AuthKey string `json:"authKey"`
|
||||
NewAuthKey string `json:"newAuthKey"`
|
||||
Blob string `json:"blob"`
|
||||
LogoutOthers bool `json:"logoutOthers"`
|
||||
}
|
||||
if !decode(w, r, &in) {
|
||||
return
|
||||
}
|
||||
u, ok := s.self(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !s.confirm(w, in.AuthKey, u) {
|
||||
return
|
||||
}
|
||||
newKey, ok := authKey(in.NewAuthKey)
|
||||
if !ok {
|
||||
Invalid(w, "newAuthKey", "newAuthKey — не 32 байта base64url")
|
||||
return
|
||||
}
|
||||
if _, err := blobIterations(in.Blob); err != nil {
|
||||
Invalid(w, "blob", err.Error())
|
||||
return
|
||||
}
|
||||
cred, err := auth.Hash(newKey)
|
||||
if err != nil {
|
||||
s.internal(w, r, err)
|
||||
return
|
||||
}
|
||||
sess, _ := auth.From(r)
|
||||
if err := s.st.SetPassword(r.Context(), u.Nick, cred, in.Blob, in.LogoutOthers, sess.TokenHash); err != nil {
|
||||
s.internal(w, r, err)
|
||||
return
|
||||
}
|
||||
noContent(w)
|
||||
}
|
||||
|
||||
// DELETE /api/me — удаление аккаунта, подтверждённое authKey.
|
||||
func (s *server) deleteMe(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
AuthKey string `json:"authKey"`
|
||||
}
|
||||
if !decode(w, r, &in) {
|
||||
return
|
||||
}
|
||||
u, ok := s.self(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !s.confirm(w, in.AuthKey, u) {
|
||||
return
|
||||
}
|
||||
// Устройства, сессии, контакты, членство и очереди уносит каскад.
|
||||
// Комнаты, где пользователь владелец, требуют передачи владения
|
||||
// (ADR-018) — это этап 3, до появления комнат случай не наступает.
|
||||
if err := s.st.DeleteUser(r.Context(), u.Nick); err != nil {
|
||||
s.internal(w, r, err)
|
||||
return
|
||||
}
|
||||
auth.ClearCookie(w)
|
||||
noContent(w)
|
||||
}
|
||||
|
||||
// GET /api/users/{nick} — публичный ключ собеседника. Доверие к нему —
|
||||
// TOFU на клиенте (ADR-016).
|
||||
func (s *server) user(w http.ResponseWriter, r *http.Request) {
|
||||
nick := r.PathValue("nick")
|
||||
if !validNick(nick) {
|
||||
unknownUser(w)
|
||||
return
|
||||
}
|
||||
u, err := s.st.User(r.Context(), nick)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
unknownUser(w)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
s.internal(w, r, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, struct {
|
||||
Nick string `json:"nick"`
|
||||
PublicKey json.RawMessage `json:"publicKey"`
|
||||
}{u.Nick, json.RawMessage(u.PublicKey)})
|
||||
}
|
||||
|
||||
// self читает пользователя сессии. Строки нет — сессия недействительна:
|
||||
// аккаунт удалён на другом устройстве.
|
||||
func (s *server) self(w http.ResponseWriter, r *http.Request) (store.User, bool) {
|
||||
sess, _ := auth.From(r)
|
||||
u, err := s.st.User(r.Context(), sess.Nick)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
Error(w, http.StatusUnauthorized, "unauthenticated", "нужен вход")
|
||||
return store.User{}, false
|
||||
}
|
||||
if err != nil {
|
||||
s.internal(w, r, err)
|
||||
return store.User{}, false
|
||||
}
|
||||
return u, true
|
||||
}
|
||||
|
||||
// confirm проверяет authKey — подтверждение опасной операции.
|
||||
func (s *server) confirm(w http.ResponseWriter, given string, u store.User) bool {
|
||||
key, ok := authKey(given)
|
||||
if !ok {
|
||||
invalidCredentials(w)
|
||||
return false
|
||||
}
|
||||
if valid, _ := auth.Verify(key, u.Cred); !valid {
|
||||
invalidCredentials(w)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// startSession выдаёт сессию и ставит cookie.
|
||||
func (s *server) startSession(w http.ResponseWriter, r *http.Request, nick string) error {
|
||||
token, hash, err := auth.NewToken()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now()
|
||||
expires := now.Add(auth.TTL)
|
||||
if err := s.st.CreateSession(r.Context(), hash, nick, now.UnixMilli(), expires.UnixMilli()); err != nil {
|
||||
return err
|
||||
}
|
||||
auth.SetCookie(w, token, expires)
|
||||
return nil
|
||||
}
|
||||
|
||||
func invalidCredentials(w http.ResponseWriter) {
|
||||
Error(w, http.StatusUnauthorized, "invalid_credentials", "неверный ник или пароль")
|
||||
}
|
||||
|
||||
func unknownUser(w http.ResponseWriter) {
|
||||
Error(w, http.StatusNotFound, "unknown_user", "такого ника нет")
|
||||
}
|
||||
@@ -0,0 +1,464 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/xmatic-squad/bare/internal/api"
|
||||
"github.com/xmatic-squad/bare/internal/config"
|
||||
)
|
||||
|
||||
// bytesOf — детерминированные «случайные» байты: содержимое сервер
|
||||
// не проверяет, ему важна только форма.
|
||||
func bytesOf(n int, seed byte) string {
|
||||
raw := make([]byte, n)
|
||||
for i := range raw {
|
||||
raw[i] = seed + byte(i)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(raw)
|
||||
}
|
||||
|
||||
// blobOf — ключевой блоб в форме docs/crypto.md.
|
||||
func blobOf(iter int) string {
|
||||
return fmt.Sprintf(`{"v":1,"iter":%d,"iv":"%s","ct":"%s"}`, iter, bytesOf(12, 7), bytesOf(48, 11))
|
||||
}
|
||||
|
||||
func jwk() map[string]string {
|
||||
return map[string]string{"kty": "EC", "crv": "P-256", "x": bytesOf(32, 3), "y": bytesOf(32, 5)}
|
||||
}
|
||||
|
||||
func account(nick string) map[string]any {
|
||||
return map[string]any{
|
||||
"nick": nick,
|
||||
"authKey": bytesOf(32, 1),
|
||||
"publicKey": jwk(),
|
||||
"blob": blobOf(config.KDFIterations),
|
||||
}
|
||||
}
|
||||
|
||||
// signUp регистрирует аккаунт и отдаёт cookie сессии.
|
||||
func (e *env) signUp(nick string) *http.Cookie {
|
||||
e.t.Helper()
|
||||
rec := e.do(http.MethodPost, "/api/register", account(nick))
|
||||
expect(e.t, rec, http.StatusCreated, "")
|
||||
return e.cookie(rec)
|
||||
}
|
||||
|
||||
func (e *env) cookie(rec *httptest.ResponseRecorder) *http.Cookie {
|
||||
e.t.Helper()
|
||||
for _, c := range rec.Result().Cookies() {
|
||||
if c.Name == "bare_session" {
|
||||
return c
|
||||
}
|
||||
}
|
||||
e.t.Fatal("в ответе нет cookie bare_session")
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestConfig(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
rec := e.do(http.MethodGet, "/api/config", nil)
|
||||
expect(t, rec, http.StatusOK, "")
|
||||
|
||||
var got struct {
|
||||
InviteRequired bool `json:"inviteRequired"`
|
||||
VAPIDPublicKey string `json:"vapidPublicKey"`
|
||||
KDFIterations int `json:"kdfIterations"`
|
||||
MaxMessageChars int `json:"maxMessageChars"`
|
||||
}
|
||||
decodeBody(t, rec, &got)
|
||||
if got.InviteRequired {
|
||||
t.Error("inviteRequired: получено true, ожидалось false")
|
||||
}
|
||||
if got.VAPIDPublicKey != "vapid" {
|
||||
t.Errorf("vapidPublicKey: получено %q", got.VAPIDPublicKey)
|
||||
}
|
||||
if got.KDFIterations != 1_000_000 {
|
||||
t.Errorf("kdfIterations: получено %d, ожидалось 1000000", got.KDFIterations)
|
||||
}
|
||||
if got.MaxMessageChars != 4000 {
|
||||
t.Errorf("maxMessageChars: получено %d, ожидалось 4000", got.MaxMessageChars)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterAndLogin(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
|
||||
rec := e.do(http.MethodPost, "/api/register", account("marta"))
|
||||
expect(t, rec, http.StatusCreated, "")
|
||||
var created struct {
|
||||
Nick string `json:"nick"`
|
||||
}
|
||||
decodeBody(t, rec, &created)
|
||||
if created.Nick != "marta" {
|
||||
t.Errorf("ник в ответе: получено %q", created.Nick)
|
||||
}
|
||||
|
||||
c := e.cookie(rec)
|
||||
if !c.HttpOnly || !c.Secure || c.SameSite != http.SameSiteStrictMode || c.Path != "/" {
|
||||
t.Errorf("флаги cookie: %+v", c)
|
||||
}
|
||||
if c.MaxAge < 89*24*3600 || c.MaxAge > 90*24*3600 {
|
||||
t.Errorf("срок cookie: получено %d секунд, ожидалось около 90 суток", c.MaxAge)
|
||||
}
|
||||
|
||||
// Занятый ник.
|
||||
expect(t, e.do(http.MethodPost, "/api/register", account("marta")), http.StatusConflict, "nick_taken")
|
||||
|
||||
// Сессия из регистрации работает.
|
||||
me := e.do(http.MethodGet, "/api/me", nil, with(c))
|
||||
expect(t, me, http.StatusOK, "")
|
||||
var self struct {
|
||||
Nick string `json:"nick"`
|
||||
PublicKey map[string]string `json:"publicKey"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
}
|
||||
decodeBody(t, me, &self)
|
||||
if self.Nick != "marta" || self.PublicKey["crv"] != "P-256" || self.CreatedAt == 0 {
|
||||
t.Errorf("GET /api/me: %+v", self)
|
||||
}
|
||||
|
||||
// Вход тем же authKey отдаёт публичный ключ и блоб.
|
||||
login := e.do(http.MethodPost, "/api/login", map[string]any{"nick": "marta", "authKey": bytesOf(32, 1)})
|
||||
expect(t, login, http.StatusOK, "")
|
||||
var in struct {
|
||||
Nick string `json:"nick"`
|
||||
PublicKey map[string]string `json:"publicKey"`
|
||||
Blob string `json:"blob"`
|
||||
}
|
||||
decodeBody(t, login, &in)
|
||||
if in.Nick != "marta" || in.Blob != blobOf(config.KDFIterations) || in.PublicKey["x"] != bytesOf(32, 3) {
|
||||
t.Errorf("вход: %+v", in)
|
||||
}
|
||||
if _, ok := in.PublicKey["d"]; ok {
|
||||
t.Error("в публичном ключе есть d")
|
||||
}
|
||||
e.cookie(login)
|
||||
|
||||
// Неверный authKey и несуществующий ник неразличимы.
|
||||
bad := e.do(http.MethodPost, "/api/login", map[string]any{"nick": "marta", "authKey": bytesOf(32, 9)})
|
||||
expect(t, bad, http.StatusUnauthorized, "invalid_credentials")
|
||||
none := e.do(http.MethodPost, "/api/login", map[string]any{"nick": "никого", "authKey": bytesOf(32, 1)})
|
||||
expect(t, none, http.StatusUnauthorized, "invalid_credentials")
|
||||
unknown := e.do(http.MethodPost, "/api/login", map[string]any{"nick": "petya", "authKey": bytesOf(32, 1)})
|
||||
expect(t, unknown, http.StatusUnauthorized, "invalid_credentials")
|
||||
}
|
||||
|
||||
func TestRegisterRejects(t *testing.T) {
|
||||
private := jwk()
|
||||
private["d"] = bytesOf(32, 13)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
change func(map[string]any)
|
||||
status int
|
||||
code string
|
||||
field string
|
||||
}{
|
||||
{"кривой ник", func(m map[string]any) { m["nick"] = "Марта" }, http.StatusBadRequest, "invalid_nick", ""},
|
||||
{"короткий ник", func(m map[string]any) { m["nick"] = "m" }, http.StatusBadRequest, "invalid_nick", ""},
|
||||
{"ник с заглавной", func(m map[string]any) { m["nick"] = "Marta" }, http.StatusBadRequest, "invalid_nick", ""},
|
||||
{"короткий authKey", func(m map[string]any) { m["authKey"] = bytesOf(16, 1) }, http.StatusBadRequest, "invalid", "authKey"},
|
||||
{"authKey не base64url", func(m map[string]any) { m["authKey"] = strings.Repeat("=", 44) }, http.StatusBadRequest, "invalid", "authKey"},
|
||||
{"приватный ключ в jwk", func(m map[string]any) { m["publicKey"] = private }, http.StatusBadRequest, "invalid", "publicKey"},
|
||||
{"чужая кривая", func(m map[string]any) {
|
||||
k := jwk()
|
||||
k["crv"] = "P-384"
|
||||
m["publicKey"] = k
|
||||
}, http.StatusBadRequest, "invalid", "publicKey"},
|
||||
{"нет публичного ключа", func(m map[string]any) { delete(m, "publicKey") }, http.StatusBadRequest, "invalid", "publicKey"},
|
||||
{"слабый iter", func(m map[string]any) { m["blob"] = blobOf(599_999) }, http.StatusBadRequest, "invalid", "blob"},
|
||||
// Неподъёмный iter сервер отдал бы клиентам из GET /api/kdf (ADR-030).
|
||||
{"неподъёмный iter", func(m map[string]any) {
|
||||
m["blob"] = blobOf(config.KDFMaxIterations + 1)
|
||||
}, http.StatusBadRequest, "invalid", "blob"},
|
||||
{"iter в триллион", func(m map[string]any) { m["blob"] = blobOf(1_000_000_000_000) }, http.StatusBadRequest, "invalid", "blob"},
|
||||
{"дробный iter", func(m map[string]any) {
|
||||
m["blob"] = `{"v":1,"iter":1e6,"iv":"` + bytesOf(12, 7) + `","ct":"` + bytesOf(48, 11) + `"}`
|
||||
}, http.StatusBadRequest, "invalid", "blob"},
|
||||
{"версия блоба", func(m map[string]any) {
|
||||
m["blob"] = strings.Replace(blobOf(config.KDFIterations), `"v":1`, `"v":2`, 1)
|
||||
}, http.StatusBadRequest, "invalid", "blob"},
|
||||
{"блоб больше 8 КиБ", func(m map[string]any) {
|
||||
m["blob"] = fmt.Sprintf(`{"v":1,"iter":%d,"iv":"%s","ct":"%s"}`,
|
||||
config.KDFIterations, bytesOf(12, 7), strings.Repeat("a", 8<<10))
|
||||
}, http.StatusBadRequest, "invalid", "blob"},
|
||||
{"блоб не json", func(m map[string]any) { m["blob"] = "не json" }, http.StatusBadRequest, "invalid", "blob"},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
body := account("marta")
|
||||
c.change(body)
|
||||
rec := e.do(http.MethodPost, "/api/register", body)
|
||||
expect(t, rec, c.status, c.code)
|
||||
if c.field != "" {
|
||||
var got struct {
|
||||
Field string `json:"field"`
|
||||
}
|
||||
decodeBody(t, rec, &got)
|
||||
if got.Field != c.field {
|
||||
t.Errorf("field: получено %q, ожидалось %q", got.Field, c.field)
|
||||
}
|
||||
}
|
||||
// Ни одна из этих регистраций не должна была создать аккаунт.
|
||||
expect(t, e.do(http.MethodPost, "/api/login", map[string]any{"nick": "marta", "authKey": bytesOf(32, 1)}),
|
||||
http.StatusUnauthorized, "invalid_credentials")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterBadJSON(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
expect(t, e.do(http.MethodPost, "/api/register", "{"), http.StatusBadRequest, "bad_json")
|
||||
}
|
||||
|
||||
func TestInvite(t *testing.T) {
|
||||
e := invited(t, "секрет")
|
||||
|
||||
rec := e.do(http.MethodGet, "/api/config", nil)
|
||||
var cfg struct {
|
||||
InviteRequired bool `json:"inviteRequired"`
|
||||
}
|
||||
decodeBody(t, rec, &cfg)
|
||||
if !cfg.InviteRequired {
|
||||
t.Error("inviteRequired: получено false, ожидалось true")
|
||||
}
|
||||
|
||||
expect(t, e.do(http.MethodPost, "/api/register", account("marta")), http.StatusForbidden, "invite_required")
|
||||
|
||||
wrong := account("marta")
|
||||
wrong["invite"] = "не секрет"
|
||||
expect(t, e.do(http.MethodPost, "/api/register", wrong), http.StatusForbidden, "invalid_invite")
|
||||
|
||||
right := account("marta")
|
||||
right["invite"] = "секрет"
|
||||
expect(t, e.do(http.MethodPost, "/api/register", right), http.StatusCreated, "")
|
||||
}
|
||||
|
||||
func TestKDF(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
|
||||
// Неизвестный ник — целевое значение, тем же статусом.
|
||||
for _, nick := range []string{"marta", "", "МАРТА", strings.Repeat("x", 40)} {
|
||||
rec := e.do(http.MethodGet, "/api/kdf?nick="+nick, nil)
|
||||
expect(t, rec, http.StatusOK, "")
|
||||
var got struct {
|
||||
Iterations int `json:"iterations"`
|
||||
}
|
||||
decodeBody(t, rec, &got)
|
||||
if got.Iterations != config.KDFIterations {
|
||||
t.Errorf("iterations для %q: получено %d, ожидалось %d", nick, got.Iterations, config.KDFIterations)
|
||||
}
|
||||
}
|
||||
|
||||
// Известный ник — iter из его блоба.
|
||||
body := account("marta")
|
||||
body["blob"] = blobOf(700_000)
|
||||
expect(t, e.do(http.MethodPost, "/api/register", body), http.StatusCreated, "")
|
||||
|
||||
rec := e.do(http.MethodGet, "/api/kdf?nick=marta", nil)
|
||||
expect(t, rec, http.StatusOK, "")
|
||||
var got struct {
|
||||
Iterations int `json:"iterations"`
|
||||
}
|
||||
decodeBody(t, rec, &got)
|
||||
if got.Iterations != 700_000 {
|
||||
t.Errorf("iterations: получено %d, ожидалось 700000", got.Iterations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrigin(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
body := map[string]any{"nick": "marta", "authKey": bytesOf(32, 1)}
|
||||
|
||||
expect(t, e.do(http.MethodPost, "/api/login", body, withOrigin("")), http.StatusForbidden, "bad_origin")
|
||||
expect(t, e.do(http.MethodPost, "/api/login", body, withOrigin("https://зло.example")), http.StatusForbidden, "bad_origin")
|
||||
expect(t, e.do(http.MethodPost, "/api/login", body, withOrigin("null")), http.StatusForbidden, "bad_origin")
|
||||
|
||||
// GET без Origin работает.
|
||||
expect(t, e.do(http.MethodGet, "/api/config", nil), http.StatusOK, "")
|
||||
expect(t, e.do(http.MethodGet, "/", nil), http.StatusOK, "")
|
||||
|
||||
// Свой Origin проходит: дальше — обычная ошибка входа, не 403.
|
||||
expect(t, e.do(http.MethodPost, "/api/login", body), http.StatusUnauthorized, "invalid_credentials")
|
||||
}
|
||||
|
||||
func TestPasswordChange(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
first := e.signUp("marta")
|
||||
|
||||
// Второе устройство: свой вход, своя сессия.
|
||||
login := e.do(http.MethodPost, "/api/login", map[string]any{"nick": "marta", "authKey": bytesOf(32, 1)})
|
||||
expect(t, login, http.StatusOK, "")
|
||||
second := e.cookie(login)
|
||||
|
||||
newBlob := blobOf(config.KDFIterations)
|
||||
change := map[string]any{
|
||||
"authKey": bytesOf(32, 1),
|
||||
"newAuthKey": bytesOf(32, 9),
|
||||
"blob": newBlob,
|
||||
"logoutOthers": true,
|
||||
}
|
||||
|
||||
// Без сессии — 401 unauthenticated, а не invalid_credentials.
|
||||
expect(t, e.do(http.MethodPost, "/api/password", change), http.StatusUnauthorized, "unauthenticated")
|
||||
|
||||
// Неверный старый authKey.
|
||||
wrong := map[string]any{"authKey": bytesOf(32, 42), "newAuthKey": bytesOf(32, 9), "blob": newBlob}
|
||||
expect(t, e.do(http.MethodPost, "/api/password", wrong, with(second)), http.StatusUnauthorized, "invalid_credentials")
|
||||
|
||||
// Слабый новый блоб не принимается.
|
||||
weak := map[string]any{"authKey": bytesOf(32, 1), "newAuthKey": bytesOf(32, 9), "blob": blobOf(599_999)}
|
||||
expect(t, e.do(http.MethodPost, "/api/password", weak, with(second)), http.StatusBadRequest, "invalid")
|
||||
|
||||
expect(t, e.do(http.MethodPost, "/api/password", change, with(second)), http.StatusNoContent, "")
|
||||
|
||||
// Текущая сессия жива, остальные — нет.
|
||||
expect(t, e.do(http.MethodGet, "/api/me", nil, with(second)), http.StatusOK, "")
|
||||
expect(t, e.do(http.MethodGet, "/api/me", nil, with(first)), http.StatusUnauthorized, "unauthenticated")
|
||||
|
||||
// Старый authKey больше не подходит, новый отдаёт новый блоб.
|
||||
expect(t, e.do(http.MethodPost, "/api/login", map[string]any{"nick": "marta", "authKey": bytesOf(32, 1)}),
|
||||
http.StatusUnauthorized, "invalid_credentials")
|
||||
fresh := e.do(http.MethodPost, "/api/login", map[string]any{"nick": "marta", "authKey": bytesOf(32, 9)})
|
||||
expect(t, fresh, http.StatusOK, "")
|
||||
var got struct {
|
||||
Blob string `json:"blob"`
|
||||
}
|
||||
decodeBody(t, fresh, &got)
|
||||
if got.Blob != newBlob {
|
||||
t.Errorf("блоб после смены пароля: получено %q", got.Blob)
|
||||
}
|
||||
}
|
||||
|
||||
// Повышение итераций — та же операция без выхода на других устройствах.
|
||||
func TestPasswordKeepsOtherSessions(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
first := e.signUp("marta")
|
||||
login := e.do(http.MethodPost, "/api/login", map[string]any{"nick": "marta", "authKey": bytesOf(32, 1)})
|
||||
second := e.cookie(login)
|
||||
|
||||
change := map[string]any{
|
||||
"authKey": bytesOf(32, 1),
|
||||
"newAuthKey": bytesOf(32, 9),
|
||||
"blob": blobOf(config.KDFIterations),
|
||||
"logoutOthers": false,
|
||||
}
|
||||
expect(t, e.do(http.MethodPost, "/api/password", change, with(second)), http.StatusNoContent, "")
|
||||
expect(t, e.do(http.MethodGet, "/api/me", nil, with(first)), http.StatusOK, "")
|
||||
}
|
||||
|
||||
func TestSessionRequired(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.signUp("marta")
|
||||
|
||||
for _, target := range []string{"/api/me", "/api/users/marta"} {
|
||||
expect(t, e.do(http.MethodGet, target, nil), http.StatusUnauthorized, "unauthenticated")
|
||||
}
|
||||
expect(t, e.do(http.MethodPost, "/api/logout", nil), http.StatusUnauthorized, "unauthenticated")
|
||||
|
||||
garbage := &http.Cookie{Name: "bare_session", Value: "not-a-token"}
|
||||
expect(t, e.do(http.MethodGet, "/api/me", nil, with(garbage)), http.StatusUnauthorized, "unauthenticated")
|
||||
|
||||
stranger := &http.Cookie{Name: "bare_session", Value: bytesOf(32, 77)}
|
||||
expect(t, e.do(http.MethodGet, "/api/me", nil, with(stranger)), http.StatusUnauthorized, "unauthenticated")
|
||||
}
|
||||
|
||||
func TestLogout(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
c := e.signUp("marta")
|
||||
|
||||
rec := e.do(http.MethodPost, "/api/logout", nil, with(c))
|
||||
expect(t, rec, http.StatusNoContent, "")
|
||||
if cleared := e.cookie(rec); cleared.Value != "" || cleared.MaxAge >= 0 {
|
||||
t.Errorf("cookie не стёрта: %+v", cleared)
|
||||
}
|
||||
expect(t, e.do(http.MethodGet, "/api/me", nil, with(c)), http.StatusUnauthorized, "unauthenticated")
|
||||
}
|
||||
|
||||
func TestUsers(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
c := e.signUp("marta")
|
||||
|
||||
rec := e.do(http.MethodGet, "/api/users/marta", nil, with(c))
|
||||
expect(t, rec, http.StatusOK, "")
|
||||
var got struct {
|
||||
Nick string `json:"nick"`
|
||||
PublicKey json.RawMessage `json:"publicKey"`
|
||||
}
|
||||
decodeBody(t, rec, &got)
|
||||
if got.Nick != "marta" || !strings.Contains(string(got.PublicKey), `"P-256"`) {
|
||||
t.Errorf("ответ: %s", rec.Body.String())
|
||||
}
|
||||
|
||||
expect(t, e.do(http.MethodGet, "/api/users/petya", nil, with(c)), http.StatusNotFound, "unknown_user")
|
||||
expect(t, e.do(http.MethodGet, "/api/users/МАРТА", nil, with(c)), http.StatusNotFound, "unknown_user")
|
||||
}
|
||||
|
||||
func TestDeleteMe(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
c := e.signUp("marta")
|
||||
|
||||
expect(t, e.do(http.MethodDelete, "/api/me", map[string]any{"authKey": bytesOf(32, 42)}, with(c)),
|
||||
http.StatusUnauthorized, "invalid_credentials")
|
||||
|
||||
rec := e.do(http.MethodDelete, "/api/me", map[string]any{"authKey": bytesOf(32, 1)}, with(c))
|
||||
expect(t, rec, http.StatusNoContent, "")
|
||||
if cleared := e.cookie(rec); cleared.Value != "" || cleared.MaxAge >= 0 {
|
||||
t.Errorf("cookie не стёрта: %+v", cleared)
|
||||
}
|
||||
|
||||
// Сессия ушла каскадом, ник свободен.
|
||||
expect(t, e.do(http.MethodGet, "/api/me", nil, with(c)), http.StatusUnauthorized, "unauthenticated")
|
||||
expect(t, e.do(http.MethodPost, "/api/login", map[string]any{"nick": "marta", "authKey": bytesOf(32, 1)}),
|
||||
http.StatusUnauthorized, "invalid_credentials")
|
||||
expect(t, e.do(http.MethodPost, "/api/register", account("marta")), http.StatusCreated, "")
|
||||
}
|
||||
|
||||
// Ник не должен попадать в журнал (docs/deploy.md, «Логи»).
|
||||
func TestNickStaysOutOfLog(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
c := e.signUp("marta")
|
||||
e.log.Reset()
|
||||
|
||||
e.do(http.MethodGet, "/api/users/marta", nil, with(c))
|
||||
e.do(http.MethodGet, "/api/kdf?nick=marta", nil)
|
||||
e.do(http.MethodPost, "/api/login", map[string]any{"nick": "marta", "authKey": bytesOf(32, 1)})
|
||||
|
||||
if strings.Contains(e.log.String(), "marta") {
|
||||
t.Errorf("ник в журнале: %q", e.log.String())
|
||||
}
|
||||
if !strings.Contains(e.log.String(), "/api/users/{nick}") {
|
||||
t.Errorf("шаблон маршрута не в журнале: %q", e.log.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Отказ до маршрутизации — шаблона ещё нет, а путь с ником в журнал
|
||||
// попадать не должен всё равно (docs/deploy.md, «Логи»).
|
||||
func TestNickStaysOutOfLogBeforeRouting(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
|
||||
// 403 bad_origin: любой не-GET со стороннего сайта.
|
||||
e.do(http.MethodPost, "/api/users/marta", nil, withOrigin("https://зло.example"))
|
||||
e.do(http.MethodDelete, "/api/contacts/marta", nil, withOrigin(""))
|
||||
// 413 too_large: тело больше предела, ответ до маршрутизации.
|
||||
e.do(http.MethodGet, "/api/users/marta", strings.Repeat("a", api.MaxBody+1))
|
||||
|
||||
line := e.log.String()
|
||||
if strings.Count(line, "\n") != 3 {
|
||||
t.Fatalf("строк в журнале: %q", line)
|
||||
}
|
||||
if strings.Contains(line, "marta") {
|
||||
t.Errorf("ник в журнале: %q", line)
|
||||
}
|
||||
if strings.Count(line, "/api/ ") != 3 {
|
||||
t.Errorf("вместо пути ожидалось \"/api/\": %q", line)
|
||||
}
|
||||
}
|
||||
+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. Длинный путь обрезается.
|
||||
|
||||
+174
-62
@@ -6,37 +6,140 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/xmatic-squad/bare/internal/api"
|
||||
"github.com/xmatic-squad/bare/internal/config"
|
||||
"github.com/xmatic-squad/bare/internal/store"
|
||||
"github.com/xmatic-squad/bare/internal/web"
|
||||
)
|
||||
|
||||
func handler(t *testing.T) http.Handler {
|
||||
const origin = "https://bare.test"
|
||||
|
||||
// env — сервер на временной базе плюс журнал, в который он пишет.
|
||||
type env struct {
|
||||
t *testing.T
|
||||
h http.Handler
|
||||
st *store.Store
|
||||
log *bytes.Buffer
|
||||
}
|
||||
|
||||
func newEnv(t *testing.T) *env { return invited(t, "") }
|
||||
|
||||
// invited — сервер на временной базе; непустой code включает инвайты.
|
||||
func invited(t *testing.T, code string) *env {
|
||||
t.Helper()
|
||||
static, err := web.New()
|
||||
if err != nil {
|
||||
t.Fatalf("web.New: %v", err)
|
||||
}
|
||||
return api.New(static, nil)
|
||||
st, err := store.Open(filepath.Join(t.TempDir(), "bare.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("store.Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
|
||||
cfg := &config.Config{
|
||||
Addr: "127.0.0.1:0",
|
||||
DB: "bare.db",
|
||||
Origin: origin,
|
||||
VAPIDPublic: "vapid",
|
||||
InviteCode: code,
|
||||
}
|
||||
e := &env{t: t, st: st, log: &bytes.Buffer{}}
|
||||
e.h = api.New(cfg, st, static, e.log)
|
||||
return e
|
||||
}
|
||||
|
||||
// do отправляет запрос. Origin для методов кроме GET и HEAD ставится сам —
|
||||
// без него любой такой запрос получил бы 403 (ADR-021).
|
||||
func (e *env) do(method, target string, body any, opts ...func(*http.Request)) *httptest.ResponseRecorder {
|
||||
e.t.Helper()
|
||||
var reader io.Reader
|
||||
switch v := body.(type) {
|
||||
case nil:
|
||||
case string:
|
||||
reader = strings.NewReader(v)
|
||||
default:
|
||||
raw, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
e.t.Fatalf("сборка тела: %v", err)
|
||||
}
|
||||
reader = bytes.NewReader(raw)
|
||||
}
|
||||
r := httptest.NewRequest(method, target, reader)
|
||||
if method != http.MethodGet && method != http.MethodHead {
|
||||
r.Header.Set("Origin", origin)
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(r)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
e.h.ServeHTTP(rec, r)
|
||||
return rec
|
||||
}
|
||||
|
||||
func with(c *http.Cookie) func(*http.Request) {
|
||||
return func(r *http.Request) {
|
||||
if c != nil {
|
||||
r.AddCookie(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func withOrigin(value string) func(*http.Request) {
|
||||
return func(r *http.Request) {
|
||||
if value == "" {
|
||||
r.Header.Del("Origin")
|
||||
return
|
||||
}
|
||||
r.Header.Set("Origin", value)
|
||||
}
|
||||
}
|
||||
|
||||
// code достаёт код ошибки из тела ответа.
|
||||
func code(t *testing.T, rec *httptest.ResponseRecorder) string {
|
||||
t.Helper()
|
||||
var body struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("разбор тела %q: %v", rec.Body.String(), err)
|
||||
}
|
||||
return body.Error
|
||||
}
|
||||
|
||||
// expect проверяет статус и код ошибки; код "" — ответ без ошибки.
|
||||
func expect(t *testing.T, rec *httptest.ResponseRecorder, status int, errCode string) {
|
||||
t.Helper()
|
||||
if rec.Code != status {
|
||||
t.Fatalf("статус: получено %d (%s), ожидалось %d", rec.Code, rec.Body.String(), status)
|
||||
}
|
||||
if errCode != "" {
|
||||
if got := code(t, rec); got != errCode {
|
||||
t.Errorf("код ошибки: получено %q, ожидалось %q", got, errCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func decodeBody(t *testing.T, rec *httptest.ResponseRecorder, v any) {
|
||||
t.Helper()
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), v); err != nil {
|
||||
t.Fatalf("разбор тела %q: %v", rec.Body.String(), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthz(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
handler(t).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
||||
e := newEnv(t)
|
||||
rec := e.do(http.MethodGet, "/healthz", nil)
|
||||
|
||||
res := rec.Result()
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Errorf("статус: получено %d, ожидалось 200", res.StatusCode)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("статус: получено %d, ожидалось 200", rec.Code)
|
||||
}
|
||||
body, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("чтение тела: %v", err)
|
||||
}
|
||||
if string(body) != "ok" {
|
||||
t.Errorf("тело: получено %q, ожидалось \"ok\"", body)
|
||||
if rec.Body.String() != "ok" {
|
||||
t.Errorf("тело: получено %q, ожидалось \"ok\"", rec.Body.String())
|
||||
}
|
||||
|
||||
want := map[string]string{
|
||||
@@ -45,17 +148,16 @@ func TestHealthz(t *testing.T) {
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
}
|
||||
for header, value := range want {
|
||||
if got := res.Header.Get(header); got != value {
|
||||
if got := rec.Header().Get(header); got != value {
|
||||
t.Errorf("%s: получено %q, ожидалось %q", header, got, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaticNotModified(t *testing.T) {
|
||||
h := handler(t)
|
||||
e := newEnv(t)
|
||||
|
||||
first := httptest.NewRecorder()
|
||||
h.ServeHTTP(first, httptest.NewRequest(http.MethodGet, "/app.css", nil))
|
||||
first := e.do(http.MethodGet, "/app.css", nil)
|
||||
if first.Code != http.StatusOK {
|
||||
t.Fatalf("статус: получено %d, ожидалось 200", first.Code)
|
||||
}
|
||||
@@ -67,11 +169,9 @@ func TestStaticNotModified(t *testing.T) {
|
||||
t.Errorf("Cache-Control: получено %q, ожидалось \"no-cache\"", got)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/app.css", nil)
|
||||
req.Header.Set("If-None-Match", etag)
|
||||
second := httptest.NewRecorder()
|
||||
h.ServeHTTP(second, req)
|
||||
|
||||
second := e.do(http.MethodGet, "/app.css", nil, func(r *http.Request) {
|
||||
r.Header.Set("If-None-Match", etag)
|
||||
})
|
||||
if second.Code != http.StatusNotModified {
|
||||
t.Errorf("статус: получено %d, ожидалось 304", second.Code)
|
||||
}
|
||||
@@ -84,52 +184,44 @@ func TestStaticNotModified(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBodyTooLarge(t *testing.T) {
|
||||
body := strings.NewReader(strings.Repeat("a", api.MaxBody+1))
|
||||
rec := httptest.NewRecorder()
|
||||
handler(t).ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/api/nope", body))
|
||||
e := newEnv(t)
|
||||
rec := e.do(http.MethodPost, "/api/login", strings.Repeat("a", api.MaxBody+1))
|
||||
expect(t, rec, http.StatusRequestEntityTooLarge, "too_large")
|
||||
}
|
||||
|
||||
if rec.Code != http.StatusRequestEntityTooLarge {
|
||||
t.Fatalf("статус: получено %d, ожидалось 413", rec.Code)
|
||||
}
|
||||
var got map[string]string
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("разбор тела: %v", err)
|
||||
}
|
||||
if got["error"] != "too_large" {
|
||||
t.Errorf("код ошибки: получено %q, ожидалось \"too_large\"", got["error"])
|
||||
}
|
||||
// Тело без заявленной длины обрывается при чтении — тем же кодом.
|
||||
func TestBodyTooLargeUnannounced(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
// Тело — валидный json, чтобы разбор дошёл до предела чтения, а не
|
||||
// споткнулся о первый же байт.
|
||||
body := `{"nick":"` + strings.Repeat("a", api.MaxBody) + `"}`
|
||||
rec := e.do(http.MethodPost, "/api/login", nil, func(r *http.Request) {
|
||||
r.Body = io.NopCloser(strings.NewReader(body))
|
||||
r.ContentLength = -1
|
||||
})
|
||||
expect(t, rec, http.StatusRequestEntityTooLarge, "too_large")
|
||||
}
|
||||
|
||||
// Неподдерживаемый метод на известном пути — 404 not_found (ADR-026).
|
||||
func TestStaticRejectsWrite(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
handler(t).ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/app.css", nil))
|
||||
e := newEnv(t)
|
||||
expect(t, e.do(http.MethodPost, "/app.css", nil), http.StatusNotFound, "not_found")
|
||||
}
|
||||
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("статус: получено %d, ожидалось 404", rec.Code)
|
||||
}
|
||||
var got map[string]string
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("разбор тела: %v", err)
|
||||
}
|
||||
if got["error"] != "not_found" {
|
||||
t.Errorf("код ошибки: получено %q, ожидалось \"not_found\"", got["error"])
|
||||
}
|
||||
func TestMethodOnKnownAPIPathIs404(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
expect(t, e.do(http.MethodPost, "/api/me", nil), http.StatusNotFound, "not_found")
|
||||
expect(t, e.do(http.MethodGet, "/api/nope", nil), http.StatusNotFound, "not_found")
|
||||
}
|
||||
|
||||
// Путь из запроса не должен уметь дописать строку в журнал.
|
||||
func TestLogPathEscaped(t *testing.T) {
|
||||
static, err := web.New()
|
||||
if err != nil {
|
||||
t.Fatalf("web.New: %v", err)
|
||||
}
|
||||
var log bytes.Buffer
|
||||
h := api.New(static, &log)
|
||||
e := newEnv(t)
|
||||
|
||||
target := "/x%0a2026-01-01T00:00:00Z%20GET%20/fake%20200%201ms"
|
||||
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, target, nil))
|
||||
e.do(http.MethodGet, target, nil)
|
||||
|
||||
line := log.String()
|
||||
line := e.log.String()
|
||||
if n := strings.Count(line, "\n"); n != 1 {
|
||||
t.Errorf("строк в логе: получено %d, ожидалась 1: %q", n, line)
|
||||
}
|
||||
@@ -137,19 +229,25 @@ func TestLogPathEscaped(t *testing.T) {
|
||||
t.Errorf("путь не в percent-форме: %q", line)
|
||||
}
|
||||
|
||||
log.Reset()
|
||||
long := "/" + strings.Repeat("z", 4096)
|
||||
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, long, nil))
|
||||
if len(log.String()) > 512 {
|
||||
t.Errorf("длина строки лога: получено %d байт, ожидалось не больше 512", len(log.String()))
|
||||
e.log.Reset()
|
||||
e.do(http.MethodGet, "/"+strings.Repeat("z", 4096), nil)
|
||||
if len(e.log.String()) > 512 {
|
||||
t.Errorf("длина строки лога: получено %d байт, ожидалось не больше 512", len(e.log.String()))
|
||||
}
|
||||
}
|
||||
|
||||
// SSE (docs/protocol.md, «События») флашит каждое событие: обёртка логгера
|
||||
// не должна прятать Flush от http.ResponseController.
|
||||
func TestFlushThroughMiddleware(t *testing.T) {
|
||||
st, err := store.Open(filepath.Join(t.TempDir(), "bare.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("store.Open: %v", err)
|
||||
}
|
||||
defer st.Close()
|
||||
|
||||
var flushErr error
|
||||
h := api.New(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
cfg := &config.Config{Addr: "127.0.0.1:0", DB: "bare.db", Origin: origin}
|
||||
h := api.New(cfg, st, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
flushErr = http.NewResponseController(w).Flush()
|
||||
}), io.Discard)
|
||||
|
||||
@@ -158,3 +256,17 @@ func TestFlushThroughMiddleware(t *testing.T) {
|
||||
t.Errorf("Flush: %v", flushErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInternalErrorHasCode(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
// Закрытая база — единственный простой способ получить сбой хранилища.
|
||||
e.st.Close()
|
||||
rec := e.do(http.MethodGet, "/api/kdf?nick=marta", nil)
|
||||
expect(t, rec, http.StatusInternalServerError, "internal")
|
||||
if !strings.Contains(e.log.String(), "ошибка:") {
|
||||
t.Errorf("причина не попала в журнал: %q", e.log.String())
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), "sql") {
|
||||
t.Errorf("причина уехала клиенту: %q", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
|
||||
"github.com/xmatic-squad/bare/internal/config"
|
||||
)
|
||||
|
||||
// Сервер не умеет и не пытается проверять шифротексты. Он проверяет форму:
|
||||
// base64url, длины, версии (docs/crypto.md, «Что сервер проверяет»).
|
||||
const (
|
||||
authKeyLen = 32 // байт
|
||||
ivLen = 12 // байт
|
||||
minCTLen = 16 // байт: короче тега AES-GCM шифротекста не бывает
|
||||
maxBlob = 8 << 10 // ключевой блоб, docs/protocol.md
|
||||
)
|
||||
|
||||
// b64 — кодировка бинарных полей протокола: base64url без паддинга.
|
||||
var b64 = base64.RawURLEncoding
|
||||
|
||||
// nickRe — ник по ADR-019: только строчные, без регистровых коллизий.
|
||||
var nickRe = regexp.MustCompile(`^[a-z0-9_]{2,32}$`)
|
||||
|
||||
func validNick(nick string) bool { return nickRe.MatchString(nick) }
|
||||
|
||||
// decodeExactly разбирает base64url и требует ровно n байт.
|
||||
func decodeExactly(s string, n int) ([]byte, bool) {
|
||||
raw, err := b64.DecodeString(s)
|
||||
if err != nil || len(raw) != n {
|
||||
return nil, false
|
||||
}
|
||||
return raw, true
|
||||
}
|
||||
|
||||
// authKey разбирает authKey клиента: base64url ровно 32 байта.
|
||||
func authKey(s string) ([]byte, bool) { return decodeExactly(s, authKeyLen) }
|
||||
|
||||
// jwkPublic — публичный ключ в том виде, в каком сервер его хранит
|
||||
// и отдаёт: четыре поля и ничего больше.
|
||||
type jwkPublic struct {
|
||||
Kty string `json:"kty"`
|
||||
Crv string `json:"crv"`
|
||||
X string `json:"x"`
|
||||
Y string `json:"y"`
|
||||
}
|
||||
|
||||
// publicKeyJSON проверяет JWK и отдаёт его канонический JSON.
|
||||
//
|
||||
// Поле d — приватный ключ. Его наличие означает, что клиент собирается
|
||||
// отдать серверу материал, которого у сервера не должно быть ни при каких
|
||||
// условиях, поэтому такой запрос отвергается целиком, а не чистится молча.
|
||||
// Всё, что не kty, crv, x и y, отбрасывается: хранится ровно то, что нужно.
|
||||
func publicKeyJSON(raw json.RawMessage) (string, error) {
|
||||
var in struct {
|
||||
Kty string `json:"kty"`
|
||||
Crv string `json:"crv"`
|
||||
X string `json:"x"`
|
||||
Y string `json:"y"`
|
||||
D json.RawMessage `json:"d"`
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
return "", errors.New("нет публичного ключа")
|
||||
}
|
||||
if err := json.Unmarshal(raw, &in); err != nil {
|
||||
return "", errors.New("публичный ключ — не jwk")
|
||||
}
|
||||
if in.D != nil {
|
||||
return "", errors.New("приватному ключу на сервере не место")
|
||||
}
|
||||
if in.Kty != "EC" || in.Crv != "P-256" {
|
||||
return "", errors.New("ожидается ключ ec p-256")
|
||||
}
|
||||
if _, ok := decodeExactly(in.X, 32); !ok {
|
||||
return "", errors.New("x — не 32 байта base64url")
|
||||
}
|
||||
if _, ok := decodeExactly(in.Y, 32); !ok {
|
||||
return "", errors.New("y — не 32 байта base64url")
|
||||
}
|
||||
out, err := json.Marshal(jwkPublic{Kty: in.Kty, Crv: in.Crv, X: in.X, Y: in.Y})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(out), nil
|
||||
}
|
||||
|
||||
// blobIterations проверяет форму ключевого блоба (docs/crypto.md,
|
||||
// «Ключевой блоб») и отдаёт iter. Это единственное поле блоба, которое
|
||||
// сервер читает: его же отдаёт GET /api/kdf. Всё остальное — непрозрачный
|
||||
// шифротекст.
|
||||
func blobIterations(blob string) (int, error) {
|
||||
if blob == "" {
|
||||
return 0, errors.New("нет ключевого блоба")
|
||||
}
|
||||
if len(blob) > maxBlob {
|
||||
return 0, errors.New("ключевой блоб больше 8 КиБ")
|
||||
}
|
||||
var b struct {
|
||||
V int `json:"v"`
|
||||
Iter int `json:"iter"`
|
||||
IV string `json:"iv"`
|
||||
CT string `json:"ct"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(blob), &b); err != nil {
|
||||
return 0, errors.New("ключевой блоб — не json")
|
||||
}
|
||||
if b.V != 1 {
|
||||
return 0, fmt.Errorf("версия блоба %d, ожидается 1", b.V)
|
||||
}
|
||||
if b.Iter < config.KDFMinIterations || b.Iter > config.KDFMaxIterations {
|
||||
return 0, fmt.Errorf("iter вне границ %d…%d", config.KDFMinIterations, config.KDFMaxIterations)
|
||||
}
|
||||
if _, ok := decodeExactly(b.IV, ivLen); !ok {
|
||||
return 0, errors.New("iv — не 12 байт base64url")
|
||||
}
|
||||
if ct, err := b64.DecodeString(b.CT); err != nil || len(ct) < minCTLen {
|
||||
return 0, errors.New("ct — не base64url или слишком короткий")
|
||||
}
|
||||
return b.Iter, nil
|
||||
}
|
||||
Reference in New Issue
Block a user