Этап 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,58 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Session — строка sessions. Токена здесь нет: в базе лежит только
|
||||
// SHA-256 от него (ADR-021).
|
||||
type Session struct {
|
||||
TokenHash []byte
|
||||
Nick string
|
||||
DeviceID string // пусто, пока сессия не привязана к устройству
|
||||
CreatedAt int64
|
||||
ExpiresAt int64
|
||||
}
|
||||
|
||||
// CreateSession записывает сессию. tokenHash — SHA-256 токена из cookie.
|
||||
func (s *Store) CreateSession(ctx context.Context, tokenHash []byte, nick string, createdAt, expiresAt int64) error {
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO sessions (token_hash, nick, device_id, created_at, expires_at)
|
||||
VALUES (?, ?, NULL, ?, ?)`, tokenHash, nick, createdAt, expiresAt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: создание сессии: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Session читает живую сессию по хешу токена. Истёкшая считается
|
||||
// отсутствующей: чистит её фоновая задача, а не запрос.
|
||||
func (s *Store) Session(ctx context.Context, tokenHash []byte, now int64) (Session, error) {
|
||||
var (
|
||||
sess Session
|
||||
device sql.NullString
|
||||
)
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
SELECT token_hash, nick, device_id, created_at, expires_at
|
||||
FROM sessions WHERE token_hash = ? AND expires_at > ?`, tokenHash, now).
|
||||
Scan(&sess.TokenHash, &sess.Nick, &device, &sess.CreatedAt, &sess.ExpiresAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Session{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Session{}, fmt.Errorf("store: чтение сессии: %w", err)
|
||||
}
|
||||
sess.DeviceID = device.String
|
||||
return sess, nil
|
||||
}
|
||||
|
||||
// DeleteSession удаляет одну сессию — выход на этом устройстве.
|
||||
func (s *Store) DeleteSession(ctx context.Context, tokenHash []byte) error {
|
||||
if _, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE token_hash = ?`, tokenHash); err != nil {
|
||||
return fmt.Errorf("store: удаление сессии: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user