feat(auth): argon2id пароли + session store (sha256 токена)
This commit is contained in:
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user