feat(api): sign-in, refresh rotation and bootstrap admin (M1)
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
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>
This commit is contained in:
1 parent
b6def39e29
commit
ed4a492dba
37 files changed
+5826
-49
No files matched your search
@@ -0,0 +1,569 @@
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"pipu/api/internal/auth"
|
||||
"pipu/api/internal/auth/authtest"
|
||||
"pipu/api/internal/server"
|
||||
"pipu/api/internal/system"
|
||||
)
|
||||
|
||||
// Быстрые параметры argon2: тестам важна логика, а не стойкость.
|
||||
var testArgon = auth.Argon2Params{MemoryKiB: 64, Time: 1, Threads: 1, SaltLen: 16, KeyLen: 32}
|
||||
|
||||
type env struct {
|
||||
t *testing.T
|
||||
store *authtest.MemStore
|
||||
clock *authtest.Clock
|
||||
limiter *authtest.Limiter
|
||||
hasher *auth.Hasher
|
||||
svc *auth.Service
|
||||
h http.Handler
|
||||
}
|
||||
|
||||
func newEnv(t *testing.T, mutate ...func(*auth.Config)) *env {
|
||||
t.Helper()
|
||||
e := &env{
|
||||
t: t,
|
||||
store: authtest.NewMemStore(),
|
||||
clock: &authtest.Clock{T: time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)},
|
||||
limiter: &authtest.Limiter{},
|
||||
hasher: auth.NewHasher(testArgon),
|
||||
}
|
||||
key, err := auth.GenerateKey()
|
||||
require.NoError(t, err)
|
||||
cfg := auth.DefaultConfig()
|
||||
cfg.BootstrapPassword = ""
|
||||
for _, m := range mutate {
|
||||
m(&cfg)
|
||||
}
|
||||
e.svc, err = auth.NewService(e.store, e.hasher, auth.NewTokenIssuer(key, nil, 15*time.Minute, e.clock.Now),
|
||||
e.limiter, e.clock, auth.UUIDv7{}, nil, cfg)
|
||||
require.NoError(t, err)
|
||||
sys := system.NewHandler(system.Info{Version: "test", BootstrapRequired: e.svc.BootstrapRequired}, nil, nil)
|
||||
e.h = server.NewRouter(server.Deps{System: sys, Auth: auth.NewHandler(e.svc, true), AuthService: e.svc})
|
||||
return e
|
||||
}
|
||||
|
||||
// addUser создаёт пользователя напрямую в хранилище.
|
||||
func (e *env) addUser(username, password string, role auth.Role, mutate ...func(*auth.User)) auth.User {
|
||||
e.t.Helper()
|
||||
hash, err := e.hasher.Hash(password)
|
||||
require.NoError(e.t, err)
|
||||
u := auth.User{ID: uuid.New(), Username: username, PasswordHash: hash, Role: role, Locale: "ru", CreatedAt: e.clock.Now()}
|
||||
for _, m := range mutate {
|
||||
m(&u)
|
||||
}
|
||||
require.NoError(e.t, e.store.CreateUser(context.Background(), u, nil))
|
||||
return u
|
||||
}
|
||||
|
||||
type resp struct {
|
||||
*httptest.ResponseRecorder
|
||||
body map[string]any
|
||||
}
|
||||
|
||||
func (r resp) code() string { s, _ := r.body["code"].(string); return s }
|
||||
|
||||
func (r resp) cookie(name string) *http.Cookie {
|
||||
for _, c := range r.Result().Cookies() {
|
||||
if c.Name == name {
|
||||
return c
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type reqOpt func(*http.Request)
|
||||
|
||||
func bearer(token string) reqOpt {
|
||||
return func(r *http.Request) { r.Header.Set("Authorization", "Bearer "+token) }
|
||||
}
|
||||
|
||||
func withCookie(c *http.Cookie) reqOpt { return func(r *http.Request) { r.AddCookie(c) } }
|
||||
|
||||
func (e *env) do(method, path string, body any, opts ...reqOpt) resp {
|
||||
e.t.Helper()
|
||||
var rd *bytes.Reader
|
||||
if body != nil {
|
||||
raw, err := json.Marshal(body)
|
||||
require.NoError(e.t, err)
|
||||
rd = bytes.NewReader(raw)
|
||||
} else {
|
||||
rd = bytes.NewReader(nil)
|
||||
}
|
||||
req := httptest.NewRequest(method, path, rd)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
req.RemoteAddr = "192.0.2.10:5555"
|
||||
for _, o := range opts {
|
||||
o(req)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
e.h.ServeHTTP(rec, req)
|
||||
out := resp{ResponseRecorder: rec}
|
||||
if rec.Body.Len() > 0 {
|
||||
require.NoError(e.t, json.Unmarshal(rec.Body.Bytes(), &out.body), rec.Body.String())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (e *env) login(username, password, clientType string) resp {
|
||||
e.t.Helper()
|
||||
return e.do(http.MethodPost, "/api/v1/auth/login", map[string]any{
|
||||
"username": username, "password": password, "client_type": clientType, "device_name": "Test device",
|
||||
})
|
||||
}
|
||||
|
||||
func (e *env) mustLogin(username, password, clientType string) resp {
|
||||
e.t.Helper()
|
||||
r := e.login(username, password, clientType)
|
||||
require.Equal(e.t, http.StatusOK, r.Code, r.Body.String())
|
||||
return r
|
||||
}
|
||||
|
||||
func access(r resp) string { return r.body["access_token"].(string) }
|
||||
|
||||
func user(r resp) map[string]any { return r.body["user"].(map[string]any) }
|
||||
|
||||
func TestBootstrapFlow(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
ctx := context.Background()
|
||||
require.NoError(t, e.svc.Bootstrap(ctx))
|
||||
require.NoError(t, e.svc.Bootstrap(ctx), "second bootstrap is a no-op")
|
||||
require.Len(t, e.store.Users, 1)
|
||||
|
||||
info := e.do(http.MethodGet, "/api/v1/server-info", nil)
|
||||
assert.Equal(t, true, info.body["bootstrap_required"])
|
||||
|
||||
// Сгенерированный пароль неизвестен тесту — подменяем хэш известным паролем.
|
||||
var admin auth.User
|
||||
for _, u := range e.store.Users {
|
||||
admin = u
|
||||
}
|
||||
assert.Equal(t, "admin", admin.Username)
|
||||
assert.Equal(t, auth.RoleAdmin, admin.Role)
|
||||
assert.True(t, admin.MustChangeCredentials)
|
||||
admin.PasswordHash, _ = e.hasher.Hash("initial-secret-pass")
|
||||
e.store.Users[admin.ID] = admin
|
||||
|
||||
r := e.mustLogin("admin", "initial-secret-pass", "web")
|
||||
assert.Equal(t, true, user(r)["must_change_credentials"])
|
||||
tok := access(r)
|
||||
|
||||
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/me", nil, bearer(tok)).Code)
|
||||
blocked := e.do(http.MethodGet, "/api/v1/me/sessions", nil, bearer(tok))
|
||||
assert.Equal(t, http.StatusForbidden, blocked.Code)
|
||||
assert.Equal(t, auth.CodeCredentialsChangeRequired, blocked.code())
|
||||
assert.Equal(t, auth.CodeCredentialsChangeRequired,
|
||||
e.do(http.MethodPost, "/api/v1/me/password", map[string]any{"new_password": "whatever-long-pass"}, bearer(tok)).code())
|
||||
|
||||
short := e.do(http.MethodPost, "/api/v1/me/bootstrap-credentials",
|
||||
map[string]any{"new_username": "owner", "new_password": "short"}, bearer(tok))
|
||||
assert.Equal(t, http.StatusUnprocessableEntity, short.Code)
|
||||
assert.Equal(t, auth.CodePasswordTooShort, short.code())
|
||||
assert.EqualValues(t, 12, short.body["params"].(map[string]any)["min"])
|
||||
|
||||
same := e.do(http.MethodPost, "/api/v1/me/bootstrap-credentials",
|
||||
map[string]any{"new_username": "owner", "new_password": "initial-secret-pass"}, bearer(tok))
|
||||
assert.Equal(t, auth.CodePasswordUnchanged, same.code())
|
||||
|
||||
badName := e.do(http.MethodPost, "/api/v1/me/bootstrap-credentials",
|
||||
map[string]any{"new_username": "x", "new_password": "a-much-better-secret"}, bearer(tok))
|
||||
assert.Equal(t, auth.CodeUsernameInvalid, badName.code())
|
||||
|
||||
ok := e.do(http.MethodPost, "/api/v1/me/bootstrap-credentials",
|
||||
map[string]any{"new_username": " Owner ", "new_password": "a-much-better-secret"}, bearer(tok))
|
||||
require.Equal(t, http.StatusOK, ok.Code, ok.Body.String())
|
||||
assert.Equal(t, "owner", user(ok)["username"])
|
||||
assert.Equal(t, false, user(ok)["must_change_credentials"])
|
||||
assert.NotNil(t, ok.cookie(auth.RefreshCookie))
|
||||
|
||||
old := e.do(http.MethodGet, "/api/v1/me", nil, bearer(tok))
|
||||
assert.Equal(t, http.StatusUnauthorized, old.Code)
|
||||
assert.Equal(t, auth.CodeTokenRevoked, old.code())
|
||||
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/me/sessions", nil, bearer(access(ok))).Code)
|
||||
|
||||
assert.Equal(t, false, e.do(http.MethodGet, "/api/v1/server-info", nil).body["bootstrap_required"])
|
||||
again := e.do(http.MethodPost, "/api/v1/me/bootstrap-credentials",
|
||||
map[string]any{"new_username": "owner", "new_password": "another-better-secret"}, bearer(access(ok)))
|
||||
assert.Equal(t, http.StatusConflict, again.Code)
|
||||
|
||||
actions := []string{}
|
||||
for _, a := range e.store.Audit {
|
||||
actions = append(actions, a.Action)
|
||||
}
|
||||
assert.Equal(t, []string{"user.bootstrap_create", "user.bootstrap_credentials"}, actions)
|
||||
|
||||
// После завершения первого запуска удаление всех админов не создаёт нового автоматически.
|
||||
delete(e.store.Users, admin.ID)
|
||||
require.NoError(t, e.svc.Bootstrap(ctx))
|
||||
assert.Empty(t, e.store.Users)
|
||||
}
|
||||
|
||||
func TestBootstrapWithConfiguredPassword(t *testing.T) {
|
||||
e := newEnv(t, func(c *auth.Config) {
|
||||
c.BootstrapUsername = "Root"
|
||||
c.BootstrapPassword = "configured-admin-pass"
|
||||
})
|
||||
require.NoError(t, e.svc.Bootstrap(context.Background()))
|
||||
r := e.mustLogin("root", "configured-admin-pass", "android")
|
||||
assert.Equal(t, "root", user(r)["username"])
|
||||
}
|
||||
|
||||
func TestBootstrapSkippedWhenAdminExists(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.addUser("boss", "boss-password-123", auth.RoleAdmin)
|
||||
require.NoError(t, e.svc.Bootstrap(context.Background()))
|
||||
assert.Len(t, e.store.Users, 1)
|
||||
}
|
||||
|
||||
func TestLoginErrorsAreIndistinguishable(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.addUser("alice", "alice-password", auth.RoleUser)
|
||||
|
||||
wrong := e.login("alice", "nope-nope-nope", "web")
|
||||
unknown := e.login("bob", "nope-nope-nope", "web")
|
||||
for _, r := range []resp{wrong, unknown} {
|
||||
assert.Equal(t, http.StatusUnauthorized, r.Code)
|
||||
assert.Equal(t, auth.CodeInvalidCredentials, r.code())
|
||||
}
|
||||
assert.Equal(t, http.StatusOK, e.login("ALICE", "alice-password", "web").Code, "username is case-insensitive")
|
||||
|
||||
bad := e.do(http.MethodPost, "/api/v1/auth/login",
|
||||
map[string]any{"username": "alice", "password": "alice-password", "client_type": "ios"})
|
||||
assert.Equal(t, http.StatusUnprocessableEntity, bad.Code)
|
||||
}
|
||||
|
||||
func TestLoginLockout(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
u := e.addUser("alice", "alice-password", auth.RoleUser)
|
||||
|
||||
for range 5 {
|
||||
assert.Equal(t, auth.CodeInvalidCredentials, e.login("alice", "wrong-password", "web").code())
|
||||
e.limiter.Reset()
|
||||
}
|
||||
locked := e.store.Users[u.ID]
|
||||
require.NotNil(t, locked.LockedUntil)
|
||||
assert.Equal(t, e.clock.Now().Add(5*time.Minute), *locked.LockedUntil)
|
||||
|
||||
assert.Equal(t, auth.CodeInvalidCredentials, e.login("alice", "alice-password", "web").code(),
|
||||
"correct password is rejected while locked")
|
||||
assert.Equal(t, 5, e.store.Users[u.ID].FailedLoginCount, "attempts while locked are not counted")
|
||||
|
||||
e.clock.Advance(5 * time.Minute)
|
||||
assert.Equal(t, auth.CodeInvalidCredentials, e.login("alice", "wrong-password", "web").code())
|
||||
assert.Equal(t, e.clock.Now().Add(10*time.Minute), *e.store.Users[u.ID].LockedUntil, "lock doubles")
|
||||
|
||||
e.clock.Advance(10 * time.Minute)
|
||||
e.limiter.Reset()
|
||||
e.mustLogin("alice", "alice-password", "web")
|
||||
assert.Zero(t, e.store.Users[u.ID].FailedLoginCount)
|
||||
assert.Nil(t, e.store.Users[u.ID].LockedUntil)
|
||||
}
|
||||
|
||||
func TestLockDurationIsCapped(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
u := e.addUser("alice", "alice-password", auth.RoleUser, func(u *auth.User) { u.FailedLoginCount = 40 })
|
||||
e.login("alice", "wrong-password", "web")
|
||||
assert.Equal(t, e.clock.Now().Add(time.Hour), *e.store.Users[u.ID].LockedUntil)
|
||||
}
|
||||
|
||||
func TestLoginRateLimit(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.addUser("alice", "alice-password", auth.RoleUser)
|
||||
for range 5 {
|
||||
e.login("alice", "wrong-password", "web")
|
||||
}
|
||||
r := e.login("alice", "alice-password", "web")
|
||||
assert.Equal(t, http.StatusTooManyRequests, r.Code)
|
||||
assert.Equal(t, auth.CodeTooManyAttempts, r.code())
|
||||
assert.Equal(t, "60", r.Header().Get("Retry-After"))
|
||||
}
|
||||
|
||||
func TestBlockedUserCannotLoginOrUseTokens(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
u := e.addUser("alice", "alice-password", auth.RoleUser)
|
||||
tok := access(e.mustLogin("alice", "alice-password", "web"))
|
||||
|
||||
blocked := e.store.Users[u.ID]
|
||||
blocked.IsBlocked = true
|
||||
e.store.Users[u.ID] = blocked
|
||||
|
||||
assert.Equal(t, auth.CodeUserBlocked, e.do(http.MethodGet, "/api/v1/me", nil, bearer(tok)).code())
|
||||
r := e.login("alice", "alice-password", "web")
|
||||
assert.Equal(t, http.StatusForbidden, r.Code)
|
||||
assert.Equal(t, auth.CodeUserBlocked, r.code())
|
||||
assert.Equal(t, auth.CodeInvalidCredentials, e.login("alice", "wrong-password", "web").code(),
|
||||
"blocked status is not revealed without the password")
|
||||
}
|
||||
|
||||
func TestWebRefreshRotation(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.addUser("alice", "alice-password", auth.RoleUser)
|
||||
r := e.mustLogin("alice", "alice-password", "web")
|
||||
assert.Nil(t, r.body["refresh_token"], "web never gets refresh token in the body")
|
||||
c1 := r.cookie(auth.RefreshCookie)
|
||||
require.NotNil(t, c1)
|
||||
assert.True(t, c1.HttpOnly)
|
||||
assert.True(t, c1.Secure)
|
||||
assert.Equal(t, http.SameSiteStrictMode, c1.SameSite)
|
||||
assert.Equal(t, "/api/v1/auth", c1.Path)
|
||||
|
||||
e.clock.Advance(time.Minute)
|
||||
r2 := e.do(http.MethodPost, "/api/v1/auth/refresh", nil, withCookie(c1))
|
||||
require.Equal(t, http.StatusOK, r2.Code, r2.Body.String())
|
||||
c2 := r2.cookie(auth.RefreshCookie)
|
||||
require.NotNil(t, c2)
|
||||
assert.NotEqual(t, c1.Value, c2.Value)
|
||||
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/me", nil, bearer(access(r2))).Code)
|
||||
|
||||
// Вторая вкладка прислала старую cookie сразу после ротации — это гонка, а не кража.
|
||||
race := e.do(http.MethodPost, "/api/v1/auth/refresh", nil, withCookie(c1))
|
||||
assert.Equal(t, auth.CodeRefreshRace, race.code())
|
||||
assert.Nil(t, race.cookie(auth.RefreshCookie), "race keeps the fresh cookie")
|
||||
|
||||
// Позже старый токен — признак кражи: отзывается вся семья, в том числе новый токен.
|
||||
e.clock.Advance(time.Minute)
|
||||
reuse := e.do(http.MethodPost, "/api/v1/auth/refresh", nil, withCookie(c1))
|
||||
assert.Equal(t, http.StatusUnauthorized, reuse.Code)
|
||||
assert.Equal(t, auth.CodeRefreshReused, reuse.code())
|
||||
cleared := reuse.cookie(auth.RefreshCookie)
|
||||
require.NotNil(t, cleared)
|
||||
assert.Equal(t, -1, cleared.MaxAge)
|
||||
assert.Equal(t, auth.CodeRefreshInvalid, e.do(http.MethodPost, "/api/v1/auth/refresh", nil, withCookie(c2)).code())
|
||||
}
|
||||
|
||||
func TestAndroidRefreshInBody(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.addUser("alice", "alice-password", auth.RoleUser)
|
||||
r := e.mustLogin("alice", "alice-password", "android")
|
||||
assert.Nil(t, r.cookie(auth.RefreshCookie))
|
||||
rt, ok := r.body["refresh_token"].(string)
|
||||
require.True(t, ok)
|
||||
|
||||
r2 := e.do(http.MethodPost, "/api/v1/auth/refresh", map[string]any{"refresh_token": rt})
|
||||
require.Equal(t, http.StatusOK, r2.Code, r2.Body.String())
|
||||
assert.NotEqual(t, rt, r2.body["refresh_token"])
|
||||
|
||||
assert.Equal(t, auth.CodeRefreshInvalid,
|
||||
e.do(http.MethodPost, "/api/v1/auth/refresh", map[string]any{"refresh_token": "garbage"}).code())
|
||||
assert.Equal(t, auth.CodeRefreshInvalid, e.do(http.MethodPost, "/api/v1/auth/refresh", nil).code())
|
||||
}
|
||||
|
||||
func TestRefreshExpiry(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.addUser("alice", "alice-password", auth.RoleUser)
|
||||
rt := e.mustLogin("alice", "alice-password", "android").body["refresh_token"].(string)
|
||||
|
||||
// Скользящий срок: каждые 29 дней ротация продлевает, но не дальше 180 дней от входа.
|
||||
for range 6 {
|
||||
e.clock.Advance(29 * 24 * time.Hour)
|
||||
r := e.do(http.MethodPost, "/api/v1/auth/refresh", map[string]any{"refresh_token": rt})
|
||||
require.Equal(t, http.StatusOK, r.Code, r.Body.String())
|
||||
rt = r.body["refresh_token"].(string)
|
||||
}
|
||||
e.clock.Advance(7 * 24 * time.Hour) // 181 день от входа
|
||||
assert.Equal(t, auth.CodeRefreshInvalid,
|
||||
e.do(http.MethodPost, "/api/v1/auth/refresh", map[string]any{"refresh_token": rt}).code())
|
||||
|
||||
rt2 := e.mustLogin("alice", "alice-password", "android").body["refresh_token"].(string)
|
||||
e.clock.Advance(31 * 24 * time.Hour)
|
||||
assert.Equal(t, auth.CodeRefreshInvalid,
|
||||
e.do(http.MethodPost, "/api/v1/auth/refresh", map[string]any{"refresh_token": rt2}).code())
|
||||
}
|
||||
|
||||
func TestAccessTokenErrors(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.addUser("alice", "alice-password", auth.RoleUser)
|
||||
tok := access(e.mustLogin("alice", "alice-password", "web"))
|
||||
|
||||
assert.Equal(t, auth.CodeUnauthorized, e.do(http.MethodGet, "/api/v1/me", nil).code())
|
||||
assert.Equal(t, auth.CodeTokenInvalid, e.do(http.MethodGet, "/api/v1/me", nil, bearer(tok+"x")).code())
|
||||
assert.Equal(t, auth.CodeUnauthorized, e.do(http.MethodGet, "/api/v1/me", nil,
|
||||
func(r *http.Request) { r.Header.Set("Authorization", "Basic abc") }).code())
|
||||
|
||||
e.clock.Advance(16 * time.Minute)
|
||||
expired := e.do(http.MethodGet, "/api/v1/me", nil, bearer(tok))
|
||||
assert.Equal(t, http.StatusUnauthorized, expired.Code)
|
||||
assert.Equal(t, auth.CodeTokenExpired, expired.code())
|
||||
}
|
||||
|
||||
func TestTokenSignedByPreviousKey(t *testing.T) {
|
||||
clock := &authtest.Clock{T: time.Now()}
|
||||
oldKey, _ := auth.GenerateKey()
|
||||
newKey, _ := auth.GenerateKey()
|
||||
old := auth.NewTokenIssuer(oldKey, nil, time.Minute, clock.Now)
|
||||
rotated := auth.NewTokenIssuer(newKey, []ed25519.PrivateKey{oldKey}, time.Minute, clock.Now)
|
||||
other := auth.NewTokenIssuer(newKey, nil, time.Minute, clock.Now)
|
||||
|
||||
tok, err := old.Issue(uuid.New(), auth.RoleUser, uuid.New(), 3)
|
||||
require.NoError(t, err)
|
||||
c, err := rotated.Parse(tok)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 3, c.TokenVersion)
|
||||
_, err = other.Parse(tok)
|
||||
assert.ErrorIs(t, err, auth.ErrTokenInvalid)
|
||||
}
|
||||
|
||||
func TestChangePassword(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.addUser("alice", "alice-password", auth.RoleUser)
|
||||
web := e.mustLogin("alice", "alice-password", "web")
|
||||
phone := e.mustLogin("alice", "alice-password", "android")
|
||||
tok := access(web)
|
||||
|
||||
missing := e.do(http.MethodPost, "/api/v1/me/password", map[string]any{"new_password": "brand-new-pass"}, bearer(tok))
|
||||
assert.Equal(t, auth.CodeCurrentPasswordRequired, missing.code())
|
||||
wrong := e.do(http.MethodPost, "/api/v1/me/password",
|
||||
map[string]any{"current_password": "nope", "new_password": "brand-new-pass"}, bearer(tok))
|
||||
assert.Equal(t, auth.CodeCurrentPasswordInvalid, wrong.code())
|
||||
common := e.do(http.MethodPost, "/api/v1/me/password",
|
||||
map[string]any{"current_password": "alice-password", "new_password": "Password123"}, bearer(tok))
|
||||
assert.Equal(t, auth.CodePasswordTooCommon, common.code())
|
||||
|
||||
ok := e.do(http.MethodPost, "/api/v1/me/password",
|
||||
map[string]any{"current_password": "alice-password", "new_password": "brand-new-pass"}, bearer(tok))
|
||||
require.Equal(t, http.StatusOK, ok.Code, ok.Body.String())
|
||||
assert.NotNil(t, ok.cookie(auth.RefreshCookie), "current web device keeps a session")
|
||||
|
||||
assert.Equal(t, auth.CodeTokenRevoked, e.do(http.MethodGet, "/api/v1/me", nil, bearer(tok)).code())
|
||||
assert.Equal(t, auth.CodeRefreshInvalid, e.do(http.MethodPost, "/api/v1/auth/refresh",
|
||||
map[string]any{"refresh_token": phone.body["refresh_token"]}).code(), "other devices are signed out")
|
||||
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/me", nil, bearer(access(ok))).Code)
|
||||
|
||||
assert.Equal(t, auth.CodeInvalidCredentials, e.login("alice", "alice-password", "web").code())
|
||||
e.mustLogin("alice", "brand-new-pass", "web")
|
||||
}
|
||||
|
||||
func TestTemporaryPasswordMustBeChanged(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.addUser("bob", "temp-pass-123", auth.RoleUser, func(u *auth.User) { u.MustChangePassword = true })
|
||||
r := e.mustLogin("bob", "temp-pass-123", "android")
|
||||
tok := access(r)
|
||||
assert.Equal(t, true, user(r)["must_change_password"])
|
||||
|
||||
denied := e.do(http.MethodGet, "/api/v1/me/sessions", nil, bearer(tok))
|
||||
assert.Equal(t, http.StatusForbidden, denied.Code)
|
||||
assert.Equal(t, auth.CodePasswordChangeRequired, denied.code())
|
||||
|
||||
ok := e.do(http.MethodPost, "/api/v1/me/password", map[string]any{"new_password": "bobs-own-pass"}, bearer(tok))
|
||||
require.Equal(t, http.StatusOK, ok.Code, ok.Body.String())
|
||||
assert.Equal(t, false, user(ok)["must_change_password"])
|
||||
assert.NotEmpty(t, ok.body["refresh_token"], "android device gets refresh token in the body")
|
||||
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/me/sessions", nil, bearer(access(ok))).Code)
|
||||
}
|
||||
|
||||
func TestPrivilegedPasswordLength(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.addUser("mod", "moderator-pass-1", auth.RoleModerator)
|
||||
tok := access(e.mustLogin("mod", "moderator-pass-1", "web"))
|
||||
r := e.do(http.MethodPost, "/api/v1/me/password",
|
||||
map[string]any{"current_password": "moderator-pass-1", "new_password": "eleven-char"}, bearer(tok))
|
||||
assert.Equal(t, auth.CodePasswordTooShort, r.code())
|
||||
assert.EqualValues(t, 12, r.body["params"].(map[string]any)["min"])
|
||||
assert.Equal(t, []any{map[string]any{"field": "new_password", "code": auth.CodePasswordTooShort}}, r.body["errors"])
|
||||
}
|
||||
|
||||
func TestUpdateMe(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.addUser("alice", "alice-password", auth.RoleUser)
|
||||
tok := access(e.mustLogin("alice", "alice-password", "web"))
|
||||
|
||||
r := e.do(http.MethodPatch, "/api/v1/me", map[string]any{
|
||||
"display_name": " Alice ", "locale": "en", "ui_prefs": map[string]any{"theme": "light", "crossfade_s": 3},
|
||||
}, bearer(tok))
|
||||
require.Equal(t, http.StatusOK, r.Code, r.Body.String())
|
||||
assert.Equal(t, "Alice", r.body["display_name"])
|
||||
assert.Equal(t, "en", r.body["locale"])
|
||||
|
||||
r = e.do(http.MethodPatch, "/api/v1/me", map[string]any{"ui_prefs": map[string]any{"wave_variant": "liquid"}}, bearer(tok))
|
||||
assert.Equal(t, map[string]any{"theme": "light", "crossfade_s": float64(3), "wave_variant": "liquid"}, r.body["ui_prefs"],
|
||||
"ui_prefs are merged")
|
||||
assert.Equal(t, "Alice", r.body["display_name"], "absent fields are unchanged")
|
||||
|
||||
r = e.do(http.MethodPatch, "/api/v1/me", map[string]any{"display_name": ""}, bearer(tok))
|
||||
assert.Nil(t, r.body["display_name"])
|
||||
|
||||
assert.Equal(t, auth.CodeLocaleUnsupported,
|
||||
e.do(http.MethodPatch, "/api/v1/me", map[string]any{"locale": "de"}, bearer(tok)).code())
|
||||
assert.Equal(t, auth.CodeUIPrefsInvalid,
|
||||
e.do(http.MethodPatch, "/api/v1/me", map[string]any{"ui_prefs": map[string]any{"crossfade_s": 9}}, bearer(tok)).code())
|
||||
assert.Equal(t, auth.CodeDisplayNameTooLong,
|
||||
e.do(http.MethodPatch, "/api/v1/me", map[string]any{"display_name": strings.Repeat("я", 65)}, bearer(tok)).code())
|
||||
}
|
||||
|
||||
func TestSessions(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.addUser("alice", "alice-password", auth.RoleUser)
|
||||
web := e.mustLogin("alice", "alice-password", "web")
|
||||
e.clock.Advance(time.Second)
|
||||
phone := e.mustLogin("alice", "alice-password", "android")
|
||||
tok := access(web)
|
||||
|
||||
list := e.do(http.MethodGet, "/api/v1/me/sessions", nil, bearer(tok))
|
||||
items := list.body["items"].([]any)
|
||||
require.Len(t, items, 2)
|
||||
first, second := items[0].(map[string]any), items[1].(map[string]any)
|
||||
assert.Equal(t, "android", first["client_type"], "newest first")
|
||||
assert.Equal(t, false, first["current"])
|
||||
assert.Equal(t, true, second["current"])
|
||||
assert.Equal(t, "Test device", second["device_name"])
|
||||
|
||||
assert.Equal(t, auth.CodeSessionNotFound,
|
||||
e.do(http.MethodDelete, "/api/v1/me/sessions/"+uuid.NewString(), nil, bearer(tok)).code())
|
||||
del := e.do(http.MethodDelete, "/api/v1/me/sessions/"+first["id"].(string), nil, bearer(tok))
|
||||
assert.Equal(t, http.StatusNoContent, del.Code)
|
||||
assert.Equal(t, auth.CodeRefreshInvalid, e.do(http.MethodPost, "/api/v1/auth/refresh",
|
||||
map[string]any{"refresh_token": phone.body["refresh_token"]}).code())
|
||||
assert.Len(t, e.do(http.MethodGet, "/api/v1/me/sessions", nil, bearer(tok)).body["items"], 1)
|
||||
|
||||
// Чужую сессию завершить нельзя.
|
||||
e.addUser("bob", "bob-password", auth.RoleUser)
|
||||
bob := access(e.mustLogin("bob", "bob-password", "web"))
|
||||
assert.Equal(t, auth.CodeSessionNotFound,
|
||||
e.do(http.MethodDelete, "/api/v1/me/sessions/"+second["id"].(string), nil, bearer(bob)).code())
|
||||
}
|
||||
|
||||
func TestLogout(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.addUser("alice", "alice-password", auth.RoleUser)
|
||||
web := e.mustLogin("alice", "alice-password", "web")
|
||||
phone := e.mustLogin("alice", "alice-password", "android")
|
||||
|
||||
out := e.do(http.MethodPost, "/api/v1/auth/logout", nil, bearer(access(web)))
|
||||
assert.Equal(t, http.StatusNoContent, out.Code)
|
||||
require.NotNil(t, out.cookie(auth.RefreshCookie))
|
||||
assert.Equal(t, -1, out.cookie(auth.RefreshCookie).MaxAge)
|
||||
assert.Equal(t, auth.CodeRefreshInvalid,
|
||||
e.do(http.MethodPost, "/api/v1/auth/refresh", nil, withCookie(web.cookie(auth.RefreshCookie))).code())
|
||||
|
||||
rt := phone.body["refresh_token"]
|
||||
ok := e.do(http.MethodPost, "/api/v1/auth/refresh", map[string]any{"refresh_token": rt})
|
||||
require.Equal(t, http.StatusOK, ok.Code, "logout touches only the current session")
|
||||
|
||||
assert.Equal(t, http.StatusNoContent, e.do(http.MethodPost, "/api/v1/auth/logout-all", nil, bearer(access(ok))).Code)
|
||||
assert.Equal(t, auth.CodeRefreshInvalid,
|
||||
e.do(http.MethodPost, "/api/v1/auth/refresh", map[string]any{"refresh_token": ok.body["refresh_token"]}).code())
|
||||
}
|
||||
|
||||
func TestPublicRoutes(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/server-info", nil).Code)
|
||||
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/healthz", nil).Code)
|
||||
assert.Equal(t, http.StatusUnauthorized, e.do(http.MethodGet, "/api/v1/unknown", nil).Code)
|
||||
}
|
||||
Reference in new issue
Block a user