package identity import ( "context" "crypto/rand" "crypto/sha256" "encoding/base64" "errors" "fmt" "time" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) var ErrUnauthenticated = errors.New("identity: unauthenticated") type Session struct { UserID int64 MerchantID int64 Username string MerchantSourceKey string MerchantDisplayName string ProfileIsSynthetic bool CreatedAt time.Time IdleExpiresAt time.Time AbsoluteExpiresAt time.Time CSRFToken string } type Service struct { pool *pgxpool.Pool now func() time.Time } func New(pool *pgxpool.Pool) *Service { return &Service{pool: pool, now: time.Now} } func (s *Service) Login(ctx context.Context, username, password string) (Session, string, error) { var userID, merchantID int64 var passwordHash, sourceKey, displayName string var isActive, isSynthetic bool err := s.pool.QueryRow(ctx, `SELECT u.id,u.merchant_id,u.password_hash,u.is_active,m.source_key,m.display_name,m.profile_is_synthetic FROM merchant_users u JOIN merchants m ON m.id=u.merchant_id WHERE u.username=$1`, username).Scan( &userID, &merchantID, &passwordHash, &isActive, &sourceKey, &displayName, &isSynthetic, ) if err != nil || !isActive || !VerifyPassword(password, passwordHash) { // Perform an Argon2 calculation for missing users to reduce enumeration timing. if errors.Is(err, pgx.ErrNoRows) { _, _ = HashPassword("invalid-credential-padding") } return Session{}, "", ErrUnauthenticated } token, err := randomToken(32) if err != nil { return Session{}, "", err } csrf, err := randomToken(32) if err != nil { return Session{}, "", err } now := s.now().UTC() session := Session{UserID: userID, MerchantID: merchantID, Username: username, MerchantSourceKey: sourceKey, MerchantDisplayName: displayName, ProfileIsSynthetic: isSynthetic, CreatedAt: now, IdleExpiresAt: now.Add(30 * time.Minute), AbsoluteExpiresAt: now.Add(8 * time.Hour), CSRFToken: csrf} hash := sha256.Sum256([]byte(token)) _, err = s.pool.Exec(ctx, `INSERT INTO auth_sessions(user_id,merchant_id,token_hash,csrf_token,created_at,idle_expires_at,absolute_expires_at,last_seen_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$5)`, userID, merchantID, hash[:], csrf, now, session.IdleExpiresAt, session.AbsoluteExpiresAt) if err != nil { return Session{}, "", fmt.Errorf("creating auth session: %w", err) } return session, token, nil } func (s *Service) Resolve(ctx context.Context, token string) (Session, error) { if token == "" { return Session{}, ErrUnauthenticated } hash := sha256.Sum256([]byte(token)) var session Session err := s.pool.QueryRow(ctx, `SELECT u.id,m.id,u.username,m.source_key,m.display_name,m.profile_is_synthetic, a.created_at,a.idle_expires_at,a.absolute_expires_at,a.csrf_token FROM auth_sessions a JOIN merchant_users u ON u.id=a.user_id JOIN merchants m ON m.id=a.merchant_id WHERE a.token_hash=$1 AND a.revoked_at IS NULL AND u.is_active AND a.idle_expires_at>now() AND a.absolute_expires_at>now()`, hash[:]).Scan( &session.UserID, &session.MerchantID, &session.Username, &session.MerchantSourceKey, &session.MerchantDisplayName, &session.ProfileIsSynthetic, &session.CreatedAt, &session.IdleExpiresAt, &session.AbsoluteExpiresAt, &session.CSRFToken, ) if errors.Is(err, pgx.ErrNoRows) { return Session{}, ErrUnauthenticated } if err != nil { return Session{}, fmt.Errorf("resolving auth session: %w", err) } return session, nil } func (s *Service) Logout(ctx context.Context, token string) error { hash := sha256.Sum256([]byte(token)) _, err := s.pool.Exec(ctx, `UPDATE auth_sessions SET revoked_at=now() WHERE token_hash=$1 AND revoked_at IS NULL`, hash[:]) if err != nil { return fmt.Errorf("revoking auth session: %w", err) } return nil } func randomToken(size int) (string, error) { data := make([]byte, size) if _, err := rand.Read(data); err != nil { return "", fmt.Errorf("generating secure token: %w", err) } return base64.RawURLEncoding.EncodeToString(data), nil }