feat: seed secure demo merchant identity

This commit is contained in:
2026-08-20 19:52:01 +03:30
parent cc7a4e4718
commit b30e3439a4
7 changed files with 296 additions and 2 deletions

View File

@@ -0,0 +1,56 @@
package identity
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"errors"
"fmt"
"strings"
"golang.org/x/crypto/argon2"
)
const (
argonMemory = 64 * 1024
argonIterations = 3
argonParallelism = 2
argonSaltLength = 16
argonKeyLength = 32
)
func HashPassword(password string) (string, error) {
if len(password) < 12 {
return "", errors.New("identity: password must contain at least 12 characters")
}
salt := make([]byte, argonSaltLength)
if _, err := rand.Read(salt); err != nil {
return "", fmt.Errorf("generating password salt: %w", err)
}
hash := argon2.IDKey([]byte(password), salt, argonIterations, argonMemory, argonParallelism, argonKeyLength)
return fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s", argonMemory, argonIterations,
argonParallelism, base64.RawStdEncoding.EncodeToString(salt), base64.RawStdEncoding.EncodeToString(hash)), nil
}
func VerifyPassword(password, encoded string) bool {
parts := strings.Split(encoded, "$")
if len(parts) != 6 || parts[1] != "argon2id" || parts[2] != "v=19" {
return false
}
var memory uint32
var iterations uint32
var parallelism uint8
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &iterations, &parallelism); err != nil {
return false
}
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
if err != nil {
return false
}
expected, err := base64.RawStdEncoding.DecodeString(parts[5])
if err != nil || len(expected) == 0 {
return false
}
actual := argon2.IDKey([]byte(password), salt, iterations, memory, parallelism, uint32(len(expected)))
return subtle.ConstantTimeCompare(actual, expected) == 1
}

View File

@@ -0,0 +1,24 @@
package identity
import "testing"
func TestHashPasswordAndVerifyPassword(t *testing.T) {
t.Parallel()
hash, err := HashPassword("correct horse battery staple")
if err != nil {
t.Fatal(err)
}
if !VerifyPassword("correct horse battery staple", hash) {
t.Fatal("VerifyPassword() = false, want true")
}
if VerifyPassword("incorrect password", hash) {
t.Fatal("VerifyPassword() = true for incorrect password")
}
}
func TestHashPasswordRejectsShortPassword(t *testing.T) {
t.Parallel()
if _, err := HashPassword("short"); err == nil {
t.Fatal("HashPassword() error = nil, want length error")
}
}

39
internal/identity/seed.go Normal file
View File

@@ -0,0 +1,39 @@
package identity
import (
"context"
"fmt"
"github.com/jackc/pgx/v5/pgxpool"
)
type SeedResult struct {
Username string
MerchantSourceKey string
MerchantName string
}
func SeedDemo(ctx context.Context, pool *pgxpool.Pool, username, password string) (SeedResult, error) {
hash, err := HashPassword(password)
if err != nil {
return SeedResult{}, err
}
var result SeedResult
var merchantID int64
err = pool.QueryRow(ctx, `SELECT m.id,m.source_key,m.display_name
FROM merchants m JOIN payment_sessions ps ON ps.merchant_id=m.id
GROUP BY m.id ORDER BY count(*) DESC,m.source_key LIMIT 1`).Scan(
&merchantID, &result.MerchantSourceKey, &result.MerchantName,
)
if err != nil {
return SeedResult{}, fmt.Errorf("selecting demo merchant: %w", err)
}
_, err = pool.Exec(ctx, `INSERT INTO merchant_users(merchant_id,username,password_hash)
VALUES ($1,$2,$3) ON CONFLICT (username) DO UPDATE
SET merchant_id=excluded.merchant_id,password_hash=excluded.password_hash,is_active=true`, merchantID, username, hash)
if err != nil {
return SeedResult{}, fmt.Errorf("seeding demo merchant user: %w", err)
}
result.Username = username
return result, nil
}

View File

@@ -0,0 +1,113 @@
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
}