Ветка отведена от 522ba89, где последним был ADR-063, и заняла
064–072. Тем временем в main через #3 и #4 пришли свои 064
(строка ввода — div вместо form), 065 (стороны сообщений)
и 066 (серверного перца нет).
Переезжают три ADR этой ветки, 067–072 остаются на месте:
их номера свободны, а внутренние ссылки менять незачем.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QR8uS3zGkybzWRW4GEY1oz
390 lines
13 KiB
Go
390 lines
13 KiB
Go
package api
|
||
|
||
import (
|
||
"crypto/subtle"
|
||
"encoding/json"
|
||
"errors"
|
||
"net/http"
|
||
"time"
|
||
|
||
"github.com/xmatic-squad/bare/internal/auth"
|
||
"github.com/xmatic-squad/bare/internal/build"
|
||
"github.com/xmatic-squad/bare/internal/config"
|
||
"github.com/xmatic-squad/bare/internal/store"
|
||
)
|
||
|
||
// GET /api/config — то, что клиенту нужно знать до входа.
|
||
//
|
||
// version и commitAt отвечают на вопрос «какой код сейчас работает»:
|
||
// короткая ревизия сборки и время коммита, а не момент компиляции
|
||
// (ADR-074). Отдельного эндпоинта им не заводится — конфигурацию клиент
|
||
// читает до входа и так.
|
||
func (s *server) config(w http.ResponseWriter, r *http.Request) {
|
||
info := build.Current()
|
||
writeJSON(w, http.StatusOK, struct {
|
||
InviteRequired bool `json:"inviteRequired"`
|
||
VAPIDPublicKey string `json:"vapidPublicKey"`
|
||
KDFIterations int `json:"kdfIterations"`
|
||
MaxMessageChars int `json:"maxMessageChars"`
|
||
Version string `json:"version"`
|
||
CommitAt int64 `json:"commitAt"`
|
||
}{
|
||
InviteRequired: s.cfg.InviteCode != "",
|
||
VAPIDPublicKey: s.cfg.VAPIDPublic,
|
||
KDFIterations: config.KDFIterations,
|
||
MaxMessageChars: config.MaxMessageChars,
|
||
Version: info.Version(),
|
||
CommitAt: info.CommitMilli(),
|
||
})
|
||
}
|
||
|
||
// GET /api/kdf?nick= — сколько итераций PBKDF2 брать для этого ника.
|
||
//
|
||
// Значение лежит открытым полем iter в ключевом блобе: другого места
|
||
// у него нет (docs/crypto.md). Неизвестный ник получает целевое значение
|
||
// тем же статусом 200. Скрытием существования ника этот ответ
|
||
// не занимается: после повышения цели у аккаунта, который с тех пор
|
||
// не входил, iter свой, и по числу его видно (ADR-062). Существование
|
||
// ника публично и так (ADR-019).
|
||
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
|
||
}
|
||
// Форма — раньше инвайт-кода: он даёт право регистрироваться, а права
|
||
// идут после формы (ADR-043). Занятость ника этим не выдаётся: nick_taken
|
||
// живёт дальше по тексту, за инвайтом.
|
||
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
|
||
}
|
||
// Лимит — 5 в час на IP (ADR-021) — стоит раньше проверки инвайт-кода:
|
||
// иначе код подбирался бы запросами без счёта.
|
||
if wait, ok := s.regs.take(clientIP(r), time.Now()); !ok {
|
||
s.rateLimited(w, wait)
|
||
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
|
||
}
|
||
}
|
||
|
||
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
|
||
}
|
||
// Лимит — 10 за 10 минут на пару IP+ник (ADR-021) — стоит раньше
|
||
// хранилища и argon2: перебор не должен заказывать серверу работу.
|
||
// Ключ ведра собирается из адреса и ника через байт, которого нет
|
||
// ни в том ни в другом.
|
||
if wait, ok := s.logins.take(clientIP(r)+"\x00"+in.Nick, time.Now()); !ok {
|
||
s.rateLimited(w, wait)
|
||
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)
|
||
revoked, err := s.st.SetPassword(r.Context(), u.Nick, cred, in.Blob, in.LogoutOthers, sess.TokenHash)
|
||
if err != nil {
|
||
s.internal(w, r, err)
|
||
return
|
||
}
|
||
// Сессия проверяется при подключении к потоку, а не в его цикле,
|
||
// поэтому отозванная продолжала бы получать конверты до обрыва
|
||
// соединения. Отзыв доступа закрывает поток сам — тем же способом,
|
||
// что и удаление устройства (ADR-058).
|
||
for _, device := range revoked {
|
||
s.hub.Close(device)
|
||
}
|
||
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) — всё в одной транзакции хранилища.
|
||
changes, err := s.st.DeleteUser(r.Context(), u.Nick)
|
||
if err != nil {
|
||
s.internal(w, r, err)
|
||
return
|
||
}
|
||
// Удаление аккаунта — выход из всех его комнат: оставшимся уходит room
|
||
// с needsRekey, каждому со своим ключом (ADR-041).
|
||
for _, change := range changes {
|
||
s.sendRoom(r, change, "")
|
||
}
|
||
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", "такого ника нет")
|
||
}
|