package app import ( "context" "errors" "fmt" "strings" "github.com/jackc/pgx/v5" "golang.org/x/crypto/bcrypt" ) // User is an authenticated account. A user always belongs to one subscriber. type User struct { ID int64 SubscriberID int64 Email string Name string } // Subscriber is a tenant. The internal subscriber (is_internal=true) sees the // admin/CMS view; every other subscriber sees the customer dashboard. type Subscriber struct { ID int64 Name string IsInternal bool } var errNoCredentials = errors.New("invalid email or password") func normEmail(email string) string { return strings.ToLower(strings.TrimSpace(email)) } // authenticate verifies an email/password pair and returns the user. func (a *App) authenticate(ctx context.Context, email, password string) (User, error) { var u User var hash string err := a.pool.QueryRow(ctx, `SELECT id, subscriber_id, email, name, password_hash FROM users WHERE email = $1`, normEmail(email), ).Scan(&u.ID, &u.SubscriberID, &u.Email, &u.Name, &hash) if errors.Is(err, pgx.ErrNoRows) { return User{}, errNoCredentials } if err != nil { return User{}, err } if hash == "" || bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) != nil { return User{}, errNoCredentials } return u, nil } func (a *App) subscriber(ctx context.Context, id int64) (Subscriber, error) { var s Subscriber err := a.pool.QueryRow(ctx, `SELECT id, name, is_internal FROM subscribers WHERE id = $1`, id, ).Scan(&s.ID, &s.Name, &s.IsInternal) return s, err } // InitAdmin ensures the internal subscriber exists and creates or updates the // first admin user under it. Used by the `spread init-admin` subcommand. func (a *App) InitAdmin(ctx context.Context, email, password string) error { if len(password) < 8 { return fmt.Errorf("password must be at least 8 characters") } hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { return err } tx, err := a.pool.Begin(ctx) if err != nil { return err } defer tx.Rollback(ctx) var subID int64 err = tx.QueryRow(ctx, `SELECT id FROM subscribers WHERE is_internal = TRUE ORDER BY id LIMIT 1`).Scan(&subID) if errors.Is(err, pgx.ErrNoRows) { if err = tx.QueryRow(ctx, `INSERT INTO subscribers (name, is_internal) VALUES ('Spread (internal)', TRUE) RETURNING id`, ).Scan(&subID); err != nil { return err } } else if err != nil { return err } _, err = tx.Exec(ctx, `INSERT INTO users (subscriber_id, email, password_hash, name) VALUES ($1, $2, $3, 'Admin') ON CONFLICT (email) DO UPDATE SET password_hash = EXCLUDED.password_hash, subscriber_id = EXCLUDED.subscriber_id`, subID, normEmail(email), string(hash), ) if err != nil { return err } return tx.Commit(ctx) } // OpenAndInitAdmin is a convenience for the init-admin subcommand. func OpenAndInitAdmin(ctx context.Context, dsn, email, password string) error { a, err := Open(ctx, Config{DSN: dsn}) if err != nil { return err } defer a.Close() return a.InitAdmin(ctx, email, password) } func (a *App) findOrCreateSubscriber(ctx context.Context, name string) (int64, error) { var id int64 err := a.pool.QueryRow(ctx, `SELECT id FROM subscribers WHERE name = $1 AND is_internal = FALSE ORDER BY id LIMIT 1`, name, ).Scan(&id) if errors.Is(err, pgx.ErrNoRows) { return a.createSubscriber(ctx, name) } return id, err } // OpenAndCreateCustomer creates a customer user, making the named subscriber if // it doesn't exist yet. Convenience for the create-customer subcommand. func OpenAndCreateCustomer(ctx context.Context, dsn, email, subscriberName, password string) error { a, err := Open(ctx, Config{DSN: dsn}) if err != nil { return err } defer a.Close() subID, err := a.findOrCreateSubscriber(ctx, subscriberName) if err != nil { return err } _, err = a.createUser(ctx, subID, email, "", password) return err }