initial commit
This commit is contained in:
@@ -0,0 +1,277 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// defaultTestTTL is long enough that nothing expires mid-test.
|
||||
const defaultTestTTL = 5 * time.Minute
|
||||
|
||||
func TestPasswordHashingRoundTrip(t *testing.T) {
|
||||
const password = "correct horse battery staple"
|
||||
|
||||
hash, err := HashPassword(password)
|
||||
if err != nil {
|
||||
t.Fatalf("hash: %v", err)
|
||||
}
|
||||
if strings.Contains(hash, password) {
|
||||
t.Fatal("the hash contains the plaintext password")
|
||||
}
|
||||
if !strings.HasPrefix(hash, "$argon2id$") {
|
||||
t.Errorf("hash = %q, want the Argon2id PHC format", hash)
|
||||
}
|
||||
|
||||
ok, err := VerifyPassword(hash, password)
|
||||
if err != nil {
|
||||
t.Fatalf("verify: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Error("the correct password did not verify")
|
||||
}
|
||||
|
||||
ok, err = VerifyPassword(hash, "wrong password entirely")
|
||||
if err != nil {
|
||||
t.Fatalf("verify wrong: %v", err)
|
||||
}
|
||||
if ok {
|
||||
t.Error("an incorrect password verified")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashesAreSalted(t *testing.T) {
|
||||
a, err := HashPassword("same password")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := HashPassword("same password")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a == b {
|
||||
t.Error("two hashes of the same password are identical; the salt is not random")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyRejectsMalformedHashes(t *testing.T) {
|
||||
for _, bad := range []string{
|
||||
"", "not-a-hash", "$argon2id$", "$argon2id$v=19$m=1$x$y",
|
||||
"$bcrypt$v=19$m=65536,t=3,p=2$c2FsdA$aGFzaA",
|
||||
} {
|
||||
if _, err := VerifyPassword(bad, "password"); err == nil {
|
||||
t.Errorf("VerifyPassword(%q) returned no error for a malformed hash", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatePassword(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
wantErr bool
|
||||
}{
|
||||
{"long enough", "a-perfectly-fine-phrase", false},
|
||||
{"exactly the minimum", strings.Repeat("x", 12), true}, // repeated character
|
||||
{"mixed at the minimum", "aB3$xY9!zQ2w", false},
|
||||
{"too short", "short", true},
|
||||
{"empty", "", true},
|
||||
{"contains password", "mypassword123456", true},
|
||||
{"contains the product name", "vibedns-is-great-here", true},
|
||||
{"single repeated character", strings.Repeat("a", 20), true},
|
||||
{"control character", "abcdefghijkl\x00mnop", true},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := ValidatePassword(tc.in)
|
||||
if tc.wantErr && err == nil {
|
||||
t.Error("expected an error, got none")
|
||||
}
|
||||
if !tc.wantErr && err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratePassword(t *testing.T) {
|
||||
seen := map[string]bool{}
|
||||
for i := 0; i < 50; i++ {
|
||||
p, err := GeneratePassword(20)
|
||||
if err != nil {
|
||||
t.Fatalf("generate: %v", err)
|
||||
}
|
||||
if len(p) != 20 {
|
||||
t.Fatalf("length = %d, want 20", len(p))
|
||||
}
|
||||
if seen[p] {
|
||||
t.Fatal("generated the same password twice")
|
||||
}
|
||||
seen[p] = true
|
||||
|
||||
if err := ValidatePassword(p); err != nil {
|
||||
t.Errorf("a generated password failed the policy: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Short requests are raised to a safe floor rather than honoured.
|
||||
p, _ := GeneratePassword(4)
|
||||
if len(p) < 12 {
|
||||
t.Errorf("short request produced %d characters, want at least 12", len(p))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenGeneration(t *testing.T) {
|
||||
tok, err := GenerateToken()
|
||||
if err != nil {
|
||||
t.Fatalf("generate: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(tok.Secret, "vibedns_") {
|
||||
t.Errorf("secret = %q, want the vibedns_ prefix for secret scanners", tok.Secret)
|
||||
}
|
||||
if len(tok.Prefix) != TokenPrefixLen {
|
||||
t.Errorf("prefix length = %d, want %d", len(tok.Prefix), TokenPrefixLen)
|
||||
}
|
||||
if strings.Contains(tok.Hash, tok.Secret) {
|
||||
t.Error("the stored hash contains the secret")
|
||||
}
|
||||
|
||||
if !VerifyToken(tok.Hash, tok.Secret) {
|
||||
t.Error("the generated token did not verify against its own hash")
|
||||
}
|
||||
if VerifyToken(tok.Hash, "vibedns_someothervalue") {
|
||||
t.Error("a different token verified against the hash")
|
||||
}
|
||||
|
||||
got, err := TokenPrefix(tok.Secret)
|
||||
if err != nil {
|
||||
t.Fatalf("prefix: %v", err)
|
||||
}
|
||||
if got != tok.Prefix {
|
||||
t.Errorf("extracted prefix = %q, want %q", got, tok.Prefix)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokensAreUnique(t *testing.T) {
|
||||
seen := map[string]bool{}
|
||||
for i := 0; i < 100; i++ {
|
||||
tok, err := GenerateToken()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if seen[tok.Secret] {
|
||||
t.Fatal("generated the same token twice")
|
||||
}
|
||||
seen[tok.Secret] = true
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenPrefixRejectsShortInput(t *testing.T) {
|
||||
if _, err := TokenPrefix("vibedns_ab"); err == nil {
|
||||
t.Error("expected an error for a truncated token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSRFTokenLifecycle(t *testing.T) {
|
||||
a := New(nil, nil, []byte("a-test-signing-key-of-sufficient-length"))
|
||||
|
||||
token := a.IssueCSRFToken("admin")
|
||||
if token == "" {
|
||||
t.Fatal("no token issued")
|
||||
}
|
||||
|
||||
if !a.ValidateCSRFToken(token, "admin") {
|
||||
t.Error("a freshly issued token did not validate")
|
||||
}
|
||||
// A token is bound to the account it was issued for.
|
||||
if a.ValidateCSRFToken(token, "someone-else") {
|
||||
t.Error("a token validated for a different account")
|
||||
}
|
||||
if a.ValidateCSRFToken("garbage", "admin") {
|
||||
t.Error("a garbage token validated")
|
||||
}
|
||||
if a.ValidateCSRFToken("", "admin") {
|
||||
t.Error("an empty token validated")
|
||||
}
|
||||
|
||||
// A token signed with a different key must not validate.
|
||||
other := New(nil, nil, []byte("a-completely-different-signing-key-xx"))
|
||||
if other.ValidateCSRFToken(token, "admin") {
|
||||
t.Error("a token validated under a different signing key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeedsRehash(t *testing.T) {
|
||||
current, err := HashPassword("some password here")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if NeedsRehash(current) {
|
||||
t.Error("a hash produced with the current parameters should not need rehashing")
|
||||
}
|
||||
// A hash with weaker parameters should be upgraded on next sign-in.
|
||||
weak := "$argon2id$v=19$m=1024,t=1,p=1$c2FsdHNhbHQ$aGFzaGhhc2hoYXNoaGFzaA"
|
||||
if !NeedsRehash(weak) {
|
||||
t.Error("a weak hash should be flagged for rehashing")
|
||||
}
|
||||
if !NeedsRehash("not-a-hash") {
|
||||
t.Error("an unparseable hash should be flagged for rehashing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialCache(t *testing.T) {
|
||||
c := newCredentialCache(defaultTestTTL)
|
||||
const user, pass, hash = "admin", "the password", "stored-hash-value"
|
||||
|
||||
if c.valid(user, pass, hash) {
|
||||
t.Error("an empty cache reported a valid credential")
|
||||
}
|
||||
|
||||
c.store(user, pass, hash)
|
||||
if !c.valid(user, pass, hash) {
|
||||
t.Error("a stored credential did not validate")
|
||||
}
|
||||
if c.valid(user, "wrong password", hash) {
|
||||
t.Error("a wrong password validated against the cache")
|
||||
}
|
||||
// A changed stored hash means the password was rotated: the cached entry
|
||||
// must stop being authoritative immediately.
|
||||
if c.valid(user, pass, "a-different-stored-hash") {
|
||||
t.Error("the cache validated against a stale password hash")
|
||||
}
|
||||
|
||||
c.reset()
|
||||
if c.valid(user, pass, hash) {
|
||||
t.Error("the cache still validated after being reset")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttemptLimiter(t *testing.T) {
|
||||
l := newAttemptLimiter(3, defaultTestTTL)
|
||||
const key = "192.0.2.1"
|
||||
|
||||
if !l.allow(key) {
|
||||
t.Fatal("a fresh address was blocked")
|
||||
}
|
||||
for i := 0; i < 3; i++ {
|
||||
l.fail(key)
|
||||
}
|
||||
if l.allow(key) {
|
||||
t.Error("the address should be locked out after reaching the failure limit")
|
||||
}
|
||||
|
||||
// A different address is unaffected.
|
||||
if !l.allow("192.0.2.2") {
|
||||
t.Error("an unrelated address was locked out")
|
||||
}
|
||||
|
||||
// A success clears the record.
|
||||
l2 := newAttemptLimiter(3, defaultTestTTL)
|
||||
l2.fail(key)
|
||||
l2.fail(key)
|
||||
l2.succeed(key)
|
||||
l2.fail(key)
|
||||
if !l2.allow(key) {
|
||||
t.Error("a successful sign-in should reset the failure count")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user