Netpulse_SasS/server/cmd/netpulse-user/main.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

217 lines
6.5 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.

// Команда netpulse-user — заведення першого власника й керування
// користувачами з командного рядка.
//
// Потрібна через «проблему курки і яйця»: щоб зайти в панель, треба
// бути користувачем, а щоб створити користувача через API — треба
// зайти. Цей інструмент розриває коло, і він же лишається способом
// відновити доступ, коли єдиний адміністратор загубив пароль.
package main
import (
"bufio"
"context"
"errors"
"flag"
"fmt"
"os"
"strings"
"syscall"
"github.com/jackc/pgx/v5"
"github.com/netpulse/netpulse/server/internal/auth"
"golang.org/x/term"
)
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, "netpulse-user:", err)
os.Exit(1)
}
}
func run() error {
var (
dsn = flag.String("dsn", os.Getenv("NETPULSE_DSN"), "DSN PostgreSQL")
tenant = flag.String("tenant", "", "slug або uuid тенанта")
email = flag.String("email", "", "email користувача")
fullName = flag.String("name", "", "повне ім'я")
role = flag.String("role", "owner", "owner|admin|engineer|operator|viewer")
password = flag.String("password", "", "пароль; порожній — спитати інтерактивно")
list = flag.Bool("list", false, "показати користувачів тенанта")
reset = flag.Bool("reset-password", false, "змінити пароль наявного користувача")
)
flag.Parse()
if *dsn == "" {
return errors.New("не вказано -dsn (або NETPULSE_DSN)")
}
if *tenant == "" {
return errors.New("не вказано -tenant")
}
ctx := context.Background()
conn, err := pgx.Connect(ctx, *dsn)
if err != nil {
return fmt.Errorf("підключення: %w", err)
}
defer conn.Close(ctx)
tenantID, tenantName, err := resolveTenant(ctx, conn, *tenant)
if err != nil {
return err
}
if *list {
return listUsers(ctx, conn, tenantID, tenantName)
}
if *email == "" {
return errors.New("не вказано -email")
}
pw := *password
if pw == "" {
pw, err = askPassword()
if err != nil {
return err
}
}
if len(pw) < 8 {
return errors.New("пароль коротший за 8 символів")
}
hash, err := auth.HashPassword(pw)
if err != nil {
return err
}
if *reset {
tag, err := conn.Exec(ctx, `
UPDATE core.users SET password_hash = $2, updated_at = now()
WHERE email = $1 AND deleted_at IS NULL
`, *email, hash)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("користувача %s не знайдено", *email)
}
// Зміна пароля адміністратором — це реакція на втрату доступу
// або на компрометацію. В обох випадках старі сесії мають померти.
if _, err := conn.Exec(ctx, `
UPDATE core.sessions SET revoked_at = now()
WHERE user_id = (SELECT id FROM core.users WHERE email = $1)
AND revoked_at IS NULL
`, *email); err != nil {
return err
}
fmt.Printf("пароль змінено, усі сесії %s відкликано\n", *email)
return nil
}
tx, err := conn.Begin(ctx)
if err != nil {
return err
}
defer func() { _ = tx.Rollback(ctx) }()
var userID string
if 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 password_hash = EXCLUDED.password_hash,
full_name = COALESCE(NULLIF(EXCLUDED.full_name,''), core.users.full_name),
updated_at = now()
RETURNING id::text
`, *email, hash, *fullName).Scan(&userID); 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
`, *role, tenantID).Scan(&roleID); err != nil {
return fmt.Errorf("роль %q: %w", *role, 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, userID, roleID); err != nil {
return fmt.Errorf("членство: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return err
}
fmt.Printf("користувач %s (%s) доданий до «%s» з роллю %s\n",
*email, userID, tenantName, *role)
return nil
}
func resolveTenant(ctx context.Context, conn *pgx.Conn, ref string) (id, name string, err error) {
err = conn.QueryRow(ctx, `
SELECT id::text, name FROM core.tenants
WHERE (slug::text = $1 OR id::text = $1) AND deleted_at IS NULL
`, ref).Scan(&id, &name)
if errors.Is(err, pgx.ErrNoRows) {
return "", "", fmt.Errorf("тенанта %q не знайдено", ref)
}
return id, name, err
}
func listUsers(ctx context.Context, conn *pgx.Conn, tenantID, tenantName string) error {
rows, err := conn.Query(ctx, `
SELECT u.email::text, COALESCE(u.full_name,''), r.key::text,
COALESCE(u.last_login_at::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()
fmt.Printf("Користувачі «%s»:\n", tenantName)
n := 0
for rows.Next() {
var email, name, role, last string
if err := rows.Scan(&email, &name, &role, &last); err != nil {
return err
}
fmt.Printf(" %-30s %-12s %-20s вхід: %s\n", email, role, name, last)
n++
}
if n == 0 {
fmt.Println(" (порожньо)")
}
return rows.Err()
}
// askPassword читає пароль без відлуння в терміналі.
func askPassword() (string, error) {
fmt.Print("Пароль: ")
if term.IsTerminal(int(syscall.Stdin)) {
b, err := term.ReadPassword(int(syscall.Stdin))
fmt.Println()
if err != nil {
return "", err
}
return string(b), nil
}
// Не термінал (скрипт, конвеєр) — читаємо рядок як є.
line, err := bufio.NewReader(os.Stdin).ReadString('\n')
if err != nil && line == "" {
return "", err
}
return strings.TrimRight(line, "\r\n"), nil
}