From 12b7945efcb61b42ea3944c776a681a7b078f345 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Fri, 3 Jul 2026 19:50:11 +0700 Subject: [PATCH] =?UTF-8?q?feat(auth):=20argon2id=20=D0=BF=D0=B0=D1=80?= =?UTF-8?q?=D0=BE=D0=BB=D0=B8=20+=20session=20store=20(sha256=20=D1=82?= =?UTF-8?q?=D0=BE=D0=BA=D0=B5=D0=BD=D0=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- go.mod | 6 ++-- go.sum | 16 +++++----- internal/auth/password.go | 52 +++++++++++++++++++++++++++++++ internal/auth/password_test.go | 29 ++++++++++++++++++ internal/auth/session.go | 56 ++++++++++++++++++++++++++++++++++ internal/auth/session_test.go | 55 +++++++++++++++++++++++++++++++++ 6 files changed, 203 insertions(+), 11 deletions(-) create mode 100644 internal/auth/password.go create mode 100644 internal/auth/password_test.go create mode 100644 internal/auth/session.go create mode 100644 internal/auth/session_test.go diff --git a/go.mod b/go.mod index 986f736..59e8617 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/pressly/goose/v3 v3.27.2 github.com/testcontainers/testcontainers-go v0.43.0 github.com/testcontainers/testcontainers-go/modules/postgres v0.43.0 + golang.org/x/crypto v0.53.0 ) require ( @@ -64,9 +65,8 @@ require ( go.opentelemetry.io/otel/metric v1.43.0 // indirect go.opentelemetry.io/otel/trace v1.43.0 // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/crypto v0.52.0 // indirect golang.org/x/sync v0.21.0 // indirect - golang.org/x/sys v0.45.0 // indirect - golang.org/x/text v0.37.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.38.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 23b3e13..92810b7 100644 --- a/go.sum +++ b/go.sum @@ -150,19 +150,19 @@ go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09 go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= -golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= -golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= diff --git a/internal/auth/password.go b/internal/auth/password.go new file mode 100644 index 0000000..e88b04b --- /dev/null +++ b/internal/auth/password.go @@ -0,0 +1,52 @@ +package auth + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "fmt" + "strings" + + "golang.org/x/crypto/argon2" +) + +const ( + argonTime = 1 + argonMemory = 64 * 1024 + argonThreads = 4 + argonKeyLen = 32 + argonSaltLen = 16 +) + +func HashPassword(password string) (string, error) { + salt := make([]byte, argonSaltLen) + if _, err := rand.Read(salt); err != nil { + return "", err + } + key := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen) + b64 := base64.RawStdEncoding.EncodeToString + return fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s", + argonMemory, argonTime, argonThreads, b64(salt), b64(key)), nil +} + +func VerifyPassword(encoded, password string) (bool, error) { + parts := strings.Split(encoded, "$") + if len(parts) != 6 || parts[1] != "argon2id" { + return false, fmt.Errorf("auth: bad hash format") + } + var m, t uint32 + var p uint8 + if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &m, &t, &p); err != nil { + return false, err + } + salt, err := base64.RawStdEncoding.DecodeString(parts[4]) + if err != nil { + return false, err + } + want, err := base64.RawStdEncoding.DecodeString(parts[5]) + if err != nil { + return false, err + } + got := argon2.IDKey([]byte(password), salt, t, m, p, uint32(len(want))) + return subtle.ConstantTimeCompare(got, want) == 1, nil +} diff --git a/internal/auth/password_test.go b/internal/auth/password_test.go new file mode 100644 index 0000000..61c151e --- /dev/null +++ b/internal/auth/password_test.go @@ -0,0 +1,29 @@ +package auth + +import "testing" + +func TestHashVerifyRoundTrip(t *testing.T) { + h, err := HashPassword("s3cret-pw") + if err != nil { + t.Fatal(err) + } + if h == "s3cret-pw" || len(h) < 20 { + t.Fatalf("bad hash %q", h) + } + ok, err := VerifyPassword(h, "s3cret-pw") + if err != nil || !ok { + t.Fatalf("verify failed: %v %v", ok, err) + } + bad, _ := VerifyPassword(h, "wrong") + if bad { + t.Fatal("wrong password must not verify") + } +} + +func TestHashNonDeterministic(t *testing.T) { + a, _ := HashPassword("same") + b, _ := HashPassword("same") + if a == b { + t.Fatal("salt must randomize hash") + } +} diff --git a/internal/auth/session.go b/internal/auth/session.go new file mode 100644 index 0000000..cf34883 --- /dev/null +++ b/internal/auth/session.go @@ -0,0 +1,56 @@ +package auth + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "errors" + "time" + + "github.com/google/uuid" +) + +var ErrNoSession = errors.New("auth: no such session") + +type SessionStore interface { + CreateSession(ctx context.Context, userID uuid.UUID, tokenHash string, expiresAt time.Time) error + GetSessionUser(ctx context.Context, tokenHash string) (uuid.UUID, error) + DeleteSession(ctx context.Context, tokenHash string) error +} + +type Sessions struct { + store SessionStore + ttl time.Duration +} + +func NewSessions(store SessionStore, ttl time.Duration) *Sessions { + return &Sessions{store: store, ttl: ttl} +} + +func TokenHash(token string) string { + sum := sha256.Sum256([]byte(token)) + return hex.EncodeToString(sum[:]) +} + +func (s *Sessions) Create(ctx context.Context, userID uuid.UUID) (string, time.Time, error) { + raw := make([]byte, 32) + if _, err := rand.Read(raw); err != nil { + return "", time.Time{}, err + } + token := base64.RawURLEncoding.EncodeToString(raw) + exp := time.Now().Add(s.ttl) + if err := s.store.CreateSession(ctx, userID, TokenHash(token), exp); err != nil { + return "", time.Time{}, err + } + return token, exp, nil +} + +func (s *Sessions) Validate(ctx context.Context, token string) (uuid.UUID, error) { + return s.store.GetSessionUser(ctx, TokenHash(token)) +} + +func (s *Sessions) Destroy(ctx context.Context, token string) error { + return s.store.DeleteSession(ctx, TokenHash(token)) +} diff --git a/internal/auth/session_test.go b/internal/auth/session_test.go new file mode 100644 index 0000000..6dad7da --- /dev/null +++ b/internal/auth/session_test.go @@ -0,0 +1,55 @@ +package auth + +import ( + "context" + "testing" + "time" + + "github.com/google/uuid" +) + +type memStore struct { + byHash map[string]uuid.UUID + exp map[string]time.Time +} + +func newMem() *memStore { return &memStore{byHash: map[string]uuid.UUID{}, exp: map[string]time.Time{}} } +func (m *memStore) CreateSession(_ context.Context, uid uuid.UUID, h string, e time.Time) error { + m.byHash[h] = uid + m.exp[h] = e + return nil +} +func (m *memStore) GetSessionUser(_ context.Context, h string) (uuid.UUID, error) { + uid, ok := m.byHash[h] + if !ok || time.Now().After(m.exp[h]) { + return uuid.Nil, ErrNoSession + } + return uid, nil +} +func (m *memStore) DeleteSession(_ context.Context, h string) error { delete(m.byHash, h); return nil } + +func TestSessionCreateValidateDestroy(t *testing.T) { + s := NewSessions(newMem(), time.Hour) + uid := uuid.New() + token, exp, err := s.Create(context.Background(), uid) + if err != nil || token == "" || exp.Before(time.Now()) { + t.Fatalf("create: %v %q", err, token) + } + got, err := s.Validate(context.Background(), token) + if err != nil || got != uid { + t.Fatalf("validate: %v %v", got, err) + } + if err := s.Destroy(context.Background(), token); err != nil { + t.Fatal(err) + } + if _, err := s.Validate(context.Background(), token); err == nil { + t.Fatal("destroyed session must not validate") + } +} + +func TestValidateUnknownToken(t *testing.T) { + s := NewSessions(newMem(), time.Hour) + if _, err := s.Validate(context.Background(), "nope"); err == nil { + t.Fatal("unknown token must error") + } +}