Досі доступ давав машинний токен зі змінної збірки — одні права на всіх і жодного способу відрізнити, хто що зробив. Тепер продукт уміє впустити людину. Сервер: - 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>
217 lines
6.5 KiB
Go
217 lines
6.5 KiB
Go
// Команда 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
|
||
}
|