Netpulse_SasS/server/internal/store/users.go
byrsapty a4faf31fcd Етап 5: користувачі, вхід і права
Досі доступ давав машинний токен зі змінної збірки — одні права на всіх
і жодного способу відрізнити, хто що зробив. Тепер продукт уміє впустити
людину.

Сервер:
- argon2id для паролів, власний HS256 JWT (15 хв) + refresh-сесія в
  httpOnly-кукі на 30 днів з ротацією при кожному обміні
- Principal зводить людину й машинний токен до одного набору прав;
  права читаються з БД на кожному запиті, а не з claims, щоб відкликана
  роль не жила до кінця TTL
- 9 ендпоїнтів: auth/login|refresh|logout|password, me, team CRUD, roles
- netpulse-user — CLI для першого власника: публічна реєстрація в B2B
  це дірка, а «перший через веб, поки нікого немає» — нечесна гонка
- міграція 0012: три RLS-політики винятку для шляху входу (без них
  вхід неможливий за побудовою — щоб знайти користувача за email,
  треба знати тенант, який відомий лише після пошуку) і login_attempts
  для тротлінгу

Фронтенд:
- сторінка входу з вибором організації, access-токен у замиканні
  модуля замість localStorage, тихе відновлення сесії по кукі
- один refresh на всі паралельні запити: інакше ротація зробила б усі,
  крім першого, недійсними й викинула б людину на вхід
- дії без права не показуються; полотно нередаговане для глядача
- мобільний адаптив: висувна бічна панель, інспектор нижнім аркушем

11 нових тестів (37 у httpapi), go vet і tsc чисто, живий прогін з
8 кроків проти netpulse_it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 10:24:58 +03:00

534 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package store
import (
"context"
"crypto/sha256"
"errors"
"fmt"
"net"
"time"
"github.com/jackc/pgx/v5"
"github.com/netpulse/netpulse/server/internal/auth"
)
// RefreshTTL — скільки живе сесія без повторного входу.
//
// 30 днів — компроміс між «щодня вводити пароль» і «звільнений
// співробітник місяць має доступ». Друге лікується відкликанням сесій
// при видаленні членства, а не коротшим строком.
const RefreshTTL = 30 * 24 * time.Hour
var (
ErrBadCredentials = errors.New("невірний email або пароль")
ErrNoMembership = errors.New("користувач не належить жодному тенанту")
ErrSessionInvalid = errors.New("сесія недійсна")
)
type User struct {
ID string `json:"id"`
Email string `json:"email"`
FullName string `json:"full_name,omitempty"`
Locale string `json:"locale"`
Timezone string `json:"timezone"`
MFA bool `json:"mfa_enabled"`
}
// TenantMembership — до якого тенанта і з якою роллю належить користувач.
type TenantMembership struct {
TenantID string `json:"tenant_id"`
TenantName string `json:"tenant_name"`
TenantSlug string `json:"tenant_slug"`
RoleKey string `json:"role_key"`
RoleName string `json:"role_name"`
}
// TeamMember — рядок сторінки «Команда».
type TeamMember struct {
UserID string `json:"user_id"`
Email string `json:"email"`
FullName string `json:"full_name,omitempty"`
RoleID string `json:"role_id"`
RoleKey string `json:"role_key"`
RoleName string `json:"role_name"`
LastLogin *time.Time `json:"last_login_at,omitempty"`
AcceptedAt *time.Time `json:"accepted_at,omitempty"`
ScopeGroups []string `json:"scope_group_ids,omitempty"`
}
type Role struct {
ID string `json:"id"`
Key string `json:"key"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
IsSystem bool `json:"is_system"`
Permissions []string `json:"permissions"`
}
// ---------------------------------------------------------------------
// Вхід
// ---------------------------------------------------------------------
// AuthenticateUser перевіряє email і пароль.
//
// Запити йдуть БЕЗ tenant-контексту: у момент входу тенант ще невідомий,
// його називає саме членство. Політики з міграції 0012 дозволяють це
// рівно тоді, коли app.tenant_id порожній.
func (s *Store) AuthenticateUser(ctx context.Context, email, password string, ip net.IP, userAgent string) (*User, []TenantMembership, error) {
var (
u User
hash *string
)
err := s.pool.QueryRow(ctx, `
SELECT id::text, email::text, COALESCE(full_name,''), locale, timezone,
mfa_enabled, password_hash
FROM core.users
WHERE email = $1 AND deleted_at IS NULL
`, email).Scan(&u.ID, &u.Email, &u.FullName, &u.Locale, &u.Timezone, &u.MFA, &hash)
if errors.Is(err, pgx.ErrNoRows) {
// Спалюємо стільки ж часу, скільки справжня перевірка: інакше
// швидка відповідь видає, що такого email немає, і перебір
// перетворюється на збір списку зареєстрованих адрес.
auth.DummyVerify(password)
s.recordLogin(ctx, email, ip, userAgent, false, "no_user", "")
return nil, nil, ErrBadCredentials
}
if err != nil {
return nil, nil, err
}
if hash == nil || *hash == "" {
// Користувач лише через SSO — локального пароля немає.
auth.DummyVerify(password)
s.recordLogin(ctx, email, ip, userAgent, false, "no_password", u.ID)
return nil, nil, ErrBadCredentials
}
if err := auth.VerifyPassword(*hash, password); err != nil {
s.recordLogin(ctx, email, ip, userAgent, false, "bad_password", u.ID)
return nil, nil, ErrBadCredentials
}
memberships, err := s.userMemberships(ctx, u.ID)
if err != nil {
return nil, nil, err
}
if len(memberships) == 0 {
s.recordLogin(ctx, email, ip, userAgent, false, "no_membership", u.ID)
return nil, nil, ErrNoMembership
}
s.recordLogin(ctx, email, ip, userAgent, true, "", u.ID)
if _, err := s.pool.Exec(ctx,
`UPDATE core.users SET last_login_at = now() WHERE id = $1`, u.ID); err != nil {
return nil, nil, err
}
return &u, memberships, nil
}
func (s *Store) userMemberships(ctx context.Context, userID string) ([]TenantMembership, error) {
rows, err := s.pool.Query(ctx, `
SELECT t.id::text, t.name, t.slug::text, r.key::text, r.name
FROM core.memberships m
JOIN core.tenants t ON t.id = m.tenant_id
JOIN core.roles r ON r.id = m.role_id
WHERE m.user_id = $1 AND t.deleted_at IS NULL
ORDER BY t.name
`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []TenantMembership
for rows.Next() {
var m TenantMembership
if err := rows.Scan(&m.TenantID, &m.TenantName, &m.TenantSlug,
&m.RoleKey, &m.RoleName); err != nil {
return nil, err
}
out = append(out, m)
}
return out, rows.Err()
}
// recordLogin пише спробу входу. Помилки навмисно глушаться: збій
// журналювання не має заважати людині увійти.
func (s *Store) recordLogin(ctx context.Context, email string, ip net.IP, ua string, ok bool, reason, userID string) {
var ipArg any
if ip != nil {
ipArg = ip.String()
}
_, _ = s.pool.Exec(ctx, `
INSERT INTO core.login_attempts (email, ip, user_agent, success, reason, user_id)
VALUES ($1, $2::inet, $3, $4, NULLIF($5,''), $6)
`, email, ipArg, nullString(ua), ok, reason, nullUUID(userID))
}
// FailedLoginsSince рахує невдалі спроби — основа для гальмування
// перебору на рівні застосунку.
func (s *Store) FailedLoginsSince(ctx context.Context, email string, since time.Duration) (int, error) {
var n int
err := s.pool.QueryRow(ctx, `
SELECT count(*) FROM core.login_attempts
WHERE email = $1 AND NOT success AND ts > now() - $2::interval
`, email, since.String()).Scan(&n)
return n, err
}
// ---------------------------------------------------------------------
// Сесії
// ---------------------------------------------------------------------
type Session struct {
ID string
UserID string
TenantID string
RefreshToken string
ExpiresAt time.Time
}
// CreateSession видає refresh-токен. У БД лягає лише його sha256.
func (s *Store) CreateSession(ctx context.Context, userID, tenantID string, ip net.IP, ua string) (*Session, error) {
token, err := auth.NewToken("npr_")
if err != nil {
return nil, err
}
sum := sha256.Sum256([]byte(token))
expires := time.Now().Add(RefreshTTL)
var ipArg any
if ip != nil {
ipArg = ip.String()
}
sess := &Session{UserID: userID, TenantID: tenantID, RefreshToken: token, ExpiresAt: expires}
err = s.pool.QueryRow(ctx, `
INSERT INTO core.sessions (user_id, tenant_id, token_hash, user_agent, ip, expires_at)
VALUES ($1, $2, $3, $4, $5::inet, $6)
RETURNING id::text
`, userID, tenantID, sum[:], nullString(ua), ipArg, expires).Scan(&sess.ID)
if err != nil {
return nil, err
}
return sess, nil
}
// RotateSession обмінює refresh-токен на новий.
//
// Ротація при кожному обміні, а не подовження строку: викрадений
// refresh-токен спрацює щонайбільше один раз, і законний власник
// одразу отримає відмову — це видимий сигнал компрометації.
func (s *Store) RotateSession(ctx context.Context, refreshToken string, ip net.IP, ua string) (*Session, *User, error) {
sum := sha256.Sum256([]byte(refreshToken))
var (
oldID, userID, tenantID string
expires time.Time
revoked *time.Time
)
err := s.pool.QueryRow(ctx, `
SELECT id::text, user_id::text, COALESCE(tenant_id::text,''), expires_at, revoked_at
FROM core.sessions WHERE token_hash = $1
`, sum[:]).Scan(&oldID, &userID, &tenantID, &expires, &revoked)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil, ErrSessionInvalid
}
if err != nil {
return nil, nil, err
}
if revoked != nil || time.Now().After(expires) {
return nil, nil, ErrSessionInvalid
}
var u User
if err := s.pool.QueryRow(ctx, `
SELECT id::text, email::text, COALESCE(full_name,''), locale, timezone, mfa_enabled
FROM core.users WHERE id = $1 AND deleted_at IS NULL
`, userID).Scan(&u.ID, &u.Email, &u.FullName, &u.Locale, &u.Timezone, &u.MFA); err != nil {
return nil, nil, ErrSessionInvalid
}
// Членство могли відкликати вже після входу — перевіряємо щоразу,
// інакше звільнений співробітник продовжував би подовжувати сесію.
var stillMember bool
if err := s.pool.QueryRow(ctx, `
SELECT EXISTS (SELECT 1 FROM core.memberships WHERE user_id = $1 AND tenant_id = $2)
`, userID, tenantID).Scan(&stillMember); err != nil {
return nil, nil, err
}
if !stillMember {
return nil, nil, ErrSessionInvalid
}
if _, err := s.pool.Exec(ctx,
`UPDATE core.sessions SET revoked_at = now() WHERE id = $1`, oldID); err != nil {
return nil, nil, err
}
sess, err := s.CreateSession(ctx, userID, tenantID, ip, ua)
if err != nil {
return nil, nil, err
}
return sess, &u, nil
}
func (s *Store) RevokeSession(ctx context.Context, refreshToken string) error {
sum := sha256.Sum256([]byte(refreshToken))
_, err := s.pool.Exec(ctx, `
UPDATE core.sessions SET revoked_at = now()
WHERE token_hash = $1 AND revoked_at IS NULL
`, sum[:])
return err
}
// RevokeAllSessions — вихід з усіх пристроїв; те саме робиться при зміні
// пароля й при видаленні членства.
func (s *Store) RevokeAllSessions(ctx context.Context, userID string) error {
_, err := s.pool.Exec(ctx, `
UPDATE core.sessions SET revoked_at = now()
WHERE user_id = $1 AND revoked_at IS NULL
`, userID)
return err
}
// ---------------------------------------------------------------------
// Права
// ---------------------------------------------------------------------
// UserPermissions збирає ефективні права людини в тенанті.
//
// Джерело — membership → role → role_permissions. Кешувати тут нічого не
// треба: це один індексний join, а кеш прав — класичне джерело «звільнив,
// а він ще годину має доступ».
func (s *Store) UserPermissions(ctx context.Context, userID, tenantID string) ([]string, error) {
rows, err := s.pool.Query(ctx, `
SELECT rp.permission_key
FROM core.memberships m
JOIN core.role_permissions rp ON rp.role_id = m.role_id
WHERE m.user_id = $1 AND m.tenant_id = $2
`, userID, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var p string
if err := rows.Scan(&p); err != nil {
return nil, err
}
out = append(out, p)
}
return out, rows.Err()
}
// ScopeGroups — обмеження видимості пристроїв для користувача.
// nil означає «усі пристрої тенанта».
func (s *Store) ScopeGroups(ctx context.Context, userID, tenantID string) ([]string, error) {
var groups []string
err := s.pool.QueryRow(ctx, `
SELECT scope_group_ids::text[]
FROM core.memberships WHERE user_id = $1 AND tenant_id = $2
`, userID, tenantID).Scan(&groups)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNoMembership
}
return groups, err
}
// ---------------------------------------------------------------------
// Команда
// ---------------------------------------------------------------------
func (s *Store) ListTeam(ctx context.Context, tenantID string) ([]TeamMember, error) {
var out []TeamMember
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
rows, err := tx.Query(ctx, `
SELECT u.id::text, u.email::text, COALESCE(u.full_name,''),
r.id::text, r.key::text, r.name,
u.last_login_at, m.accepted_at, m.scope_group_ids::text[]
FROM core.memberships m
JOIN core.users u ON u.id = m.user_id
JOIN core.roles r ON r.id = m.role_id
WHERE m.tenant_id = $1 AND u.deleted_at IS NULL
ORDER BY u.email
`, tenantID)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var t TeamMember
if err := rows.Scan(&t.UserID, &t.Email, &t.FullName,
&t.RoleID, &t.RoleKey, &t.RoleName,
&t.LastLogin, &t.AcceptedAt, &t.ScopeGroups); err != nil {
return err
}
out = append(out, t)
}
return rows.Err()
})
return out, err
}
func (s *Store) ListRoles(ctx context.Context, tenantID string) ([]Role, error) {
var out []Role
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
rows, err := tx.Query(ctx, `
SELECT r.id::text, r.key::text, r.name, COALESCE(r.description,''), r.is_system,
COALESCE(array_agg(rp.permission_key ORDER BY rp.permission_key)
FILTER (WHERE rp.permission_key IS NOT NULL), '{}')
FROM core.roles r
LEFT JOIN core.role_permissions rp ON rp.role_id = r.id
WHERE r.tenant_id IS NULL OR r.tenant_id = $1
GROUP BY r.id
ORDER BY r.is_system DESC, r.name
`, tenantID)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var r Role
if err := rows.Scan(&r.ID, &r.Key, &r.Name, &r.Description,
&r.IsSystem, &r.Permissions); err != nil {
return err
}
out = append(out, r)
}
return rows.Err()
})
return out, err
}
// CreateUser заводить користувача й одразу дає йому членство.
//
// Використовується і CLI-початківцем (перший власник), і запрошеннями.
func (s *Store) CreateUser(ctx context.Context, tenantID, email, password, fullName, roleKey string) (*User, error) {
hash, err := auth.HashPassword(password)
if err != nil {
return nil, err
}
var u User
err = s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
// Користувач глобальний: одна людина може працювати в кількох
// тенантах (типово для MSP-провайдера).
err := tx.QueryRow(ctx, `
INSERT INTO core.users (email, password_hash, full_name, email_verified_at)
VALUES ($1, $2, NULLIF($3,''), now())
ON CONFLICT (email) DO UPDATE
SET full_name = COALESCE(NULLIF(EXCLUDED.full_name,''), core.users.full_name)
RETURNING id::text, email::text, COALESCE(full_name,''), locale, timezone, mfa_enabled
`, email, hash, fullName).Scan(&u.ID, &u.Email, &u.FullName, &u.Locale, &u.Timezone, &u.MFA)
if err != nil {
return fmt.Errorf("створення користувача: %w", err)
}
var roleID string
if err := tx.QueryRow(ctx, `
SELECT id::text FROM core.roles
WHERE key = $1 AND (tenant_id IS NULL OR tenant_id = $2)
ORDER BY tenant_id NULLS LAST
LIMIT 1
`, roleKey, tenantID).Scan(&roleID); err != nil {
return fmt.Errorf("роль %q: %w", roleKey, err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO core.memberships (tenant_id, user_id, role_id, accepted_at)
VALUES ($1, $2, $3, now())
ON CONFLICT (tenant_id, user_id) DO UPDATE SET role_id = EXCLUDED.role_id
`, tenantID, u.ID, roleID); err != nil {
return fmt.Errorf("членство: %w", err)
}
return nil
})
if err != nil {
return nil, mapPgError(err)
}
return &u, nil
}
// SetRole змінює роль учасника команди.
func (s *Store) SetRole(ctx context.Context, tenantID, userID, roleID string) error {
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
tag, err := tx.Exec(ctx, `
UPDATE core.memberships SET role_id = $3
WHERE tenant_id = $1 AND user_id = $2
`, tenantID, userID, roleID)
if err != nil {
return mapPgError(err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
})
}
// RemoveMember прибирає людину з тенанта.
//
// Сам користувач лишається: він може працювати в іншому тенанті, а його
// сліди в аудиті мають лишатися читабельними. Зате всі сесії саме в
// цьому тенанті відкликаються негайно.
func (s *Store) RemoveMember(ctx context.Context, tenantID, userID string) error {
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
tag, err := tx.Exec(ctx, `
DELETE FROM core.memberships WHERE tenant_id = $1 AND user_id = $2
`, tenantID, userID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
_, err = tx.Exec(ctx, `
UPDATE core.sessions SET revoked_at = now()
WHERE user_id = $1 AND tenant_id = $2 AND revoked_at IS NULL
`, userID, tenantID)
return err
})
}
// ChangePassword міняє пароль і відкликає всі інші сесії.
func (s *Store) ChangePassword(ctx context.Context, userID, oldPassword, newPassword string) error {
var hash *string
if err := s.pool.QueryRow(ctx,
`SELECT password_hash FROM core.users WHERE id = $1 AND deleted_at IS NULL`,
userID).Scan(&hash); err != nil {
return ErrBadCredentials
}
if hash == nil || auth.VerifyPassword(*hash, oldPassword) != nil {
return ErrBadCredentials
}
newHash, err := auth.HashPassword(newPassword)
if err != nil {
return err
}
if _, err := s.pool.Exec(ctx,
`UPDATE core.users SET password_hash = $2, updated_at = now() WHERE id = $1`,
userID, newHash); err != nil {
return err
}
// Зміна пароля — типова реакція на «здається, мене зламали».
// Якщо після неї чужа сесія лишається живою, дія безглузда.
return s.RevokeAllSessions(ctx, userID)
}