feat(auth): argon2id пароли + session store (sha256 токена)

This commit is contained in:
2026-07-03 19:50:11 +07:00
parent 3bd237d562
commit 12b7945efc
6 changed files with 203 additions and 11 deletions
+52
View File
@@ -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
}
+29
View File
@@ -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")
}
}
+56
View File
@@ -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))
}
+55
View File
@@ -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")
}
}