ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m15s
ci / api / api (push) Successful in 1m31s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 47s
ci / ts / ts (packages + web) (push) Successful in 39s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 26s
- users, refresh_tokens and audit_log schema (migration 00002) - argon2id passwords with rehash on login, password policy, lockout with exponential back-off and a per-IP rate limit in Redis - Ed25519 JWT access tokens with kid-based key rotation; rotating refresh tokens with reuse detection (cookie for web, body for Android) - bootstrap admin on first start; must_change_credentials and must_change_password gate every other endpoint - /auth/login, /auth/refresh, /auth/logout(-all), /me, /me/password, /me/bootstrap-credentials, /me/sessions; server-info reports bootstrap_required - role policy from docs/auth-and-roles.md with table tests - problem+json errors with field errors; ru/en translations of the codes - install.sh and update.sh generate JWT_PRIVATE_KEY Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
122 lines
3.9 KiB
Go
122 lines
3.9 KiB
Go
package auth
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"pipu/api/internal/platform/apperr"
|
|
)
|
|
|
|
var fastArgon = Argon2Params{MemoryKiB: 64, Time: 1, Threads: 1, SaltLen: 16, KeyLen: 32}
|
|
|
|
func TestHasher(t *testing.T) {
|
|
h := NewHasher(fastArgon)
|
|
enc, err := h.Hash("correct horse")
|
|
require.NoError(t, err)
|
|
assert.True(t, strings.HasPrefix(enc, "$argon2id$v=19$m=64,t=1,p=1$"), enc)
|
|
|
|
ok, rehash, err := h.Verify("correct horse", enc)
|
|
require.NoError(t, err)
|
|
assert.True(t, ok)
|
|
assert.False(t, rehash)
|
|
|
|
ok, _, err = h.Verify("wrong horse", enc)
|
|
require.NoError(t, err)
|
|
assert.False(t, ok)
|
|
|
|
other, _ := h.Hash("correct horse")
|
|
assert.NotEqual(t, enc, other, "salt is random")
|
|
|
|
stronger := NewHasher(Argon2Params{MemoryKiB: 128, Time: 1, Threads: 1, SaltLen: 16, KeyLen: 32})
|
|
ok, rehash, err = stronger.Verify("correct horse", enc)
|
|
require.NoError(t, err)
|
|
assert.True(t, ok, "old params are read from the hash")
|
|
assert.True(t, rehash)
|
|
}
|
|
|
|
func TestHasherRejectsMalformed(t *testing.T) {
|
|
h := NewHasher(fastArgon)
|
|
for _, s := range []string{"", "plain", "$argon2i$v=19$m=64,t=1,p=1$c2FsdA$a2V5", "$argon2id$v=18$m=64,t=1,p=1$c2FsdA$a2V5",
|
|
"$argon2id$v=19$m=x,t=1,p=1$c2FsdA$a2V5", "$argon2id$v=19$m=64,t=1,p=1$!!$a2V5", "$argon2id$v=19$m=64,t=1,p=1$c2FsdA$"} {
|
|
_, _, err := h.Verify("x", s)
|
|
assert.Error(t, err, s)
|
|
}
|
|
}
|
|
|
|
func TestGeneratePassword(t *testing.T) {
|
|
seen := map[string]bool{}
|
|
for range 50 {
|
|
p, err := GeneratePassword(12)
|
|
require.NoError(t, err)
|
|
assert.Len(t, p, 12)
|
|
for _, r := range p {
|
|
assert.Contains(t, tempPasswordAlphabet, string(r))
|
|
}
|
|
seen[p] = true
|
|
}
|
|
assert.Len(t, seen, 50)
|
|
}
|
|
|
|
func TestValidatePassword(t *testing.T) {
|
|
cases := []struct {
|
|
name, password, username string
|
|
role Role
|
|
code string
|
|
}{
|
|
{"user ok", "eightchr", "alice", RoleUser, ""},
|
|
{"user short", "sevench", "alice", RoleUser, CodePasswordTooShort},
|
|
{"moderator needs 12", "elevenchars", "mod", RoleModerator, CodePasswordTooShort},
|
|
{"admin ok", "twelve-chars", "root", RoleAdmin, ""},
|
|
{"runes not bytes", "пароль-ок", "alice", RoleUser, ""},
|
|
{"same as username", "Alexander", "alexander", RoleUser, CodePasswordSameAsUsername},
|
|
{"common", "Password123", "alice", RoleUser, CodePasswordTooCommon},
|
|
{"common cyrillic", "пароль123", "alice", RoleUser, CodePasswordTooCommon},
|
|
{"too long", strings.Repeat("a", MaxPassword+1), "alice", RoleUser, CodePasswordTooLong},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
err := ValidatePassword("new_password", tc.password, tc.username, tc.role)
|
|
if tc.code == "" {
|
|
assert.NoError(t, err)
|
|
return
|
|
}
|
|
var ae *apperr.Error
|
|
require.ErrorAs(t, err, &ae)
|
|
assert.Equal(t, tc.code, ae.Code)
|
|
assert.Equal(t, "new_password", ae.Fields[0].Field)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestValidateUsername(t *testing.T) {
|
|
for _, ok := range []string{"abc", "john.doe", "a_b-c", strings.Repeat("a", 32)} {
|
|
assert.NoError(t, ValidateUsername("username", ok), ok)
|
|
}
|
|
for _, bad := range []string{"ab", "John", "имя", "a b", "a@b", strings.Repeat("a", 33)} {
|
|
assert.Error(t, ValidateUsername("username", bad), bad)
|
|
}
|
|
assert.Equal(t, "john", NormalizeUsername(" John "))
|
|
}
|
|
|
|
func TestParsePrivateKey(t *testing.T) {
|
|
seed := "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
|
|
k1, err := ParsePrivateKey(seed)
|
|
require.NoError(t, err)
|
|
k2, err := ParsePrivateKey(strings.TrimRight(strings.NewReplacer("+", "-", "/", "_").Replace(seed), "="))
|
|
require.NoError(t, err)
|
|
assert.Equal(t, k1, k2)
|
|
k3, err := ParsePrivateKey(b64std(k1))
|
|
require.NoError(t, err)
|
|
assert.Equal(t, k1, k3, "64-byte key form")
|
|
_, err = ParsePrivateKey("c2hvcnQ=")
|
|
assert.Error(t, err)
|
|
_, err = ParsePrivateKey("not base64 !!")
|
|
assert.Error(t, err)
|
|
}
|
|
|
|
func b64std(b []byte) string { return base64.StdEncoding.EncodeToString(b) }
|