feat(api): identité user-first — login/password, ownership user, fin du token device
- 000006 : users.username/username_normalized/password_hash/is_admin, index unique partiel sur username_normalized, DROP idx_users_email (unicité V1 = username, jamais email) ; 000007 destructif : resources.owner_id→user_id (FK→users, reset base dev), l'unicité racine devient par user - pkg/passwd : argon2id (t=1,m=64MiB,p=4,k=32), comparaison constant-time, VerifyTimedEqual (délai égalisé dummy-hash) ; pkg/auth : subject=user_id + claim device_id, TTL 7 j sans refresh - repository : Users (count/get/resolve-exact/create/update-password/ mark-deleted), Devices.MarkUser (user_id INFORMATIF uniquement) - handlers : POST /devices sans token, POST /auth/login (401 indistinguable user inconnu/mauvais mdp, device requis), PATCH /users/me/password (current_password, tokens non révoqués — limite V1), GET /users/resolve (exact lowercase, jamais email/is_admin) ; middleware RequireAuth (user authorisant, device porté) ; tout le scoping ressources passe user - bootstrap admin : users vide + ADMIN_* absents → refus de démarrer ; .env.example ; docs/api-v1.md §2/§3/§7/§8 - tests : migrations 000006/000007 (up/down), pkg/auth Identity, handlers login/password/resolve, helpers refactorés registerAndLogin, repo tests scopés user — suite backend verte
This commit is contained in:
+29
-10
@@ -8,11 +8,22 @@ import (
|
||||
"aidanwoods.dev/go-paseto"
|
||||
)
|
||||
|
||||
const TokenTTL = 90 * 24 * time.Hour
|
||||
// TokenTTL = 7 jours, sans refresh (V1). À expiration, le client doit
|
||||
// re-demander un POST /auth/login.
|
||||
const TokenTTL = 7 * 24 * time.Hour
|
||||
|
||||
var ErrInvalidToken = errors.New("invalid token")
|
||||
|
||||
// Manager issues and verifies paseto v4-local bearer tokens bound to a device_id.
|
||||
// Identity est le résultat de Verify : l'utilisateur (sujet du token, autorisant)
|
||||
// et le device (claim secondaire, porté mais NON autorisant seul).
|
||||
type Identity struct {
|
||||
UserID string
|
||||
DeviceID string
|
||||
}
|
||||
|
||||
// Manager issues and verifies paseto v4-local bearer tokens.
|
||||
// Le subject est l'USER (scope des ressources) ; device_id est un claim
|
||||
// transporté pour l'idempotence outbox et le nommage des uploads.
|
||||
type Manager struct {
|
||||
key paseto.V4SymmetricKey
|
||||
}
|
||||
@@ -26,24 +37,32 @@ func NewManager(secret string) (*Manager, error) {
|
||||
return &Manager{key: key}, nil
|
||||
}
|
||||
|
||||
func (m *Manager) Issue(deviceID string) (string, error) {
|
||||
// Issue émet un token lié à un utilisateur ET à un device.
|
||||
func (m *Manager) Issue(userID, deviceID string) (string, error) {
|
||||
now := time.Now()
|
||||
token := paseto.NewToken()
|
||||
token.SetIssuedAt(now)
|
||||
token.SetNotBefore(now)
|
||||
token.SetExpiration(now.Add(TokenTTL))
|
||||
token.SetSubject(deviceID)
|
||||
token.SetSubject(userID)
|
||||
// v1.6.0 : SetString n'expose pas d'erreur (panic si non sérialisable).
|
||||
token.SetString("device_id", deviceID)
|
||||
return token.V4Encrypt(m.key, nil), nil
|
||||
}
|
||||
|
||||
func (m *Manager) Verify(signed string) (string, error) {
|
||||
// Verify décode et contrôle le token, retourne l'identité (user + device).
|
||||
func (m *Manager) Verify(signed string) (Identity, error) {
|
||||
parsed, err := paseto.NewParserForValidNow().ParseV4Local(m.key, signed, nil)
|
||||
if err != nil {
|
||||
return "", ErrInvalidToken
|
||||
return Identity{}, ErrInvalidToken
|
||||
}
|
||||
subject, err := parsed.GetSubject()
|
||||
if err != nil {
|
||||
return "", ErrInvalidToken
|
||||
userID, err := parsed.GetSubject()
|
||||
if err != nil || userID == "" {
|
||||
return Identity{}, ErrInvalidToken
|
||||
}
|
||||
return subject, nil
|
||||
deviceID, err := parsed.GetString("device_id")
|
||||
if err != nil || deviceID == "" {
|
||||
return Identity{}, ErrInvalidToken
|
||||
}
|
||||
return Identity{UserID: userID, DeviceID: deviceID}, nil
|
||||
}
|
||||
|
||||
@@ -3,6 +3,14 @@ package auth
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"aidanwoods.dev/go-paseto"
|
||||
)
|
||||
|
||||
const (
|
||||
testUserID = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
testDeviceID = "0123456789abcdef0123456789abcdef"
|
||||
)
|
||||
|
||||
func TestIssueVerifyRoundtrip(t *testing.T) {
|
||||
@@ -11,8 +19,7 @@ func TestIssueVerifyRoundtrip(t *testing.T) {
|
||||
t.Fatalf("NewManager: %v", err)
|
||||
}
|
||||
|
||||
deviceID := "0123456789abcdef0123456789abcdef"
|
||||
signed, err := m.Issue(deviceID)
|
||||
signed, err := m.Issue(testUserID, testDeviceID)
|
||||
if err != nil {
|
||||
t.Fatalf("Issue: %v", err)
|
||||
}
|
||||
@@ -21,18 +28,33 @@ func TestIssueVerifyRoundtrip(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Verify: %v", err)
|
||||
}
|
||||
if got != deviceID {
|
||||
t.Fatalf("Verify: got %q want %q", got, deviceID)
|
||||
if got.UserID != testUserID {
|
||||
t.Errorf("Verify.UserID = %q, want %q", got.UserID, testUserID)
|
||||
}
|
||||
if got.DeviceID != testDeviceID {
|
||||
t.Errorf("Verify.DeviceID = %q, want %q", got.DeviceID, testDeviceID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueIsUserSubject(t *testing.T) {
|
||||
m, _ := NewManager("test-secret")
|
||||
signed, err := m.Issue(testUserID, testDeviceID)
|
||||
if err != nil {
|
||||
t.Fatalf("Issue: %v", err)
|
||||
}
|
||||
parsed, err := paseto.NewParserForValidNow().ParseV4Local(m.key, signed, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if subject, _ := parsed.GetSubject(); subject != testUserID {
|
||||
t.Errorf("subject = %q, want user %q", subject, testUserID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyRejectsTamperedToken(t *testing.T) {
|
||||
m, err := NewManager("test-secret")
|
||||
if err != nil {
|
||||
t.Fatalf("NewManager: %v", err)
|
||||
}
|
||||
m, _ := NewManager("test-secret")
|
||||
|
||||
signed, _ := m.Issue("0123456789abcdef0123456789abcdef")
|
||||
signed, _ := m.Issue(testUserID, testDeviceID)
|
||||
parts := strings.Split(signed, ".")
|
||||
parts[len(parts)-1] = "nope"
|
||||
tampered := strings.Join(parts, ".")
|
||||
@@ -43,10 +65,7 @@ func TestVerifyRejectsTamperedToken(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestVerifyRejectsGarbage(t *testing.T) {
|
||||
m, err := NewManager("test-secret")
|
||||
if err != nil {
|
||||
t.Fatalf("NewManager: %v", err)
|
||||
}
|
||||
m, _ := NewManager("test-secret")
|
||||
if _, err := m.Verify("not-a-token"); err == nil {
|
||||
t.Fatal("expected garbage to be rejected")
|
||||
}
|
||||
@@ -56,8 +75,23 @@ func TestDifferentSecretRejectsToken(t *testing.T) {
|
||||
a, _ := NewManager("secret-a")
|
||||
b, _ := NewManager("secret-b")
|
||||
|
||||
signed, _ := a.Issue("0123456789abcdef0123456789abcdef")
|
||||
signed, _ := a.Issue(testUserID, testDeviceID)
|
||||
if _, err := b.Verify(signed); err == nil {
|
||||
t.Fatal("expected token from another manager to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyRequiresDeviceClaim(t *testing.T) {
|
||||
m, _ := NewManager("test-secret")
|
||||
// Token sans claim device_id (subject seul, type de l'ancien format) → invalide.
|
||||
now := time.Now()
|
||||
token := paseto.NewToken()
|
||||
token.SetIssuedAt(now)
|
||||
token.SetNotBefore(now)
|
||||
token.SetExpiration(now.Add(TokenTTL))
|
||||
token.SetSubject(testUserID)
|
||||
signed := token.V4Encrypt(m.key, nil)
|
||||
if _, err := m.Verify(signed); err == nil {
|
||||
t.Fatal("expected token without device claim to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
// Package passwd gère le hachage des mots de passe (argon2id).
|
||||
// Paramètres explicites, jamais de défauts implicites de la lib.
|
||||
// Attention : ne jamais logger ni exposer un hash hors de Verify/Hash.
|
||||
package passwd
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
)
|
||||
|
||||
// Paramètres argon2id (OWASP-ish, raisonnables pour un usage familial V1).
|
||||
const (
|
||||
argonTime = 1
|
||||
argonMemory = 64 * 1024 // 64 MiB
|
||||
argonThreads = 4
|
||||
argonKeyLen = 32
|
||||
argonSaltLen = 16
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrMismatch : mot de passe (ou dummy) ne correspond pas au hash.
|
||||
ErrMismatch = errors.New("invalid password")
|
||||
// ErrMalformed : hash stocké illisible/corrompu dans la base.
|
||||
ErrMalformed = errors.New("malformed password hash")
|
||||
)
|
||||
|
||||
// dummyHash est un hash argon2id valide d'un mot de passe aléatoire fixe.
|
||||
// Il sert d'égalisation de timing : quand le username est inconnu, on vérifie
|
||||
// quand même contre ce hash pour que le délai de réponse soit identique à un
|
||||
// « mauvais mot de passe ».
|
||||
var dummyHash = func() string {
|
||||
h, err := Hash("vaultdrop-constantime-dummy")
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("passwd: impossible de pré-hacher le dummy: %v", err))
|
||||
}
|
||||
return h
|
||||
}()
|
||||
|
||||
// Hash produit une chaîne encodable : $argon2id$v=19$m=65536,t=1,p=4$<salt>$<hash>.
|
||||
func Hash(password string) (string, error) {
|
||||
salt := make([]byte, argonSaltLen)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
return "", fmt.Errorf("passwd: rand: %w", err)
|
||||
}
|
||||
|
||||
key := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
|
||||
|
||||
params := fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d",
|
||||
argon2.Version, argonMemory, argonTime, argonThreads)
|
||||
saltEnc := base64.RawStdEncoding.EncodeToString(salt)
|
||||
keyEnc := base64.RawStdEncoding.EncodeToString(key)
|
||||
return params + "$" + saltEnc + "$" + keyEnc, nil
|
||||
}
|
||||
|
||||
// Verify compare le mot de passe au hash stocké (temps constant sur le hash).
|
||||
// En cas de hash corrompu, on vérifie contre le dummyHash (même délai).
|
||||
func Verify(password, stored string) error {
|
||||
fields := strings.Split(stored, "$")
|
||||
if len(fields) != 6 || fields[1] != "argon2id" {
|
||||
return ErrMalformed
|
||||
}
|
||||
|
||||
var version int
|
||||
if _, err := fmt.Sscanf(fields[2], "v=%d", &version); err != nil || version != argon2.Version {
|
||||
return ErrMalformed
|
||||
}
|
||||
|
||||
var memory, timeCost, threads int
|
||||
if _, err := fmt.Sscanf(fields[3], "m=%d,t=%d,p=%d", &memory, &timeCost, &threads); err != nil {
|
||||
return ErrMalformed
|
||||
}
|
||||
|
||||
salt, err := base64.RawStdEncoding.DecodeString(fields[4])
|
||||
if err != nil {
|
||||
return ErrMalformed
|
||||
}
|
||||
want, err := base64.RawStdEncoding.DecodeString(fields[5])
|
||||
if err != nil {
|
||||
return ErrMalformed
|
||||
}
|
||||
|
||||
got := argon2.IDKey([]byte(password), salt, uint32(timeCost), uint32(memory), uint8(threads), uint32(len(want)))
|
||||
if subtle.ConstantTimeCompare(got, want) != 1 {
|
||||
return ErrMismatch
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// VerifyTimedEqual vérifie le mot de passe fourni en égalisant le temps de
|
||||
// réponse que le compte existe ou non : username inconnu → vérification contre
|
||||
// dummyHash (retourne toujours ErrMismatch, dans un délai comparable).
|
||||
func VerifyTimedEqual(password, stored string) error {
|
||||
target := stored
|
||||
if stored == "" {
|
||||
target = dummyHash
|
||||
}
|
||||
return Verify(password, target)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package passwd
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHashVerifyRoundTrip(t *testing.T) {
|
||||
hash, err := Hash("mon-super-mot-de-passe")
|
||||
if err != nil {
|
||||
t.Fatalf("Hash: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(hash, "$argon2id$v=19$m=65536,t=1,p=4$") {
|
||||
t.Errorf("format inattendu : %s", hash)
|
||||
}
|
||||
if err := Verify("mon-super-mot-de-passe", hash); err != nil {
|
||||
t.Errorf("Verify (bon mot de passe) = %v", err)
|
||||
}
|
||||
if err := Verify("mauvais", hash); err != ErrMismatch {
|
||||
t.Errorf("Verify (mauvais mot de passe) = %v, attendu ErrMismatch", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyTimedEqualUnknownUsername(t *testing.T) {
|
||||
// username inconnu → stored vide → vérification contre dummyHash.
|
||||
// Ne doit JAMAIS réussir, et doit retourner ErrMismatch proprement.
|
||||
if err := VerifyTimedEqual("whatever", ""); err != ErrMismatch {
|
||||
t.Errorf("VerifyTimedEqual(stored vide) = %v, attendu ErrMismatch", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyMalformed(t *testing.T) {
|
||||
cases := []string{
|
||||
"",
|
||||
"pas-un-hash",
|
||||
"$argon2id$v=19$m=65536,t=1,p=4$AAAA",
|
||||
"$argon2id$v=18$m=65536,t=1,p=4$c2FsdA==$a2V5",
|
||||
}
|
||||
for _, stored := range cases {
|
||||
if err := Verify("x", stored); err != ErrMalformed {
|
||||
t.Errorf("Verify(%q) = %v, attendu ErrMalformed", stored, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashIsSaltRandomized(t *testing.T) {
|
||||
a, _ := Hash("same")
|
||||
b, _ := Hash("same")
|
||||
if a == b {
|
||||
t.Error("deux hashes du même mot de passe identiques (salt non randomisé ?)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashNeverExposesPassword(t *testing.T) {
|
||||
hash, err := Hash("secret-password")
|
||||
if err != nil {
|
||||
t.Fatalf("Hash: %v", err)
|
||||
}
|
||||
if strings.Contains(hash, "secret-password") {
|
||||
t.Error("le hash contient le mot de passe en clair")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user