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