A merchant can create a salesperson's login and hand it over
The flow this product is sold on is three tiers: the platform admin
registers a merchant, the merchant registers their sales staff, the
staff sign in on a phone. Tier 1 handed the new owner a password. Tier 2
could not - a manager could only mint an invitation code, which the
salesperson had to redeem themselves, on their own phone, choosing their
own password. Good practice, and no use to a manager setting somebody up
before their first shift with a card and a pen.
POST /api/team/members mirrors POST /api/admin/clients: generated
password unless one is given, returned exactly once, bcrypt-hashed on
the way in and not recoverable after. Same permission shape as an
invitation - manager and above, only an owner mints an owner, admin
refused - so a manager cannot do through one door what they are refused
at the other. The invitation path stays; it is the better one whenever
the salesperson has their phone.
POST /api/team/{id}/password is the everyday case on a shop floor:
they forgot it. It sets a new one AND revokes every session they hold,
in one transaction, because the other reason a manager resets a
password is a lost phone, and a reset that left that phone signed in
would look complete while fixing nothing. Tenant-scoped in the UPDATE
itself; another company's user id is 404, never 403. No self-service
and no reset-by-email, deliberately: a floor account often has no
mailbox anyone checks, and the person who can vouch for the salesperson
standing in front of them is their manager.
RandomPassword moves from a private helper in the store to auth, so the
admin path, the merchant path and the reset all mint the same 80-bit
credential - rather than someone later writing a shorter one for the
"less important" account.
Verified: eight handler tests, and two against a real Postgres for the
things a fake cannot see - the RETURNING list scans on a row with no
last_login_at, the tenant scope holds, and the sessions row is actually
revoked. The tenant cleanup from yesterday held throughout.
API.md now documents the chain with both paths, and the note saying a
merchant could not create a login directly is gone because it is no
longer true.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
This commit is contained in:
@@ -2,10 +2,7 @@ package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base32"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/loyaly/behavision-server/internal/api"
|
||||
@@ -28,7 +25,7 @@ func (s *Store) CreateClientWithOwner(ctx context.Context, in api.NewClientInput
|
||||
if password == "" {
|
||||
// Generated rather than defaulted. An operator inventing a password for
|
||||
// somebody else invents a weak one and then sends it over chat.
|
||||
p, err := randomPassword()
|
||||
p, err := auth.RandomPassword()
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
@@ -107,11 +104,3 @@ func (s *Store) ListClients(ctx context.Context) ([]api.ClientRow, error) {
|
||||
// base32 without padding, matching the rest of this system's generated
|
||||
// secrets: it gets read down a phone line and pasted into a form, and base64's
|
||||
// + / = survive neither.
|
||||
func randomPassword() (string, error) {
|
||||
b := make([]byte, 10) // 80 bits -> 16 characters
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.ToLower(base32.StdEncoding.
|
||||
WithPadding(base32.NoPadding).EncodeToString(b)), nil
|
||||
}
|
||||
|
||||
@@ -345,3 +345,72 @@ func (s *Store) RevokeOtherSessions(ctx context.Context, userID, keepSessionID s
|
||||
}
|
||||
return int(tag.RowsAffected()), nil
|
||||
}
|
||||
|
||||
// CreateMember inserts an active account into a tenant.
|
||||
//
|
||||
// The email uniqueness constraint is global (migration 007), and a clash here
|
||||
// is an ordinary typing mistake - somebody already has that address - so it
|
||||
// surfaces as a conflict the manager can act on, not a 500.
|
||||
func (s *Store) CreateMember(ctx context.Context, clientID string,
|
||||
in api.NewMemberInput, hash string) (api.TeamMember, error) {
|
||||
|
||||
var m api.TeamMember
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
INSERT INTO app_users (client_id, email, password_hash, full_name, role)
|
||||
VALUES ($1::uuid, $2, $3, $4, $5)
|
||||
RETURNING id::text, email, full_name, role, active, '',
|
||||
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`,
|
||||
clientID, in.Email, hash, in.FullName, in.Role,
|
||||
).Scan(&m.ID, &m.Email, &m.FullName, &m.Role, &m.Active,
|
||||
&m.LastLoginAt, &m.CreatedAt)
|
||||
if err != nil {
|
||||
return api.TeamMember{}, fmt.Errorf("create member: %w", err)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// ResetMemberPassword replaces a member's password and signs them out
|
||||
// everywhere, in one transaction.
|
||||
//
|
||||
// The two go together because of why a manager resets a password at all: the
|
||||
// salesperson forgot it, or lost the phone it was saved on. In the second case
|
||||
// the old sessions are the problem, and a reset that left them valid would
|
||||
// look complete while changing nothing that mattered. Scoped to the caller's
|
||||
// tenant in the UPDATE itself, so a user id from another company matches no
|
||||
// row rather than being reset.
|
||||
func (s *Store) ResetMemberPassword(ctx context.Context, clientID, userID,
|
||||
hash string) (api.TeamMember, error) {
|
||||
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return api.TeamMember{}, err
|
||||
}
|
||||
defer tx.Rollback(ctx) //nolint:errcheck // no-op once committed
|
||||
|
||||
var m api.TeamMember
|
||||
err = tx.QueryRow(ctx, `
|
||||
UPDATE app_users SET password_hash = $3
|
||||
WHERE id = $2::uuid AND client_id = $1::uuid
|
||||
RETURNING id::text, email, full_name, role, active,
|
||||
COALESCE(to_char(last_login_at AT TIME ZONE 'UTC',
|
||||
'YYYY-MM-DD"T"HH24:MI:SS"Z"'), ''),
|
||||
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`,
|
||||
clientID, userID, hash,
|
||||
).Scan(&m.ID, &m.Email, &m.FullName, &m.Role, &m.Active,
|
||||
&m.LastLoginAt, &m.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return api.TeamMember{}, errors.New("no such team member")
|
||||
}
|
||||
if err != nil {
|
||||
return api.TeamMember{}, fmt.Errorf("reset password: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE sessions SET revoked_at = now()
|
||||
WHERE user_id = $1::uuid AND revoked_at IS NULL`, userID); err != nil {
|
||||
return api.TeamMember{}, fmt.Errorf("revoke sessions: %w", err)
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return api.TeamMember{}, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
96
server/internal/store/api_team_live_test.go
Normal file
96
server/internal/store/api_team_live_test.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/loyaly/behavision-server/internal/api"
|
||||
"github.com/loyaly/behavision-server/internal/auth"
|
||||
)
|
||||
|
||||
// The in-memory fake agrees with whatever SQL I wrote. These run the two new
|
||||
// statements against Postgres: the RETURNING list has to scan, the tenant
|
||||
// scope has to hold, and a reset has to actually revoke the sessions row.
|
||||
|
||||
func TestLiveAManagerCreatedLoginRoundTrips(t *testing.T) {
|
||||
st := liveStore(t)
|
||||
ctx := context.Background()
|
||||
clientID, _ := seedTenant(t, st, "mem"+stamp(), 0, false)
|
||||
|
||||
hash, err := auth.HashPassword("a-perfectly-good-password")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m, err := st.CreateMember(ctx, clientID, api.NewMemberInput{
|
||||
Email: "priya@" + stamp() + ".test", FullName: "Priya R", Role: "staff",
|
||||
}, hash)
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if m.ID == "" || !m.Active || m.Role != "staff" || m.CreatedAt == "" {
|
||||
t.Fatalf("member not as created: %+v", m)
|
||||
}
|
||||
// LastLoginAt is RETURNED as '' for a brand-new row; it must scan into a
|
||||
// string, not fail as an untyped literal.
|
||||
if m.LastLoginAt != "" {
|
||||
t.Fatalf("a new member has never logged in, got %q", m.LastLoginAt)
|
||||
}
|
||||
|
||||
// Findable by the login path, in the right tenant, with the hash intact.
|
||||
rec, err := st.UserByEmail(ctx, m.Email)
|
||||
if err != nil || !rec.Found {
|
||||
t.Fatalf("new member not findable: %v found=%v", err, rec.Found)
|
||||
}
|
||||
if rec.ClientID != clientID || !auth.VerifyPassword(rec.PasswordHash, "a-perfectly-good-password") {
|
||||
t.Fatalf("landed wrong: client=%s verify=%v", rec.ClientID, auth.VerifyPassword(rec.PasswordHash, "a-perfectly-good-password"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLiveAResetIsTenantScopedAndRevokesSessions(t *testing.T) {
|
||||
st := liveStore(t)
|
||||
ctx := context.Background()
|
||||
mine, _ := seedTenant(t, st, "rsa"+stamp(), 0, false)
|
||||
theirs, _ := seedTenant(t, st, "rsb"+stamp(), 0, false)
|
||||
|
||||
oldHash, _ := auth.HashPassword("old-password-here")
|
||||
m, err := st.CreateMember(ctx, mine, api.NewMemberInput{
|
||||
Email: "sam@" + stamp() + ".test", FullName: "Sam", Role: "staff"}, oldHash)
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
|
||||
// Give them a live session to lose.
|
||||
if _, err := st.pool.Exec(ctx, `
|
||||
INSERT INTO sessions (user_id, client_id, access_hash, refresh_hash,
|
||||
access_expires_at, refresh_expires_at, device)
|
||||
VALUES ($1::uuid, $2::uuid, $3, $4, now() + interval '1 hour',
|
||||
now() + interval '30 days', 'lost phone')`,
|
||||
m.ID, mine, []byte("a"+stamp()), []byte("r"+stamp())); err != nil {
|
||||
t.Fatalf("seed session: %v", err)
|
||||
}
|
||||
|
||||
// Another tenant's manager cannot reset them, and it reads as no such row.
|
||||
newHash, _ := auth.HashPassword("new-password-here")
|
||||
if _, err := st.ResetMemberPassword(ctx, theirs, m.ID, newHash); err == nil {
|
||||
t.Fatal("a reset from another tenant should find nobody")
|
||||
}
|
||||
|
||||
// Their own tenant can, and it takes the session with it.
|
||||
if _, err := st.ResetMemberPassword(ctx, mine, m.ID, newHash); err != nil {
|
||||
t.Fatalf("reset: %v", err)
|
||||
}
|
||||
var live int
|
||||
if err := st.pool.QueryRow(ctx, `
|
||||
SELECT count(*) FROM sessions WHERE user_id = $1::uuid AND revoked_at IS NULL`,
|
||||
m.ID).Scan(&live); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if live != 0 {
|
||||
t.Fatalf("%d session(s) survived a password reset", live)
|
||||
}
|
||||
rec, _ := st.UserByEmail(ctx, m.Email)
|
||||
if !auth.VerifyPassword(rec.PasswordHash, "new-password-here") ||
|
||||
auth.VerifyPassword(rec.PasswordHash, "old-password-here") {
|
||||
t.Fatal("the hash did not change to the new password")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user