feat: add auth and multi-tenancy (Schritt 2, part 1/2)
internal/auth is pure logic (bcrypt hashing, session token generation) with no DB access — persistence for account/app_user/session lives in internal/store like everything else, via migration 0003. account is the tenant (Mandant); app_user is a login inside one account; session is a real server-side row (not a signed stateless token) so logout can actually end a session rather than the client just forgetting a JWT. submission.account_id is NOT NULL — added directly rather than the nullable-then-backfill dance, since no submission rows exist anywhere yet (verified empty on the test server before writing the migration). Added as migration 0003 (new file), not folded into an earlier one, since 0001/0002 are already applied on the test server. store.ErrNotFound lets callers distinguish "wrong email" / "unknown session" from a genuine DB error — matters for login, where those two cases should both fail closed but for different reasons. Not yet wired into internal/web — that's the next commit. All of this is tested against real Postgres (14 store tests green) but isn't reachable from any HTTP handler yet. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
77
internal/store/user.go
Normal file
77
internal/store/user.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// ErrNotFound signalisiert, dass eine Abfrage keine Zeile ergeben hat —
|
||||
// abgrenzbar von echten Fehlern (z. B. Verbindungsabbruch), damit
|
||||
// Aufrufer "nicht gefunden" gezielt anders behandeln können (z. B. bei
|
||||
// Login: falsche E-Mail vs. Datenbankfehler).
|
||||
var ErrNotFound = errors.New("store: nicht gefunden")
|
||||
|
||||
// User ist ein Login innerhalb eines Account (Mandanten).
|
||||
type User struct {
|
||||
ID string
|
||||
AccountID string
|
||||
Email string
|
||||
PasswordHash string
|
||||
Role string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// CreateUser legt einen neuen Nutzer innerhalb eines Accounts an.
|
||||
// passwordHash muss bereits gehasht sein (siehe internal/auth) — store
|
||||
// speichert nur, es hasht nicht selbst.
|
||||
func (s *Store) CreateUser(ctx context.Context, accountID, email, passwordHash, role string) (User, error) {
|
||||
var u User
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
INSERT INTO app_user (account_id, email, password_hash, role)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, account_id, email, password_hash, role, created_at
|
||||
`, accountID, email, passwordHash, role).Scan(
|
||||
&u.ID, &u.AccountID, &u.Email, &u.PasswordHash, &u.Role, &u.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return User{}, fmt.Errorf("store: create user: %w", err)
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// GetUserByEmail liest einen Nutzer anhand seiner E-Mail-Adresse.
|
||||
// Liefert ErrNotFound, wenn keine E-Mail passt (kein Datenbankfehler).
|
||||
func (s *Store) GetUserByEmail(ctx context.Context, email string) (User, error) {
|
||||
var u User
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT id, account_id, email, password_hash, role, created_at
|
||||
FROM app_user WHERE email = $1
|
||||
`, email).Scan(&u.ID, &u.AccountID, &u.Email, &u.PasswordHash, &u.Role, &u.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return User{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return User{}, fmt.Errorf("store: get user by email: %w", err)
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// GetUser liest einen Nutzer anhand seiner ID.
|
||||
func (s *Store) GetUser(ctx context.Context, id string) (User, error) {
|
||||
var u User
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT id, account_id, email, password_hash, role, created_at
|
||||
FROM app_user WHERE id = $1
|
||||
`, id).Scan(&u.ID, &u.AccountID, &u.Email, &u.PasswordHash, &u.Role, &u.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return User{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return User{}, fmt.Errorf("store: get user: %w", err)
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
Reference in New Issue
Block a user