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>
63 lines
2.1 KiB
Go
63 lines
2.1 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
)
|
|
|
|
// Session ist eine angemeldete Sitzung. token ist der Primärschlüssel
|
|
// (das Cookie-Geheimnis selbst) — es gibt bewusst keine separate ID,
|
|
// eine Session wird immer über ihren Token nachgeschlagen.
|
|
type Session struct {
|
|
Token string
|
|
UserID string
|
|
ExpiresAt time.Time
|
|
CreatedAt time.Time
|
|
}
|
|
|
|
// CreateSession speichert eine neue Sitzung. token muss bereits ein
|
|
// kryptographisch zufälliges Geheimnis sein (siehe internal/auth).
|
|
func (s *Store) CreateSession(ctx context.Context, token, userID string, expiresAt time.Time) (Session, error) {
|
|
var sess Session
|
|
err := s.Pool.QueryRow(ctx, `
|
|
INSERT INTO session (token, user_id, expires_at)
|
|
VALUES ($1, $2, $3)
|
|
RETURNING token, user_id, expires_at, created_at
|
|
`, token, userID, expiresAt).Scan(&sess.Token, &sess.UserID, &sess.ExpiresAt, &sess.CreatedAt)
|
|
if err != nil {
|
|
return Session{}, fmt.Errorf("store: create session: %w", err)
|
|
}
|
|
return sess, nil
|
|
}
|
|
|
|
// GetSession liest eine Sitzung anhand ihres Tokens. Liefert
|
|
// ErrNotFound, wenn der Token unbekannt ist — abgelaufene Sitzungen
|
|
// werden NICHT automatisch als "nicht gefunden" behandelt, das prüft
|
|
// der Aufrufer über ExpiresAt (siehe internal/auth), damit die
|
|
// Unterscheidung "gab es nie" vs. "ist abgelaufen" nicht verloren geht.
|
|
func (s *Store) GetSession(ctx context.Context, token string) (Session, error) {
|
|
var sess Session
|
|
err := s.Pool.QueryRow(ctx, `
|
|
SELECT token, user_id, expires_at, created_at FROM session WHERE token = $1
|
|
`, token).Scan(&sess.Token, &sess.UserID, &sess.ExpiresAt, &sess.CreatedAt)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return Session{}, ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return Session{}, fmt.Errorf("store: get session: %w", err)
|
|
}
|
|
return sess, nil
|
|
}
|
|
|
|
// DeleteSession beendet eine Sitzung (Logout).
|
|
func (s *Store) DeleteSession(ctx context.Context, token string) error {
|
|
if _, err := s.Pool.Exec(ctx, `DELETE FROM session WHERE token = $1`, token); err != nil {
|
|
return fmt.Errorf("store: delete session: %w", err)
|
|
}
|
|
return nil
|
|
}
|