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

- 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:
mrleo1nidandClaude Opus 5.5 committed 2026-10-20 06:12:25 +03:00
1 parent b6def39e29
commit ed4a492dba
37 files changed
+5826 -49

No files matched your search

+58 -1
View File
@@ -3,13 +3,17 @@ package main
import (
"context"
"crypto/ed25519"
"fmt"
"log/slog"
"os"
"os/signal"
"syscall"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/redis/go-redis/v9"
"pipu/api/internal/auth"
"pipu/api/internal/buildinfo"
"pipu/api/internal/platform/config"
"pipu/api/internal/platform/db"
@@ -56,16 +60,69 @@ func run() error {
rdb := redis.NewClient(redisOpts)
defer func() { _ = rdb.Close() }()
authSvc, err := newAuthService(cfg, pool, rdb, log)
if err != nil {
return err
}
if err := authSvc.Bootstrap(ctx); err != nil {
return err
}
sys := system.NewHandler(system.Info{
Version: buildinfo.Version,
SupportedLocales: cfg.SupportedLocales,
MinAndroidVersionCode: cfg.MinAndroidVersionCode,
BootstrapRequired: authSvc.BootstrapRequired,
}, log, map[string]system.Checker{
"postgres": system.CheckerFunc(pool.Ping),
"redis": system.CheckerFunc(func(ctx context.Context) error { return rdb.Ping(ctx).Err() }),
})
srv := httpx.NewServer(cfg.HTTPAddr, server.NewRouter(server.Deps{Log: log, System: sys}))
srv := httpx.NewServer(cfg.HTTPAddr, server.NewRouter(server.Deps{
Log: log,
System: sys,
Auth: auth.NewHandler(authSvc, cfg.CookieSecure),
AuthService: authSvc,
}))
log.Info("starting", "version", buildinfo.Version)
return httpx.Serve(ctx, log, srv, cfg.ShutdownTimeout)
}
func newAuthService(cfg config.API, pool *pgxpool.Pool, rdb *redis.Client, log *slog.Logger) (*auth.Service, error) {
var (
key ed25519.PrivateKey
err error
)
if cfg.JWTPrivateKey == "" {
log.Warn("JWT_PRIVATE_KEY is not set: using a random key, access tokens will not survive a restart")
key, err = auth.GenerateKey()
} else {
key, err = auth.ParsePrivateKey(cfg.JWTPrivateKey)
}
if err != nil {
return nil, err
}
var previous []ed25519.PrivateKey
for _, s := range cfg.JWTPreviousKeys {
if s == "" {
continue
}
k, err := auth.ParsePrivateKey(s)
if err != nil {
return nil, fmt.Errorf("JWT_PREVIOUS_KEYS: %w", err)
}
previous = append(previous, k)
}
clock := auth.SystemClock{}
acfg := auth.DefaultConfig()
acfg.BootstrapUsername = cfg.BootstrapAdminUsername
acfg.BootstrapPassword = cfg.BootstrapAdminPassword
acfg.SupportedLocales = cfg.SupportedLocales
return auth.NewService(
auth.NewPgStore(pool),
auth.NewHasher(auth.DefaultArgon2Params),
auth.NewTokenIssuer(key, previous, cfg.AccessTokenTTL, clock.Now),
auth.NewRedisLimiter(rdb),
clock, auth.UUIDv7{}, log, acfg,
)
}
+5 -1
View File
@@ -8,14 +8,18 @@ require (
github.com/caarlos0/env/v11 v11.3.1
github.com/getkin/kin-openapi v0.127.0
github.com/go-chi/chi/v5 v5.2.2
github.com/golang-jwt/jwt/v5 v5.2.2
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.7.5
github.com/oapi-codegen/runtime v1.1.1
github.com/pressly/goose/v3 v3.24.3
github.com/redis/go-redis/v9 v9.10.0
github.com/stretchr/testify v1.10.0
golang.org/x/crypto v0.38.0
)
require (
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
@@ -37,9 +41,9 @@ require (
github.com/speakeasy-api/openapi-overlay v0.9.0 // indirect
github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/crypto v0.38.0 // indirect
golang.org/x/mod v0.17.0 // indirect
golang.org/x/sync v0.14.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.25.0 // indirect
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
+8
View File
@@ -1,3 +1,7 @@
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
@@ -33,6 +37,8 @@ github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
@@ -65,6 +71,7 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
@@ -120,6 +127,7 @@ github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
github.com/speakeasy-api/openapi-overlay v0.9.0 h1:Wrz6NO02cNlLzx1fB093lBlYxSI54VRhy1aSutx0PQg=
github.com/speakeasy-api/openapi-overlay v0.9.0/go.mod h1:f5FloQrHA7MsxYg9djzMD5h6dxrHjVVByWKh7an8TRc=
github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
File diff suppressed because it is too large. Load diff
+569
View File
@@ -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)
}
@@ -0,0 +1,322 @@
// Package authtest — тестовые реализации зависимостей auth: хранилище в памяти, часы, лимитер.
package authtest
import (
"bytes"
"context"
"encoding/json"
"maps"
"slices"
"strings"
"sync"
"time"
"github.com/google/uuid"
"pipu/api/internal/auth"
)
// MemStore — auth.Store в памяти. WithTx откатывает изменения, если fn вернула ошибку.
type MemStore struct {
mu *sync.Mutex
inTx bool
Users map[uuid.UUID]auth.User
Tokens map[uuid.UUID]auth.RefreshToken
Audit []auth.AuditEntry
Settings map[string]any
}
// NewMemStore создаёт пустое хранилище.
func NewMemStore() *MemStore {
return &MemStore{
mu: &sync.Mutex{},
Users: map[uuid.UUID]auth.User{},
Tokens: map[uuid.UUID]auth.RefreshToken{},
Settings: map[string]any{},
}
}
func (s *MemStore) lock() func() {
if s.inTx {
return func() {}
}
s.mu.Lock()
return s.mu.Unlock
}
// WithTx реализует auth.Store.
func (s *MemStore) WithTx(_ context.Context, fn func(auth.Store) error) error {
unlock := s.lock()
defer unlock()
users, tokens, audit, settings := maps.Clone(s.Users), maps.Clone(s.Tokens), slices.Clone(s.Audit), maps.Clone(s.Settings)
tx := &MemStore{mu: s.mu, inTx: true, Users: s.Users, Tokens: s.Tokens, Audit: s.Audit, Settings: s.Settings}
if err := fn(tx); err != nil {
s.Users, s.Tokens, s.Audit, s.Settings = users, tokens, audit, settings
return err
}
s.Users, s.Tokens, s.Audit, s.Settings = tx.Users, tx.Tokens, tx.Audit, tx.Settings
return nil
}
// CountAdmins реализует auth.Store.
func (s *MemStore) CountAdmins(context.Context) (int, error) {
defer s.lock()()
n := 0
for _, u := range s.Users {
if u.Role == auth.RoleAdmin && !u.IsBlocked {
n++
}
}
return n, nil
}
func (s *MemStore) usernameTaken(username string, except uuid.UUID) bool {
for _, u := range s.Users {
if u.ID != except && strings.EqualFold(u.Username, username) {
return true
}
}
return false
}
// CreateUser реализует auth.Store.
func (s *MemStore) CreateUser(_ context.Context, u auth.User, _ *uuid.UUID) error {
defer s.lock()()
if s.usernameTaken(u.Username, uuid.Nil) {
return auth.ErrUsernameTaken
}
s.Users[u.ID] = cloneUser(u)
return nil
}
// cloneUser копирует ui_prefs через JSON, чтобы тест не делил указатели с хранилищем.
func cloneUser(u auth.User) auth.User {
raw, _ := json.Marshal(u.UIPrefs)
u.UIPrefs = auth.UIPrefs{}
_ = json.Unmarshal(raw, &u.UIPrefs)
return u
}
// GetUserByID реализует auth.Store.
func (s *MemStore) GetUserByID(_ context.Context, id uuid.UUID) (auth.User, error) {
defer s.lock()()
u, ok := s.Users[id]
if !ok {
return auth.User{}, auth.ErrNotFound
}
return cloneUser(u), nil
}
// GetUserByUsername реализует auth.Store.
func (s *MemStore) GetUserByUsername(_ context.Context, username string) (auth.User, error) {
defer s.lock()()
for _, u := range s.Users {
if strings.EqualFold(u.Username, username) {
return cloneUser(u), nil
}
}
return auth.User{}, auth.ErrNotFound
}
func (s *MemStore) update(id uuid.UUID, fn func(*auth.User)) error {
defer s.lock()()
u, ok := s.Users[id]
if !ok {
return auth.ErrNotFound
}
fn(&u)
s.Users[id] = u
return nil
}
// RecordLoginFailure реализует auth.Store.
func (s *MemStore) RecordLoginFailure(_ context.Context, id uuid.UUID, failed int, lockedUntil *time.Time) error {
return s.update(id, func(u *auth.User) { u.FailedLoginCount, u.LockedUntil = failed, lockedUntil })
}
// RecordLoginSuccess реализует auth.Store.
func (s *MemStore) RecordLoginSuccess(_ context.Context, id uuid.UUID, at time.Time, newHash *string) error {
return s.update(id, func(u *auth.User) {
u.FailedLoginCount, u.LockedUntil, u.LastLoginAt = 0, nil, &at
if newHash != nil {
u.PasswordHash = *newHash
}
})
}
// SetPassword реализует auth.Store.
func (s *MemStore) SetPassword(_ context.Context, id uuid.UUID, hash string) error {
return s.update(id, func(u *auth.User) {
u.PasswordHash, u.MustChangePassword = hash, false
u.TokenVersion++
})
}
// SetCredentials реализует auth.Store.
func (s *MemStore) SetCredentials(_ context.Context, id uuid.UUID, username, hash string) error {
unlock := s.lock()
taken := s.usernameTaken(username, id)
unlock()
if taken {
return auth.ErrUsernameTaken
}
return s.update(id, func(u *auth.User) {
u.Username, u.PasswordHash = username, hash
u.MustChangeCredentials, u.MustChangePassword = false, false
u.TokenVersion++
})
}
// UpdateProfile реализует auth.Store.
func (s *MemStore) UpdateProfile(ctx context.Context, id uuid.UUID, upd auth.ProfileUpdate) (auth.User, error) {
err := s.update(id, func(u *auth.User) {
if upd.DisplayName != nil {
u.DisplayName = *upd.DisplayName
}
if upd.Locale != nil {
u.Locale = *upd.Locale
}
if upd.UIPrefs != nil {
u.UIPrefs = *upd.UIPrefs
}
})
if err != nil {
return auth.User{}, err
}
return s.GetUserByID(ctx, id)
}
// InsertRefreshToken реализует auth.Store.
func (s *MemStore) InsertRefreshToken(_ context.Context, t auth.RefreshToken) error {
defer s.lock()()
s.Tokens[t.ID] = t
return nil
}
// GetRefreshTokenForUpdate реализует auth.Store.
func (s *MemStore) GetRefreshTokenForUpdate(_ context.Context, hash []byte) (auth.RefreshToken, error) {
defer s.lock()()
for _, t := range s.Tokens {
if bytes.Equal(t.TokenHash, hash) {
return t, nil
}
}
return auth.RefreshToken{}, auth.ErrNotFound
}
// MarkRefreshTokenUsed реализует auth.Store.
func (s *MemStore) MarkRefreshTokenUsed(_ context.Context, id uuid.UUID, at time.Time) error {
defer s.lock()()
t := s.Tokens[id]
t.UsedAt = &at
s.Tokens[id] = t
return nil
}
func (s *MemStore) revoke(at time.Time, match func(auth.RefreshToken) bool) {
defer s.lock()()
for id, t := range s.Tokens {
if t.RevokedAt == nil && match(t) {
t.RevokedAt = &at
s.Tokens[id] = t
}
}
}
// RevokeFamily реализует auth.Store.
func (s *MemStore) RevokeFamily(_ context.Context, familyID uuid.UUID, at time.Time) error {
s.revoke(at, func(t auth.RefreshToken) bool { return t.FamilyID == familyID })
return nil
}
// RevokeUserSessions реализует auth.Store.
func (s *MemStore) RevokeUserSessions(_ context.Context, userID uuid.UUID, at time.Time) error {
s.revoke(at, func(t auth.RefreshToken) bool { return t.UserID == userID })
return nil
}
// ListSessions реализует auth.Store.
func (s *MemStore) ListSessions(_ context.Context, userID uuid.UUID, now time.Time) ([]auth.Session, error) {
defer s.lock()()
var out []auth.Session
for _, t := range s.Tokens {
if t.UserID == userID && t.UsedAt == nil && t.RevokedAt == nil && t.ExpiresAt.After(now) {
out = append(out, auth.Session{
ID: t.FamilyID, DeviceName: t.DeviceName, ClientType: t.ClientType,
CreatedAt: t.FamilyStartedAt, LastUsedAt: t.CreatedAt,
})
}
}
slices.SortFunc(out, func(a, b auth.Session) int { return b.LastUsedAt.Compare(a.LastUsedAt) })
return out, nil
}
// InsertAudit реализует auth.Store.
func (s *MemStore) InsertAudit(_ context.Context, _ uuid.UUID, e auth.AuditEntry) error {
defer s.lock()()
s.Audit = append(s.Audit, e)
return nil
}
// GetSettingBool реализует auth.Store.
func (s *MemStore) GetSettingBool(_ context.Context, key string) (bool, error) {
defer s.lock()()
v, _ := s.Settings[key].(bool)
return v, nil
}
// SetSetting реализует auth.Store.
func (s *MemStore) SetSetting(_ context.Context, key string, value any) error {
defer s.lock()()
s.Settings[key] = value
return nil
}
var _ auth.Store = (*MemStore)(nil)
// Clock — управляемые часы.
type Clock struct {
mu sync.Mutex
T time.Time
}
// Now реализует auth.Clock.
func (c *Clock) Now() time.Time {
c.mu.Lock()
defer c.mu.Unlock()
return c.T
}
// Advance сдвигает часы вперёд.
func (c *Clock) Advance(d time.Duration) {
c.mu.Lock()
defer c.mu.Unlock()
c.T = c.T.Add(d)
}
// Limiter — счётчик попыток в памяти без учёта окна (окно сбрасывает Reset).
type Limiter struct {
mu sync.Mutex
counts map[string]int
}
// Allow реализует auth.Limiter.
func (l *Limiter) Allow(_ context.Context, key string, limit int, window time.Duration) (bool, int, error) {
l.mu.Lock()
defer l.mu.Unlock()
if l.counts == nil {
l.counts = map[string]int{}
}
l.counts[key]++
if l.counts[key] > limit {
return false, int(window / time.Second), nil
}
return true, 0, nil
}
// Reset обнуляет счётчики (как истечение окна).
func (l *Limiter) Reset() {
l.mu.Lock()
defer l.mu.Unlock()
l.counts = nil
}
@@ -0,0 +1,152 @@
# Частые пароли длиной от 8 символов (короче отсекает правило длины). Сравнение без учёта регистра.
# Неполный список: расширяется добавлением строк, по одной на строку.
12345678
123456789
1234567890
12345678910
123123123
1234512345
11111111
111111111
1111111111
00000000
0000000000
22222222
55555555
66666666
77777777
88888888
99999999
87654321
987654321
0987654321
11223344
12341234
12121212
13131313
147258369
123456789a
123456789q
1q2w3e4r
1q2w3e4r5t
1q2w3e4r5t6y
1qaz2wsx
1qaz2wsx3edc
zaq12wsx
qwertyui
qwertyuiop
qwerty123
qwerty1234
qwerty12345
qwerty123456
qwertyqwerty
asdfghjkl
asdfasdf
zxcvbnm123
zxcvbnmm
password
password1
password12
password123
password1234
password!
passw0rd
p@ssw0rd
p@ssword
pa55word
iloveyou
iloveyou1
iloveyou2
sunshine
princess
football
baseball
basketball
superman
batman123
starwars
whatever
trustno1
letmein1
welcome1
welcome123
changeme
changeme123
administrator
admin123
admin1234
admin12345
adminadmin
administrator1
rootroot
root1234
master123
dragon123
monkey123
shadow123
michael1
jennifer
computer
internet
abcd1234
abc12345
abc123456
abcdefgh
aaaaaaaa
aa123456
a1234567
a12345678
q1w2e3r4
q1w2e3r4t5
asdf1234
1234qwer
qwer1234
q1234567
myspace1
charlie1
freedom1
mustang1
liverpool
chelsea1
arsenal1
fuckyou1
secret123
samsung1
pokemon1
minecraft
loveyou1
hello123
helloworld
yankees1
jordan23
access14
cheese12
michelle
nicole12
jessica1
ashley12
killer12
soccer12
hunter12
ranger12
buster12
thomas12
tigger12
summer12
spring12
winter12
autumn12
qazwsxedc
1234abcd
pipu1234
pipumusic
music123
music1234
musiclover
parol123
parol1234
privet123
qwertyuiop123
йцукенгш
пароль123
пароль1234
+264
View File
@@ -0,0 +1,264 @@
package auth
import (
"context"
"errors"
"net/http"
"time"
"pipu/api/internal/api/gen"
"pipu/api/internal/platform/apperr"
)
// RefreshCookie — имя cookie с refresh-токеном веб-клиента.
const RefreshCookie = "pipu_rt"
const refreshCookiePath = "/api/v1/auth"
// Handler реализует auth- и me-части gen.StrictServerInterface.
type Handler struct {
svc *Service
cookieSecure bool
}
// NewHandler создаёт обработчик. cookieSecure = false допустим только для разработки по http.
func NewHandler(svc *Service, cookieSecure bool) *Handler {
return &Handler{svc: svc, cookieSecure: cookieSecure}
}
func principal(ctx context.Context) (Principal, error) {
p, ok := PrincipalFrom(ctx)
if !ok {
return Principal{}, apperr.Unauthorized(CodeUnauthorized, "not authenticated")
}
return p, nil
}
// Login реализует gen.StrictServerInterface.
func (h *Handler) Login(ctx context.Context, req gen.LoginRequestObject) (gen.LoginResponseObject, error) {
in := LoginInput{
Username: req.Body.Username,
Password: req.Body.Password,
ClientType: ClientType(req.Body.ClientType),
IP: clientIP(ctx),
}
if req.Body.DeviceName != nil {
in.DeviceName = *req.Body.DeviceName
}
t, err := h.svc.Login(ctx, in)
if err != nil {
return nil, err
}
return gen.Login200JSONResponse(h.tokensDTO(ctx, t)), nil
}
// RefreshTokens реализует gen.StrictServerInterface.
func (h *Handler) RefreshTokens(ctx context.Context, req gen.RefreshTokensRequestObject) (gen.RefreshTokensResponseObject, error) {
var token string
if req.Body != nil && req.Body.RefreshToken != nil {
token = *req.Body.RefreshToken
} else if req.Params.PipuRt != nil {
token = *req.Params.PipuRt
}
t, err := h.svc.Refresh(ctx, token)
if err != nil {
var ae *apperr.Error
// Токен больше не годится — убираем cookie, чтобы браузер не присылал его снова.
// При гонке двух вкладок cookie уже новая, её не трогаем.
if req.Params.PipuRt != nil && errors.As(err, &ae) && ae.Code != CodeRefreshRace {
h.clearCookie(ctx)
}
return nil, err
}
return gen.RefreshTokens200JSONResponse(h.tokensDTO(ctx, t)), nil
}
// Logout реализует gen.StrictServerInterface.
func (h *Handler) Logout(ctx context.Context, _ gen.LogoutRequestObject) (gen.LogoutResponseObject, error) {
p, err := principal(ctx)
if err != nil {
return nil, err
}
if err := h.svc.Logout(ctx, p); err != nil {
return nil, err
}
h.clearCookie(ctx)
return gen.Logout204Response{}, nil
}
// LogoutAll реализует gen.StrictServerInterface.
func (h *Handler) LogoutAll(ctx context.Context, _ gen.LogoutAllRequestObject) (gen.LogoutAllResponseObject, error) {
p, err := principal(ctx)
if err != nil {
return nil, err
}
if err := h.svc.LogoutAll(ctx, p); err != nil {
return nil, err
}
h.clearCookie(ctx)
return gen.LogoutAll204Response{}, nil
}
// GetMe реализует gen.StrictServerInterface.
func (h *Handler) GetMe(ctx context.Context, _ gen.GetMeRequestObject) (gen.GetMeResponseObject, error) {
p, err := principal(ctx)
if err != nil {
return nil, err
}
u, err := h.svc.Me(ctx, p)
if err != nil {
return nil, err
}
return gen.GetMe200JSONResponse(MeDTO(u)), nil
}
// UpdateMe реализует gen.StrictServerInterface.
func (h *Handler) UpdateMe(ctx context.Context, req gen.UpdateMeRequestObject) (gen.UpdateMeResponseObject, error) {
p, err := principal(ctx)
if err != nil {
return nil, err
}
patch := MePatch{DisplayName: req.Body.DisplayName, Locale: req.Body.Locale}
if req.Body.UiPrefs != nil {
prefs := uiPrefsFromDTO(*req.Body.UiPrefs)
patch.UIPrefs = &prefs
}
u, err := h.svc.UpdateMe(ctx, p, patch)
if err != nil {
return nil, err
}
return gen.UpdateMe200JSONResponse(MeDTO(u)), nil
}
// ChangePassword реализует gen.StrictServerInterface.
func (h *Handler) ChangePassword(ctx context.Context, req gen.ChangePasswordRequestObject) (gen.ChangePasswordResponseObject, error) {
p, err := principal(ctx)
if err != nil {
return nil, err
}
t, err := h.svc.ChangePassword(ctx, p, req.Body.CurrentPassword, req.Body.NewPassword)
if err != nil {
return nil, err
}
return gen.ChangePassword200JSONResponse(h.tokensDTO(ctx, t)), nil
}
// SetBootstrapCredentials реализует gen.StrictServerInterface.
func (h *Handler) SetBootstrapCredentials(ctx context.Context, req gen.SetBootstrapCredentialsRequestObject) (gen.SetBootstrapCredentialsResponseObject, error) {
p, err := principal(ctx)
if err != nil {
return nil, err
}
t, err := h.svc.SetBootstrapCredentials(ctx, p, req.Body.NewUsername, req.Body.NewPassword)
if err != nil {
return nil, err
}
return gen.SetBootstrapCredentials200JSONResponse(h.tokensDTO(ctx, t)), nil
}
// ListMySessions реализует gen.StrictServerInterface.
func (h *Handler) ListMySessions(ctx context.Context, _ gen.ListMySessionsRequestObject) (gen.ListMySessionsResponseObject, error) {
p, err := principal(ctx)
if err != nil {
return nil, err
}
sessions, err := h.svc.ListSessions(ctx, p)
if err != nil {
return nil, err
}
items := make([]gen.Session, 0, len(sessions))
for _, s := range sessions {
items = append(items, gen.Session{
Id: s.ID,
DeviceName: s.DeviceName,
ClientType: gen.ClientType(s.ClientType),
CreatedAt: s.CreatedAt,
LastUsedAt: s.LastUsedAt,
Current: s.ID == p.SessionID,
})
}
return gen.ListMySessions200JSONResponse{Items: items}, nil
}
// RevokeMySession реализует gen.StrictServerInterface.
func (h *Handler) RevokeMySession(ctx context.Context, req gen.RevokeMySessionRequestObject) (gen.RevokeMySessionResponseObject, error) {
p, err := principal(ctx)
if err != nil {
return nil, err
}
if err := h.svc.RevokeSession(ctx, p, req.Id); err != nil {
return nil, err
}
return gen.RevokeMySession204Response{}, nil
}
// tokensDTO собирает ответ с токенами: веб получает refresh в cookie, Android — в теле.
func (h *Handler) tokensDTO(ctx context.Context, t Tokens) gen.AuthTokens {
out := gen.AuthTokens{
AccessToken: t.Access,
TokenType: gen.Bearer,
ExpiresIn: int(t.ExpiresIn / time.Second),
User: MeDTO(t.User),
}
if t.ClientType == ClientWeb {
setCookie(ctx, &http.Cookie{
Name: RefreshCookie,
Value: t.Refresh,
Path: refreshCookiePath,
MaxAge: int(h.svc.cfg.RefreshTTL / time.Second),
HttpOnly: true,
Secure: h.cookieSecure,
SameSite: http.SameSiteStrictMode,
})
} else {
refresh := t.Refresh
out.RefreshToken = &refresh
}
return out
}
func (h *Handler) clearCookie(ctx context.Context) {
setCookie(ctx, &http.Cookie{
Name: RefreshCookie, Path: refreshCookiePath, MaxAge: -1,
HttpOnly: true, Secure: h.cookieSecure, SameSite: http.SameSiteStrictMode,
})
}
// MeDTO переводит пользователя в схему Me.
func MeDTO(u User) gen.Me {
return gen.Me{
Id: u.ID,
Username: u.Username,
DisplayName: u.DisplayName,
Role: gen.Role(u.Role),
Locale: u.Locale,
UiPrefs: uiPrefsDTO(u.UIPrefs),
MustChangePassword: u.MustChangePassword,
MustChangeCredentials: u.MustChangeCredentials,
CreatedAt: u.CreatedAt,
}
}
func uiPrefsDTO(p UIPrefs) gen.UiPrefs {
return gen.UiPrefs{
Theme: (*gen.UiPrefsTheme)(p.Theme),
WaveVariant: (*gen.UiPrefsWaveVariant)(p.WaveVariant),
WebPlayer: (*gen.UiPrefsWebPlayer)(p.WebPlayer),
MobilePlayer: (*gen.UiPrefsMobilePlayer)(p.MobilePlayer),
Ambient: p.Ambient,
CrossfadeS: p.CrossfadeS,
Normalize: p.Normalize,
}
}
func uiPrefsFromDTO(p gen.UiPrefs) UIPrefs {
return UIPrefs{
Theme: (*string)(p.Theme),
WaveVariant: (*string)(p.WaveVariant),
WebPlayer: (*string)(p.WebPlayer),
MobilePlayer: (*string)(p.MobilePlayer),
Ambient: p.Ambient,
CrossfadeS: p.CrossfadeS,
Normalize: p.Normalize,
}
}
+41
View File
@@ -0,0 +1,41 @@
package auth
import (
"context"
"fmt"
"time"
"github.com/redis/go-redis/v9"
)
// RedisLimiter — счётчик попыток с фиксированным окном в Redis.
type RedisLimiter struct{ rdb redis.Cmdable }
// NewRedisLimiter создаёт лимитер.
func NewRedisLimiter(rdb redis.Cmdable) *RedisLimiter { return &RedisLimiter{rdb: rdb} }
// Первый INCR ставит срок окна; ответ — {счётчик, оставшийся TTL в мс}.
var incrWindow = redis.NewScript(`
local n = redis.call('INCR', KEYS[1])
if n == 1 then redis.call('PEXPIRE', KEYS[1], ARGV[1]) end
return {n, redis.call('PTTL', KEYS[1])}
`)
// Allow реализует Limiter.
func (l *RedisLimiter) Allow(ctx context.Context, key string, limit int, window time.Duration) (bool, int, error) {
res, err := incrWindow.Run(ctx, l.rdb, []string{"rl:" + key}, window.Milliseconds()).Int64Slice()
if err != nil {
return false, 0, fmt.Errorf("auth: rate limit: %w", err)
}
if len(res) != 2 {
return false, 0, fmt.Errorf("auth: rate limit: unexpected reply %v", res)
}
if res[0] <= int64(limit) {
return true, 0, nil
}
retry := int((res[1] + 999) / 1000)
if retry < 1 {
retry = 1
}
return false, retry, nil
}
+162
View File
@@ -0,0 +1,162 @@
package auth
import (
"context"
"io"
"log/slog"
"net"
"net/http"
"strings"
"pipu/api/internal/platform/apperr"
"pipu/api/internal/platform/httpx"
)
type ctxKey int
const (
principalKey ctxKey = iota
requestKey
)
// requestInfo — то, что strict-обработчикам нужно от HTTP-запроса, но не входит в контракт:
// адрес клиента и cookie, которые нужно выставить в ответе.
type requestInfo struct {
ip string
cookies []*http.Cookie
}
// PrincipalFrom возвращает пользователя запроса (ok = false для публичных эндпоинтов).
func PrincipalFrom(ctx context.Context) (Principal, bool) {
p, ok := ctx.Value(principalKey).(Principal)
return p, ok
}
// WithPrincipal кладёт пользователя в контекст (для тестов других модулей).
func WithPrincipal(ctx context.Context, p Principal) context.Context {
return context.WithValue(ctx, principalKey, p)
}
func clientIP(ctx context.Context) string {
if ri, ok := ctx.Value(requestKey).(*requestInfo); ok {
return ri.ip
}
return ""
}
func setCookie(ctx context.Context, c *http.Cookie) {
if ri, ok := ctx.Value(requestKey).(*requestInfo); ok {
ri.cookies = append(ri.cookies, c)
}
}
// Публичные эндпоинты /api/v1 — без access-токена.
var publicRoutes = map[string]bool{
"GET /api/v1/server-info": true,
"POST /api/v1/auth/login": true,
"POST /api/v1/auth/refresh": true,
}
// Что разрешено, пока пользователь обязан сменить учётные данные / пароль.
var (
credentialsChangeRoutes = map[string]bool{
"GET /api/v1/me": true,
"POST /api/v1/me/bootstrap-credentials": true,
"POST /api/v1/auth/logout": true,
}
passwordChangeRoutes = map[string]bool{
"GET /api/v1/me": true,
"POST /api/v1/me/password": true,
"POST /api/v1/auth/logout": true,
}
)
// Middleware аутентифицирует запросы к /api/v1 по Bearer-токену и ограничивает пользователей,
// обязанных сменить пароль или учётные данные (docs/auth-and-roles.md).
func Middleware(svc *Service, log *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ri := &requestInfo{ip: hostOnly(r.RemoteAddr)}
ctx := context.WithValue(r.Context(), requestKey, ri)
w = &cookieWriter{ResponseWriter: w, ri: ri}
route := r.Method + " " + strings.TrimSuffix(r.URL.Path, "/")
if route == "POST /api/v1/auth/refresh" && r.ContentLength == 0 {
// Веб шлёт refresh без тела (токен в cookie), а strict-сервер oapi-codegen
// декодирует необязательное тело всё равно и отвечает 400 на пустое.
r.Body = io.NopCloser(strings.NewReader("{}"))
r.ContentLength = 2
}
if !strings.HasPrefix(r.URL.Path, "/api/v1/") || publicRoutes[route] {
next.ServeHTTP(w, r.WithContext(ctx))
return
}
token, ok := bearer(r.Header.Get("Authorization"))
if !ok {
httpx.WriteError(log, w, r, apperr.Unauthorized(CodeUnauthorized, "bearer token required"))
return
}
p, err := svc.Authenticate(ctx, token)
if err != nil {
httpx.WriteError(log, w, r, err)
return
}
switch {
case p.MustChangeCredentials && !credentialsChangeRoutes[route]:
httpx.WriteError(log, w, r, apperr.Forbidden(CodeCredentialsChangeRequired, "change bootstrap credentials first"))
return
case p.MustChangePassword && !passwordChangeRoutes[route]:
httpx.WriteError(log, w, r, apperr.Forbidden(CodePasswordChangeRequired, "change the temporary password first"))
return
}
next.ServeHTTP(w, r.WithContext(WithPrincipal(ctx, p)))
})
}
}
func bearer(h string) (string, bool) {
const prefix = "bearer "
if len(h) <= len(prefix) || !strings.EqualFold(h[:len(prefix)], prefix) {
return "", false
}
t := strings.TrimSpace(h[len(prefix):])
return t, t != ""
}
func hostOnly(addr string) string {
if host, _, err := net.SplitHostPort(addr); err == nil {
return host
}
return addr
}
// cookieWriter добавляет накопленные обработчиком cookie перед записью заголовков ответа.
type cookieWriter struct {
http.ResponseWriter
ri *requestInfo
wrote bool
}
func (w *cookieWriter) flush() {
if w.wrote {
return
}
w.wrote = true
for _, c := range w.ri.cookies {
http.SetCookie(w.ResponseWriter, c)
}
}
func (w *cookieWriter) WriteHeader(code int) {
w.flush()
w.ResponseWriter.WriteHeader(code)
}
func (w *cookieWriter) Write(b []byte) (int, error) {
w.flush()
return w.ResponseWriter.Write(b)
}
// Unwrap нужен http.ResponseController (flush, дедлайны) для обёрнутого writer.
func (w *cookieWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter }
+105
View File
@@ -0,0 +1,105 @@
// Package auth — пользователи, вход, токены, политика прав (docs/auth-and-roles.md).
package auth
import (
"time"
"github.com/google/uuid"
)
// Role — роль пользователя.
type Role string
// Роли по возрастанию прав.
const (
RoleUser Role = "user"
RoleModerator Role = "moderator"
RoleAdmin Role = "admin"
)
// Valid сообщает, известна ли роль.
func (r Role) Valid() bool { return r == RoleUser || r == RoleModerator || r == RoleAdmin }
// ClientType — тип клиента, от него зависит доставка refresh-токена.
type ClientType string
// Типы клиентов.
const (
ClientWeb ClientType = "web"
ClientAndroid ClientType = "android"
)
// Valid сообщает, известен ли тип клиента.
func (c ClientType) Valid() bool { return c == ClientWeb || c == ClientAndroid }
// UIPrefs — настройки интерфейса пользователя (users.ui_prefs). Пустые поля — значения по умолчанию клиента.
type UIPrefs struct {
Theme *string `json:"theme,omitempty"`
WaveVariant *string `json:"wave_variant,omitempty"`
WebPlayer *string `json:"web_player,omitempty"`
MobilePlayer *string `json:"mobile_player,omitempty"`
Ambient *bool `json:"ambient,omitempty"`
CrossfadeS *int `json:"crossfade_s,omitempty"`
Normalize *bool `json:"normalize,omitempty"`
}
// User — учётная запись.
type User struct {
ID uuid.UUID
Username string
DisplayName *string
PasswordHash string
Role Role
Locale string
UIPrefs UIPrefs
MustChangePassword bool
MustChangeCredentials bool
IsBlocked bool
TokenVersion int
FailedLoginCount int
LockedUntil *time.Time
CreatedAt time.Time
LastLoginAt *time.Time
}
// RefreshToken — строка refresh_tokens. Сам токен в БД не хранится, только SHA-256.
type RefreshToken struct {
ID uuid.UUID
UserID uuid.UUID
FamilyID uuid.UUID
FamilyStartedAt time.Time
TokenHash []byte
DeviceName *string
ClientType ClientType
CreatedAt time.Time
ExpiresAt time.Time
UsedAt *time.Time
RevokedAt *time.Time
}
// Session — активная семья refresh-токенов (одно устройство).
type Session struct {
ID uuid.UUID // family_id
DeviceName *string
ClientType ClientType
CreatedAt time.Time // начало семьи (вход)
LastUsedAt time.Time // последняя ротация
}
// Principal — аутентифицированный пользователь запроса (из access-токена и актуальной строки users).
type Principal struct {
UserID uuid.UUID
Role Role
SessionID uuid.UUID
MustChangePassword bool
MustChangeCredentials bool
}
// AuditEntry — запись audit_log.
type AuditEntry struct {
ActorID *uuid.UUID
Action string
TargetType string
TargetID *uuid.UUID
Payload map[string]any
}
+111
View File
@@ -0,0 +1,111 @@
package auth
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"errors"
"fmt"
"strings"
"golang.org/x/crypto/argon2"
)
// Argon2Params — параметры argon2id. Хранятся в самом хэше (формат PHC), поэтому их можно менять:
// старые хэши проверяются со своими параметрами и пересчитываются при следующем входе.
type Argon2Params struct {
MemoryKiB uint32
Time uint32
Threads uint8
SaltLen int
KeyLen uint32
}
// DefaultArgon2Params — m=64 MiB, t=3, p=2 (docs/auth-and-roles.md).
var DefaultArgon2Params = Argon2Params{MemoryKiB: 64 * 1024, Time: 3, Threads: 2, SaltLen: 16, KeyLen: 32}
// Hasher хэширует и проверяет пароли.
type Hasher struct{ p Argon2Params }
// NewHasher создаёт Hasher с заданными параметрами.
func NewHasher(p Argon2Params) *Hasher { return &Hasher{p: p} }
// Hash возвращает PHC-строку `$argon2id$v=19$m=…,t=…,p=…$salt$key`.
func (h *Hasher) Hash(password string) (string, error) {
salt := make([]byte, h.p.SaltLen)
if _, err := rand.Read(salt); err != nil {
return "", fmt.Errorf("auth: salt: %w", err)
}
key := argon2.IDKey([]byte(password), salt, h.p.Time, h.p.MemoryKiB, h.p.Threads, h.p.KeyLen)
return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", argon2.Version, h.p.MemoryKiB, h.p.Time, h.p.Threads,
b64.EncodeToString(salt), b64.EncodeToString(key)), nil
}
// Verify проверяет пароль. needsRehash = true, если хэш посчитан с устаревшими параметрами.
func (h *Hasher) Verify(password, encoded string) (ok, needsRehash bool, err error) {
p, salt, key, err := decodePHC(encoded)
if err != nil {
return false, false, err
}
got := argon2.IDKey([]byte(password), salt, p.Time, p.MemoryKiB, p.Threads, uint32(len(key)))
if subtle.ConstantTimeCompare(got, key) != 1 {
return false, false, nil
}
needsRehash = p.MemoryKiB != h.p.MemoryKiB || p.Time != h.p.Time || p.Threads != h.p.Threads ||
len(salt) != h.p.SaltLen || uint32(len(key)) != h.p.KeyLen
return true, needsRehash, nil
}
var b64 = base64.RawStdEncoding
var errBadHash = errors.New("auth: malformed password hash")
func decodePHC(s string) (Argon2Params, []byte, []byte, error) {
var p Argon2Params
parts := strings.Split(s, "$")
if len(parts) != 6 || parts[1] != "argon2id" {
return p, nil, nil, errBadHash
}
var v int
if _, err := fmt.Sscanf(parts[2], "v=%d", &v); err != nil || v != argon2.Version {
return p, nil, nil, errBadHash
}
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &p.MemoryKiB, &p.Time, &p.Threads); err != nil {
return p, nil, nil, errBadHash
}
salt, err := b64.DecodeString(parts[4])
if err != nil {
return p, nil, nil, errBadHash
}
key, err := b64.DecodeString(parts[5])
if err != nil || len(key) == 0 {
return p, nil, nil, errBadHash
}
p.SaltLen, p.KeyLen = len(salt), uint32(len(key))
return p, salt, key, nil
}
// Алфавит временных паролей: без похожих символов (0/O, 1/l/I), чтобы пароль легко продиктовать.
const tempPasswordAlphabet = "abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"
// GeneratePassword возвращает случайный пароль длины n из tempPasswordAlphabet.
func GeneratePassword(n int) (string, error) {
buf := make([]byte, n)
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("auth: random password: %w", err)
}
// 256 % 56 != 0: отбрасываем байты из «хвоста», чтобы распределение было равномерным.
const limit = 256 - 256%len(tempPasswordAlphabet)
out := make([]byte, 0, n)
for len(out) < n {
for _, b := range buf {
if int(b) < limit && len(out) < n {
out = append(out, tempPasswordAlphabet[int(b)%len(tempPasswordAlphabet)])
}
}
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("auth: random password: %w", err)
}
}
return string(out), nil
}
+121
View File
@@ -0,0 +1,121 @@
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) }
+322
View File
@@ -0,0 +1,322 @@
package auth
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
)
// querier — общее у пула и транзакции.
type querier interface {
Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
}
// PgStore — Store поверх PostgreSQL.
type PgStore struct {
pool *pgxpool.Pool
q querier
}
// NewPgStore создаёт Store на пуле соединений.
func NewPgStore(pool *pgxpool.Pool) *PgStore { return &PgStore{pool: pool, q: pool} }
// WithTx реализует Store. Вложенный вызов выполняется в уже открытой транзакции.
func (s *PgStore) WithTx(ctx context.Context, fn func(Store) error) error {
if s.pool == nil {
return fn(s)
}
return pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { return fn(&PgStore{q: tx}) })
}
const userColumns = `id, username, display_name, password_hash, role, locale, ui_prefs, must_change_password,
must_change_credentials, is_blocked, token_version, failed_login_count, locked_until, created_at, last_login_at`
func scanUser(row pgx.Row) (User, error) {
var (
u User
role string
prefs []byte
)
err := row.Scan(&u.ID, &u.Username, &u.DisplayName, &u.PasswordHash, &role, &u.Locale, &prefs,
&u.MustChangePassword, &u.MustChangeCredentials, &u.IsBlocked, &u.TokenVersion, &u.FailedLoginCount,
&u.LockedUntil, &u.CreatedAt, &u.LastLoginAt)
if errors.Is(err, pgx.ErrNoRows) {
return User{}, ErrNotFound
}
if err != nil {
return User{}, fmt.Errorf("auth: scan user: %w", err)
}
u.Role = Role(role)
if err := json.Unmarshal(prefs, &u.UIPrefs); err != nil {
return User{}, fmt.Errorf("auth: decode ui_prefs: %w", err)
}
return u, nil
}
// CountAdmins реализует Store. Заблокированные админы не считаются: войти они не могут.
func (s *PgStore) CountAdmins(ctx context.Context) (int, error) {
var n int
err := s.q.QueryRow(ctx, `SELECT count(*) FROM users WHERE role = 'admin' AND NOT is_blocked`).Scan(&n)
if err != nil {
return 0, fmt.Errorf("auth: count admins: %w", err)
}
return n, nil
}
// CreateUser реализует Store.
func (s *PgStore) CreateUser(ctx context.Context, u User, createdBy *uuid.UUID) error {
prefs, err := json.Marshal(u.UIPrefs)
if err != nil {
return err
}
_, err = s.q.Exec(ctx, `
INSERT INTO users (id, username, display_name, password_hash, role, locale, ui_prefs,
must_change_password, must_change_credentials, created_by, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $11)`,
u.ID, u.Username, u.DisplayName, u.PasswordHash, string(u.Role), u.Locale, prefs,
u.MustChangePassword, u.MustChangeCredentials, createdBy, u.CreatedAt)
if isUniqueViolation(err) {
return ErrUsernameTaken
}
if err != nil {
return fmt.Errorf("auth: create user: %w", err)
}
return nil
}
func isUniqueViolation(err error) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) && pgErr.Code == "23505"
}
// GetUserByID реализует Store.
func (s *PgStore) GetUserByID(ctx context.Context, id uuid.UUID) (User, error) {
return scanUser(s.q.QueryRow(ctx, `SELECT `+userColumns+` FROM users WHERE id = $1`, id))
}
// GetUserByUsername реализует Store (username — citext, регистр не важен).
func (s *PgStore) GetUserByUsername(ctx context.Context, username string) (User, error) {
return scanUser(s.q.QueryRow(ctx, `SELECT `+userColumns+` FROM users WHERE username = $1`, username))
}
// RecordLoginFailure реализует Store.
func (s *PgStore) RecordLoginFailure(ctx context.Context, id uuid.UUID, failedCount int, lockedUntil *time.Time) error {
_, err := s.q.Exec(ctx, `UPDATE users SET failed_login_count = $2, locked_until = $3 WHERE id = $1`,
id, failedCount, lockedUntil)
if err != nil {
return fmt.Errorf("auth: record login failure: %w", err)
}
return nil
}
// RecordLoginSuccess реализует Store.
func (s *PgStore) RecordLoginSuccess(ctx context.Context, id uuid.UUID, at time.Time, newHash *string) error {
_, err := s.q.Exec(ctx, `
UPDATE users SET failed_login_count = 0, locked_until = NULL, last_login_at = $2,
password_hash = COALESCE($3, password_hash)
WHERE id = $1`, id, at, newHash)
if err != nil {
return fmt.Errorf("auth: record login success: %w", err)
}
return nil
}
// SetPassword реализует Store.
func (s *PgStore) SetPassword(ctx context.Context, id uuid.UUID, hash string) error {
_, err := s.q.Exec(ctx, `
UPDATE users SET password_hash = $2, must_change_password = false,
token_version = token_version + 1, updated_at = now()
WHERE id = $1`, id, hash)
if err != nil {
return fmt.Errorf("auth: set password: %w", err)
}
return nil
}
// SetCredentials реализует Store.
func (s *PgStore) SetCredentials(ctx context.Context, id uuid.UUID, username, hash string) error {
_, err := s.q.Exec(ctx, `
UPDATE users SET username = $2, password_hash = $3, must_change_credentials = false,
must_change_password = false, token_version = token_version + 1, updated_at = now()
WHERE id = $1`, id, username, hash)
if isUniqueViolation(err) {
return ErrUsernameTaken
}
if err != nil {
return fmt.Errorf("auth: set credentials: %w", err)
}
return nil
}
// UpdateProfile реализует Store.
func (s *PgStore) UpdateProfile(ctx context.Context, id uuid.UUID, upd ProfileUpdate) (User, error) {
var (
setName bool
name *string
prefs []byte
err error
)
if upd.DisplayName != nil {
setName, name = true, *upd.DisplayName
}
if upd.UIPrefs != nil {
if prefs, err = json.Marshal(upd.UIPrefs); err != nil {
return User{}, err
}
}
return scanUser(s.q.QueryRow(ctx, `
UPDATE users SET
display_name = CASE WHEN $2 THEN $3 ELSE display_name END,
locale = COALESCE($4, locale),
ui_prefs = COALESCE($5::jsonb, ui_prefs),
updated_at = now()
WHERE id = $1
RETURNING `+userColumns, id, setName, name, upd.Locale, prefs))
}
// InsertRefreshToken реализует Store.
func (s *PgStore) InsertRefreshToken(ctx context.Context, t RefreshToken) error {
_, err := s.q.Exec(ctx, `
INSERT INTO refresh_tokens (id, user_id, family_id, family_started_at, token_hash, device_name,
client_type, created_at, expires_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
t.ID, t.UserID, t.FamilyID, t.FamilyStartedAt, t.TokenHash, t.DeviceName, string(t.ClientType),
t.CreatedAt, t.ExpiresAt)
if err != nil {
return fmt.Errorf("auth: insert refresh token: %w", err)
}
return nil
}
// GetRefreshTokenForUpdate реализует Store.
func (s *PgStore) GetRefreshTokenForUpdate(ctx context.Context, hash []byte) (RefreshToken, error) {
var (
t RefreshToken
ct string
)
err := s.q.QueryRow(ctx, `
SELECT id, user_id, family_id, family_started_at, token_hash, device_name, client_type,
created_at, expires_at, used_at, revoked_at
FROM refresh_tokens WHERE token_hash = $1 FOR UPDATE`, hash).
Scan(&t.ID, &t.UserID, &t.FamilyID, &t.FamilyStartedAt, &t.TokenHash, &t.DeviceName, &ct,
&t.CreatedAt, &t.ExpiresAt, &t.UsedAt, &t.RevokedAt)
if errors.Is(err, pgx.ErrNoRows) {
return RefreshToken{}, ErrNotFound
}
if err != nil {
return RefreshToken{}, fmt.Errorf("auth: get refresh token: %w", err)
}
t.ClientType = ClientType(ct)
return t, nil
}
// MarkRefreshTokenUsed реализует Store.
func (s *PgStore) MarkRefreshTokenUsed(ctx context.Context, id uuid.UUID, at time.Time) error {
_, err := s.q.Exec(ctx, `UPDATE refresh_tokens SET used_at = $2 WHERE id = $1`, id, at)
if err != nil {
return fmt.Errorf("auth: mark refresh token used: %w", err)
}
return nil
}
// RevokeFamily реализует Store.
func (s *PgStore) RevokeFamily(ctx context.Context, familyID uuid.UUID, at time.Time) error {
_, err := s.q.Exec(ctx, `UPDATE refresh_tokens SET revoked_at = $2 WHERE family_id = $1 AND revoked_at IS NULL`,
familyID, at)
if err != nil {
return fmt.Errorf("auth: revoke family: %w", err)
}
return nil
}
// RevokeUserSessions реализует Store.
func (s *PgStore) RevokeUserSessions(ctx context.Context, userID uuid.UUID, at time.Time) error {
_, err := s.q.Exec(ctx, `UPDATE refresh_tokens SET revoked_at = $2 WHERE user_id = $1 AND revoked_at IS NULL`,
userID, at)
if err != nil {
return fmt.Errorf("auth: revoke user sessions: %w", err)
}
return nil
}
// ListSessions реализует Store.
func (s *PgStore) ListSessions(ctx context.Context, userID uuid.UUID, now time.Time) ([]Session, error) {
rows, err := s.q.Query(ctx, `
SELECT family_id, device_name, client_type, family_started_at, created_at
FROM refresh_tokens
WHERE user_id = $1 AND used_at IS NULL AND revoked_at IS NULL AND expires_at > $2
ORDER BY created_at DESC`, userID, now)
if err != nil {
return nil, fmt.Errorf("auth: list sessions: %w", err)
}
out, err := pgx.CollectRows(rows, func(row pgx.CollectableRow) (Session, error) {
var (
ss Session
ct string
)
err := row.Scan(&ss.ID, &ss.DeviceName, &ct, &ss.CreatedAt, &ss.LastUsedAt)
ss.ClientType = ClientType(ct)
return ss, err
})
if err != nil {
return nil, fmt.Errorf("auth: list sessions: %w", err)
}
return out, nil
}
// InsertAudit реализует Store.
func (s *PgStore) InsertAudit(ctx context.Context, id uuid.UUID, e AuditEntry) error {
var payload []byte
if e.Payload != nil {
var err error
if payload, err = json.Marshal(e.Payload); err != nil {
return err
}
}
_, err := s.q.Exec(ctx, `
INSERT INTO audit_log (id, actor_id, action, target_type, target_id, payload)
VALUES ($1, $2, $3, NULLIF($4, ''), $5, $6)`, id, e.ActorID, e.Action, e.TargetType, e.TargetID, payload)
if err != nil {
return fmt.Errorf("auth: insert audit: %w", err)
}
return nil
}
// GetSettingBool реализует Store: отсутствующий ключ — false.
func (s *PgStore) GetSettingBool(ctx context.Context, key string) (bool, error) {
var v bool
err := s.q.QueryRow(ctx, `SELECT value::boolean FROM app_settings WHERE key = $1`, key).Scan(&v)
if errors.Is(err, pgx.ErrNoRows) {
return false, nil
}
if err != nil {
return false, fmt.Errorf("auth: get setting %s: %w", key, err)
}
return v, nil
}
// SetSetting реализует Store.
func (s *PgStore) SetSetting(ctx context.Context, key string, value any) error {
raw, err := json.Marshal(value)
if err != nil {
return err
}
_, err = s.q.Exec(ctx, `
INSERT INTO app_settings (key, value, updated_at) VALUES ($1, $2, now())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`, key, raw)
if err != nil {
return fmt.Errorf("auth: set setting %s: %w", key, err)
}
return nil
}
var _ Store = (*PgStore)(nil)
+94
View File
@@ -0,0 +1,94 @@
package auth
import "github.com/google/uuid"
// Матрица прав из docs/auth-and-roles.md. Проверяется в сервисном слое, а не только в middleware.
// Permission — право, не зависящее от целевого пользователя.
type Permission int
// Глобальные права.
const (
PermAdminPanel Permission = iota // открыть админку
PermListUsers // видеть список пользователей
PermManageLibrary // хранилища, сканирование, переиндексация
PermViewLibraryStatus // статус библиотеки (просмотр)
PermSuggestTracks // предлагать треки, видеть свои предложения
PermReviewUploads // очередь, одобрение, правка метаданных, лимиты
PermSystemStatus // статус рекомендательной системы, аудит
)
var permissions = map[Permission][]Role{
PermAdminPanel: {RoleModerator, RoleAdmin},
PermListUsers: {RoleModerator, RoleAdmin},
PermManageLibrary: {RoleAdmin},
PermViewLibraryStatus: {RoleModerator, RoleAdmin},
PermSuggestTracks: {RoleModerator, RoleAdmin},
PermReviewUploads: {RoleAdmin},
PermSystemStatus: {RoleAdmin},
}
// Can сообщает, есть ли у роли глобальное право.
func Can(r Role, p Permission) bool {
for _, allowed := range permissions[p] {
if allowed == r {
return true
}
}
return false
}
// UserAction — действие над учётной записью другого пользователя.
type UserAction int
// Действия над пользователями.
const (
UserCreate UserAction = iota // создать пользователя с ролью target
UserEdit // изменить отображаемое имя
UserResetPassword // сбросить пароль
UserBlock // заблокировать / разблокировать
UserChangeRole // сменить роль (target — текущая роль)
UserDelete // удалить
)
// Actor — кто действует (или над кем). ID нужен, чтобы запретить действия над собой.
type Actor struct {
ID uuid.UUID
Role Role
}
// CanManageUser — может ли actor выполнить action над target.
// Модератор управляет только обычными пользователями; роли и удаление — только админ.
// Блокировать, удалять себя и менять себе роль нельзя никому: так не потерять доступ случайно.
// Правило «последний админ» зависит от данных и проверяется в сервисе.
func CanManageUser(actor, target Actor, action UserAction) bool {
self := actor.ID != uuid.Nil && actor.ID == target.ID
if self && (action == UserBlock || action == UserDelete || action == UserChangeRole) {
return false
}
switch actor.Role {
case RoleAdmin:
return true
case RoleModerator:
switch action {
case UserCreate, UserEdit, UserResetPassword, UserBlock:
return target.Role == RoleUser
default:
return false
}
default:
return false
}
}
// CanAssignRole — может ли actor выдать роль newRole (при создании или смене роли).
func CanAssignRole(actor Role, newRole Role) bool {
switch actor {
case RoleAdmin:
return newRole.Valid()
case RoleModerator:
return newRole == RoleUser
default:
return false
}
}
+91
View File
@@ -0,0 +1,91 @@
package auth
import (
"fmt"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
)
func TestCan(t *testing.T) {
// Строки матрицы прав из docs/auth-and-roles.md: user, moderator, admin.
matrix := []struct {
perm Permission
name string
want [3]bool
}{
{PermAdminPanel, "admin panel", [3]bool{false, true, true}},
{PermListUsers, "list users", [3]bool{false, true, true}},
{PermManageLibrary, "manage library", [3]bool{false, false, true}},
{PermViewLibraryStatus, "library status", [3]bool{false, true, true}},
{PermSuggestTracks, "suggest tracks", [3]bool{false, true, true}},
{PermReviewUploads, "review uploads", [3]bool{false, false, true}},
{PermSystemStatus, "system status", [3]bool{false, false, true}},
}
roles := [3]Role{RoleUser, RoleModerator, RoleAdmin}
for _, row := range matrix {
for i, r := range roles {
t.Run(fmt.Sprintf("%s/%s", row.name, r), func(t *testing.T) {
assert.Equal(t, row.want[i], Can(r, row.perm))
})
}
}
assert.False(t, Can(Role("guest"), PermAdminPanel))
}
func TestCanManageUser(t *testing.T) {
actions := map[UserAction]string{
UserCreate: "create", UserEdit: "edit", UserResetPassword: "reset", UserBlock: "block",
UserChangeRole: "role", UserDelete: "delete",
}
// want[actor][target] для каждого действия; порядок ролей — user, moderator, admin.
cases := map[UserAction][3][3]bool{
UserCreate: {{false, false, false}, {true, false, false}, {true, true, true}},
UserEdit: {{false, false, false}, {true, false, false}, {true, true, true}},
UserResetPassword: {{false, false, false}, {true, false, false}, {true, true, true}},
UserBlock: {{false, false, false}, {true, false, false}, {true, true, true}},
UserChangeRole: {{false, false, false}, {false, false, false}, {true, true, true}},
UserDelete: {{false, false, false}, {false, false, false}, {true, true, true}},
}
roles := [3]Role{RoleUser, RoleModerator, RoleAdmin}
for action, want := range cases {
for ai, ar := range roles {
for ti, tr := range roles {
t.Run(fmt.Sprintf("%s/%s->%s", actions[action], ar, tr), func(t *testing.T) {
actor := Actor{ID: uuid.New(), Role: ar}
target := Actor{ID: uuid.New(), Role: tr}
assert.Equal(t, want[ai][ti], CanManageUser(actor, target, action))
})
}
}
}
}
func TestCanManageUserSelf(t *testing.T) {
admin := Actor{ID: uuid.New(), Role: RoleAdmin}
for _, a := range []UserAction{UserBlock, UserDelete, UserChangeRole} {
assert.False(t, CanManageUser(admin, admin, a), "action %d on self", a)
}
assert.True(t, CanManageUser(admin, admin, UserEdit))
assert.True(t, CanManageUser(admin, admin, UserResetPassword))
}
func TestCanAssignRole(t *testing.T) {
cases := []struct {
actor, role Role
want bool
}{
{RoleAdmin, RoleAdmin, true},
{RoleAdmin, RoleModerator, true},
{RoleAdmin, RoleUser, true},
{RoleAdmin, Role("root"), false},
{RoleModerator, RoleUser, true},
{RoleModerator, RoleModerator, false},
{RoleModerator, RoleAdmin, false},
{RoleUser, RoleUser, false},
}
for _, tc := range cases {
assert.Equal(t, tc.want, CanAssignRole(tc.actor, tc.role), "%s assigns %s", tc.actor, tc.role)
}
}
+647
View File
@@ -0,0 +1,647 @@
package auth
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
"time"
"unicode/utf8"
"github.com/google/uuid"
"pipu/api/internal/platform/apperr"
)
// Коды ошибок аутентификации.
const (
CodeInvalidCredentials = "auth.invalid_credentials"
CodeTooManyAttempts = "auth.too_many_attempts"
CodeUserBlocked = "auth.user_blocked"
CodeUnauthorized = "auth.unauthorized"
CodeTokenExpired = "auth.token_expired"
CodeTokenInvalid = "auth.token_invalid"
CodeTokenRevoked = "auth.token_revoked"
CodeRefreshInvalid = "auth.refresh_invalid"
CodeRefreshReused = "auth.refresh_reused"
CodeRefreshRace = "auth.refresh_race"
CodeCredentialsChangeRequired = "auth.credentials_change_required"
CodePasswordChangeRequired = "auth.password_change_required"
CodeCurrentPasswordRequired = "auth.current_password_required"
CodeCurrentPasswordInvalid = "auth.current_password_invalid"
CodeBootstrapNotRequired = "auth.bootstrap_not_required"
CodeSessionNotFound = "auth.session_not_found"
CodeUsernameTaken = "validation.username_taken"
)
// Ключ app_settings: первый вход администратора завершён.
const settingBootstrapCompleted = "bootstrap.completed"
// Config — параметры сервиса.
type Config struct {
RefreshTTL time.Duration // скользящий срок refresh-токена
RefreshMaxAge time.Duration // абсолютный срок семьи токенов
// RefreshRaceWindow — сколько после ротации повтор старого токена считается гонкой
// (две вкладки обновили токен одновременно), а не кражей.
RefreshRaceWindow time.Duration
LockThreshold int // неудачных входов до блокировки
LockBase time.Duration // первая блокировка, дальше удваивается
LockMax time.Duration
LoginLimitPerUser int // попыток в минуту на IP + логин
LoginLimitPerIP int // попыток в минуту на IP
BootstrapUsername string
BootstrapPassword string // пусто — сгенерировать
SupportedLocales []string
}
// DefaultConfig — значения из docs/auth-and-roles.md.
func DefaultConfig() Config {
return Config{
RefreshTTL: 30 * 24 * time.Hour,
RefreshMaxAge: 180 * 24 * time.Hour,
RefreshRaceWindow: 10 * time.Second,
LockThreshold: 5,
LockBase: 5 * time.Minute,
LockMax: time.Hour,
LoginLimitPerUser: 5,
LoginLimitPerIP: 30,
BootstrapUsername: "admin",
SupportedLocales: []string{"ru", "en"},
}
}
// Service — бизнес-логика входа, токенов и профиля.
type Service struct {
store Store
hasher *Hasher
tokens *TokenIssuer
limiter Limiter
clock Clock
ids IDGen
log *slog.Logger
cfg Config
// dummyHash проверяется для несуществующих логинов, чтобы время ответа не выдавало их отсутствие.
dummyHash string
}
// NewService создаёт сервис.
func NewService(store Store, hasher *Hasher, tokens *TokenIssuer, limiter Limiter, clock Clock, ids IDGen,
log *slog.Logger, cfg Config,
) (*Service, error) {
if log == nil {
log = slog.New(slog.DiscardHandler)
}
dummy, err := hasher.Hash("pipu-dummy-password")
if err != nil {
return nil, err
}
return &Service{
store: store, hasher: hasher, tokens: tokens, limiter: limiter, clock: clock, ids: ids, log: log, cfg: cfg,
dummyHash: dummy,
}, nil
}
// Tokens — результат входа или ротации.
type Tokens struct {
Access string
ExpiresIn time.Duration
Refresh string
ClientType ClientType
User User
}
// LoginInput — данные формы входа.
type LoginInput struct {
Username string
Password string
ClientType ClientType
DeviceName string
IP string
}
func invalidCredentials() error {
return apperr.Unauthorized(CodeInvalidCredentials, "invalid username or password")
}
// Login проверяет логин и пароль и открывает новую сессию.
func (s *Service) Login(ctx context.Context, in LoginInput) (Tokens, error) {
if !in.ClientType.Valid() {
return Tokens{}, apperr.Validation("client_type", "validation.client_type_invalid", "unknown client type", nil)
}
username := NormalizeUsername(in.Username)
if err := s.checkRateLimit(ctx, in.IP, username); err != nil {
return Tokens{}, err
}
if len(in.Password) > MaxPassword*4 {
return Tokens{}, invalidCredentials()
}
u, err := s.store.GetUserByUsername(ctx, username)
if errors.Is(err, ErrNotFound) {
_, _, _ = s.hasher.Verify(in.Password, s.dummyHash)
return Tokens{}, invalidCredentials()
}
if err != nil {
return Tokens{}, err
}
now := s.clock.Now()
if u.LockedUntil != nil && now.Before(*u.LockedUntil) {
_, _, _ = s.hasher.Verify(in.Password, s.dummyHash)
return Tokens{}, invalidCredentials()
}
ok, rehash, err := s.hasher.Verify(in.Password, u.PasswordHash)
if err != nil {
return Tokens{}, err
}
if !ok {
failed := u.FailedLoginCount + 1
var lockedUntil *time.Time
if failed >= s.cfg.LockThreshold {
until := now.Add(s.lockDuration(failed))
lockedUntil = &until
}
if err := s.store.RecordLoginFailure(ctx, u.ID, failed, lockedUntil); err != nil {
return Tokens{}, err
}
return Tokens{}, invalidCredentials()
}
if u.IsBlocked {
return Tokens{}, apperr.Forbidden(CodeUserBlocked, "user is blocked")
}
var newHash *string
if rehash {
h, err := s.hasher.Hash(in.Password)
if err != nil {
return Tokens{}, err
}
newHash = &h
}
var out Tokens
err = s.store.WithTx(ctx, func(tx Store) error {
if err := tx.RecordLoginSuccess(ctx, u.ID, now, newHash); err != nil {
return err
}
out, err = s.openSession(ctx, tx, u, in.ClientType, in.DeviceName)
return err
})
return out, err
}
// lockDuration — блокировка после failed неудачных попыток: LockBase, затем вдвое больше за каждую
// следующую неудачу, но не более LockMax.
func (s *Service) lockDuration(failed int) time.Duration {
d := s.cfg.LockBase
for i := s.cfg.LockThreshold; i < failed && d < s.cfg.LockMax; i++ {
d *= 2
}
return min(d, s.cfg.LockMax)
}
func (s *Service) checkRateLimit(ctx context.Context, ip, username string) error {
if s.limiter == nil {
return nil
}
checks := []struct {
key string
limit int
}{
{"login:ip:" + ip, s.cfg.LoginLimitPerIP},
{"login:user:" + ip + ":" + username, s.cfg.LoginLimitPerUser},
}
for _, c := range checks {
ok, retry, err := s.limiter.Allow(ctx, c.key, c.limit, time.Minute)
if err != nil {
// Redis недоступен — не запираем всех снаружи: остаётся блокировка учётки по счётчику в БД.
s.log.WarnContext(ctx, "login rate limiter unavailable", "err", err)
return nil
}
if !ok {
return apperr.TooManyRequests(CodeTooManyAttempts, retry)
}
}
return nil
}
// openSession создаёт новую семью refresh-токенов и выдаёт пару.
func (s *Service) openSession(ctx context.Context, st Store, u User, ct ClientType, device string) (Tokens, error) {
now := s.clock.Now()
return s.issue(ctx, st, u, RefreshToken{
FamilyID: s.ids.New(),
FamilyStartedAt: now,
DeviceName: deviceName(device),
ClientType: ct,
})
}
// issue записывает новый refresh-токен семьи prev и подписывает access-токен.
func (s *Service) issue(ctx context.Context, st Store, u User, prev RefreshToken) (Tokens, error) {
now := s.clock.Now()
refresh, hash, err := NewRefreshToken()
if err != nil {
return Tokens{}, err
}
expires := now.Add(s.cfg.RefreshTTL)
if hardLimit := prev.FamilyStartedAt.Add(s.cfg.RefreshMaxAge); expires.After(hardLimit) {
expires = hardLimit
}
rt := RefreshToken{
ID: s.ids.New(),
UserID: u.ID,
FamilyID: prev.FamilyID,
FamilyStartedAt: prev.FamilyStartedAt,
TokenHash: hash,
DeviceName: prev.DeviceName,
ClientType: prev.ClientType,
CreatedAt: now,
ExpiresAt: expires,
}
if err := st.InsertRefreshToken(ctx, rt); err != nil {
return Tokens{}, err
}
access, err := s.tokens.Issue(u.ID, u.Role, rt.FamilyID, u.TokenVersion)
if err != nil {
return Tokens{}, err
}
return Tokens{Access: access, ExpiresIn: s.tokens.TTL(), Refresh: refresh, ClientType: rt.ClientType, User: u}, nil
}
func deviceName(s string) *string {
s = strings.TrimSpace(s)
if s == "" {
return nil
}
if utf8.RuneCountInString(s) > 100 {
s = string([]rune(s)[:100])
}
return &s
}
// Refresh ротирует refresh-токен: старый помечается использованным, выдаётся новый в той же семье.
func (s *Service) Refresh(ctx context.Context, token string) (Tokens, error) {
if token == "" {
return Tokens{}, apperr.Unauthorized(CodeRefreshInvalid, "refresh token missing")
}
now := s.clock.Now()
var (
out Tokens
reused bool
familyID uuid.UUID
)
err := s.store.WithTx(ctx, func(tx Store) error {
rt, err := tx.GetRefreshTokenForUpdate(ctx, HashRefreshToken(token))
if errors.Is(err, ErrNotFound) {
return apperr.Unauthorized(CodeRefreshInvalid, "unknown refresh token")
}
if err != nil {
return err
}
if rt.RevokedAt != nil {
return apperr.Unauthorized(CodeRefreshInvalid, "refresh token revoked")
}
if rt.UsedAt != nil {
if now.Sub(*rt.UsedAt) <= s.cfg.RefreshRaceWindow {
return apperr.Unauthorized(CodeRefreshRace, "refresh token was just rotated")
}
reused, familyID = true, rt.FamilyID
return nil
}
if !now.Before(rt.ExpiresAt) {
return apperr.Unauthorized(CodeRefreshInvalid, "refresh token expired")
}
u, err := tx.GetUserByID(ctx, rt.UserID)
if err != nil {
return err
}
if u.IsBlocked {
return apperr.Forbidden(CodeUserBlocked, "user is blocked")
}
if err := tx.MarkRefreshTokenUsed(ctx, rt.ID, now); err != nil {
return err
}
out, err = s.issue(ctx, tx, u, rt)
return err
})
if err != nil {
return Tokens{}, err
}
if reused {
// Токен уже обменяли — его украли или скопировали. Отзываем всю семью вне транзакции чтения.
s.log.WarnContext(ctx, "refresh token reuse detected, family revoked", "family_id", familyID)
if err := s.store.RevokeFamily(ctx, familyID, now); err != nil {
return Tokens{}, err
}
return Tokens{}, apperr.Unauthorized(CodeRefreshReused, "refresh token reuse")
}
return out, nil
}
// Authenticate проверяет access-токен и актуальное состояние пользователя.
func (s *Service) Authenticate(ctx context.Context, access string) (Principal, error) {
claims, err := s.tokens.Parse(access)
switch {
case errors.Is(err, ErrTokenExpired):
return Principal{}, apperr.Unauthorized(CodeTokenExpired, "access token expired")
case err != nil:
return Principal{}, apperr.Unauthorized(CodeTokenInvalid, "access token invalid")
}
u, err := s.store.GetUserByID(ctx, claims.UserID)
if errors.Is(err, ErrNotFound) {
return Principal{}, apperr.Unauthorized(CodeTokenRevoked, "user not found")
}
if err != nil {
return Principal{}, err
}
if u.IsBlocked {
return Principal{}, apperr.Unauthorized(CodeUserBlocked, "user is blocked")
}
if u.TokenVersion != claims.TokenVersion {
return Principal{}, apperr.Unauthorized(CodeTokenRevoked, "token version changed")
}
return Principal{
UserID: u.ID,
Role: u.Role,
SessionID: claims.SessionID,
MustChangePassword: u.MustChangePassword,
MustChangeCredentials: u.MustChangeCredentials,
}, nil
}
// Logout отзывает текущую сессию.
func (s *Service) Logout(ctx context.Context, p Principal) error {
return s.store.RevokeFamily(ctx, p.SessionID, s.clock.Now())
}
// LogoutAll отзывает все сессии пользователя.
func (s *Service) LogoutAll(ctx context.Context, p Principal) error {
return s.store.RevokeUserSessions(ctx, p.UserID, s.clock.Now())
}
// Me возвращает профиль текущего пользователя.
func (s *Service) Me(ctx context.Context, p Principal) (User, error) {
return s.store.GetUserByID(ctx, p.UserID)
}
// MePatch — изменения профиля из PATCH /me. nil — не менять; DisplayName "" — сбросить.
type MePatch struct {
DisplayName *string
Locale *string
UIPrefs *UIPrefs
}
// UpdateMe меняет имя, язык и настройки интерфейса.
func (s *Service) UpdateMe(ctx context.Context, p Principal, patch MePatch) (User, error) {
var upd ProfileUpdate
if patch.DisplayName != nil {
name := strings.TrimSpace(*patch.DisplayName)
if utf8.RuneCountInString(name) > MaxDisplayName {
return User{}, apperr.Validation("display_name", CodeDisplayNameTooLong, "display name too long",
map[string]any{"max": MaxDisplayName})
}
var v *string
if name != "" {
v = &name
}
upd.DisplayName = &v
}
if patch.Locale != nil {
if !contains(s.cfg.SupportedLocales, *patch.Locale) {
return User{}, apperr.Validation("locale", CodeLocaleUnsupported, "unsupported locale",
map[string]any{"supported": s.cfg.SupportedLocales})
}
upd.Locale = patch.Locale
}
if patch.UIPrefs != nil {
if err := ValidateUIPrefs(*patch.UIPrefs); err != nil {
return User{}, err
}
u, err := s.store.GetUserByID(ctx, p.UserID)
if err != nil {
return User{}, err
}
merged := MergeUIPrefs(u.UIPrefs, *patch.UIPrefs)
upd.UIPrefs = &merged
}
return s.store.UpdateProfile(ctx, p.UserID, upd)
}
func contains(list []string, v string) bool {
for _, x := range list {
if x == v {
return true
}
}
return false
}
// ChangePassword меняет пароль, отзывает все сессии и открывает новую на текущем устройстве.
// current не нужен, если пароль временный (must_change_password).
func (s *Service) ChangePassword(ctx context.Context, p Principal, current *string, newPassword string) (Tokens, error) {
u, err := s.store.GetUserByID(ctx, p.UserID)
if err != nil {
return Tokens{}, err
}
if u.MustChangeCredentials {
return Tokens{}, apperr.Forbidden(CodeCredentialsChangeRequired, "use bootstrap credentials endpoint")
}
if !u.MustChangePassword {
if current == nil || *current == "" {
return Tokens{}, apperr.Validation("current_password", CodeCurrentPasswordRequired, "current password required", nil)
}
ok, _, err := s.hasher.Verify(*current, u.PasswordHash)
if err != nil {
return Tokens{}, err
}
if !ok {
return Tokens{}, apperr.Validation("current_password", CodeCurrentPasswordInvalid, "current password is wrong", nil)
}
}
if err := s.checkNewPassword(u, u.Username, newPassword); err != nil {
return Tokens{}, err
}
hash, err := s.hasher.Hash(newPassword)
if err != nil {
return Tokens{}, err
}
return s.replaceSessions(ctx, p, u, func(tx Store) error { return tx.SetPassword(ctx, u.ID, hash) }, nil)
}
// checkNewPassword — политика паролей плюс запрет оставить прежний пароль.
func (s *Service) checkNewPassword(u User, username, newPassword string) error {
if err := ValidatePassword("new_password", newPassword, username, u.Role); err != nil {
return err
}
same, _, err := s.hasher.Verify(newPassword, u.PasswordHash)
if err != nil {
return err
}
if same {
return apperr.Validation("new_password", CodePasswordUnchanged, "new password equals current", nil)
}
return nil
}
// replaceSessions в одной транзакции применяет change, отзывает все сессии пользователя
// и открывает новую с тем же устройством, что у текущей.
func (s *Service) replaceSessions(ctx context.Context, p Principal, u User, change func(Store) error,
audit *AuditEntry,
) (Tokens, error) {
now := s.clock.Now()
var out Tokens
err := s.store.WithTx(ctx, func(tx Store) error {
sessions, err := tx.ListSessions(ctx, u.ID, now)
if err != nil {
return err
}
ct, device := ClientWeb, ""
for _, ss := range sessions {
if ss.ID == p.SessionID {
ct = ss.ClientType
if ss.DeviceName != nil {
device = *ss.DeviceName
}
}
}
if err := change(tx); err != nil {
return err
}
if err := tx.RevokeUserSessions(ctx, u.ID, now); err != nil {
return err
}
if audit != nil {
if err := tx.InsertAudit(ctx, s.ids.New(), *audit); err != nil {
return err
}
}
// Перечитываем: change увеличил token_version, новый access-токен должен нести его.
fresh, err := tx.GetUserByID(ctx, u.ID)
if err != nil {
return err
}
out, err = s.openSession(ctx, tx, fresh, ct, device)
return err
})
return out, err
}
// SetBootstrapCredentials — первый вход администратора: новые логин и пароль.
func (s *Service) SetBootstrapCredentials(ctx context.Context, p Principal, newUsername, newPassword string) (Tokens, error) {
u, err := s.store.GetUserByID(ctx, p.UserID)
if err != nil {
return Tokens{}, err
}
if !u.MustChangeCredentials {
return Tokens{}, apperr.Conflict(CodeBootstrapNotRequired, "credentials already changed")
}
username := NormalizeUsername(newUsername)
if err := ValidateUsername("new_username", username); err != nil {
return Tokens{}, err
}
if err := s.checkNewPassword(u, username, newPassword); err != nil {
return Tokens{}, err
}
hash, err := s.hasher.Hash(newPassword)
if err != nil {
return Tokens{}, err
}
audit := &AuditEntry{
ActorID: &u.ID, Action: "user.bootstrap_credentials", TargetType: "user", TargetID: &u.ID,
Payload: map[string]any{"username": username},
}
out, err := s.replaceSessions(ctx, p, u, func(tx Store) error {
if err := tx.SetCredentials(ctx, u.ID, username, hash); err != nil {
return err
}
return tx.SetSetting(ctx, settingBootstrapCompleted, true)
}, audit)
if errors.Is(err, ErrUsernameTaken) {
return Tokens{}, apperr.Validation("new_username", CodeUsernameTaken, "username is taken", nil)
}
return out, err
}
// ListSessions — активные сессии пользователя, новые сверху.
func (s *Service) ListSessions(ctx context.Context, p Principal) ([]Session, error) {
return s.store.ListSessions(ctx, p.UserID, s.clock.Now())
}
// RevokeSession завершает одну свою сессию.
func (s *Service) RevokeSession(ctx context.Context, p Principal, id uuid.UUID) error {
now := s.clock.Now()
sessions, err := s.store.ListSessions(ctx, p.UserID, now)
if err != nil {
return err
}
for _, ss := range sessions {
if ss.ID == id {
return s.store.RevokeFamily(ctx, id, now)
}
}
return apperr.NotFound(CodeSessionNotFound, "session not found")
}
// BootstrapRequired — первый вход администратора ещё не завершён.
func (s *Service) BootstrapRequired(ctx context.Context) (bool, error) {
done, err := s.store.GetSettingBool(ctx, settingBootstrapCompleted)
return !done, err
}
// Bootstrap создаёт первого администратора, если его нет и первый запуск не завершён.
// Сгенерированный пароль печатается в лог один раз.
func (s *Service) Bootstrap(ctx context.Context) error {
done, err := s.store.GetSettingBool(ctx, settingBootstrapCompleted)
if err != nil || done {
return err
}
admins, err := s.store.CountAdmins(ctx)
if err != nil || admins > 0 {
return err
}
username := NormalizeUsername(s.cfg.BootstrapUsername)
if err := ValidateUsername("BOOTSTRAP_ADMIN_USERNAME", username); err != nil {
return fmt.Errorf("auth: bootstrap: %w", err)
}
password, generated := s.cfg.BootstrapPassword, false
if password == "" {
if password, err = GeneratePassword(20); err != nil {
return err
}
generated = true
}
hash, err := s.hasher.Hash(password)
if err != nil {
return err
}
u := User{
ID: s.ids.New(), Username: username, PasswordHash: hash, Role: RoleAdmin, Locale: "ru",
MustChangeCredentials: true, CreatedAt: s.clock.Now(),
}
err = s.store.WithTx(ctx, func(tx Store) error {
if err := tx.CreateUser(ctx, u, nil); err != nil {
return err
}
return tx.InsertAudit(ctx, s.ids.New(), AuditEntry{
Action: "user.bootstrap_create", TargetType: "user", TargetID: &u.ID,
Payload: map[string]any{"username": username},
})
})
if err != nil {
return fmt.Errorf("auth: bootstrap: %w", err)
}
if generated {
// Единственное место, где пароль попадает в лог: админ увидит его в `docker compose logs api`.
s.log.Warn("bootstrap admin created: sign in and change the credentials",
"username", username, "password", password)
} else {
s.log.Warn("bootstrap admin created with BOOTSTRAP_ADMIN_PASSWORD: sign in and change the credentials",
"username", username)
}
return nil
}
+79
View File
@@ -0,0 +1,79 @@
package auth
import (
"context"
"errors"
"time"
"github.com/google/uuid"
)
// Ошибки хранилища.
var (
ErrNotFound = errors.New("auth: not found")
ErrUsernameTaken = errors.New("auth: username taken")
)
// ProfileUpdate — изменяемые пользователем поля профиля. nil — не менять.
type ProfileUpdate struct {
DisplayName **string // *nil — сбросить имя
Locale *string
UIPrefs *UIPrefs
}
// Store — доступ к users, refresh_tokens, audit_log и app_settings.
type Store interface {
// WithTx выполняет fn в транзакции; Store внутри fn работает в ней же.
WithTx(ctx context.Context, fn func(Store) error) error
CountAdmins(ctx context.Context) (int, error)
CreateUser(ctx context.Context, u User, createdBy *uuid.UUID) error
GetUserByID(ctx context.Context, id uuid.UUID) (User, error)
GetUserByUsername(ctx context.Context, username string) (User, error)
RecordLoginFailure(ctx context.Context, id uuid.UUID, failedCount int, lockedUntil *time.Time) error
// RecordLoginSuccess сбрасывает счётчик неудач; newHash != nil — пересчитанный хэш пароля.
RecordLoginSuccess(ctx context.Context, id uuid.UUID, at time.Time, newHash *string) error
// SetPassword меняет хэш, снимает must_change_password и увеличивает token_version.
SetPassword(ctx context.Context, id uuid.UUID, hash string) error
// SetCredentials меняет логин и хэш, снимает must_change_credentials и увеличивает token_version.
SetCredentials(ctx context.Context, id uuid.UUID, username, hash string) error
UpdateProfile(ctx context.Context, id uuid.UUID, upd ProfileUpdate) (User, error)
InsertRefreshToken(ctx context.Context, t RefreshToken) error
// GetRefreshTokenForUpdate находит токен по хэшу и блокирует строку до конца транзакции.
GetRefreshTokenForUpdate(ctx context.Context, hash []byte) (RefreshToken, error)
MarkRefreshTokenUsed(ctx context.Context, id uuid.UUID, at time.Time) error
RevokeFamily(ctx context.Context, familyID uuid.UUID, at time.Time) error
// RevokeUserSessions отзывает все семьи пользователя.
RevokeUserSessions(ctx context.Context, userID uuid.UUID, at time.Time) error
// ListSessions — активные семьи пользователя: есть неиспользованный, неотозванный, не истёкший токен.
ListSessions(ctx context.Context, userID uuid.UUID, now time.Time) ([]Session, error)
InsertAudit(ctx context.Context, id uuid.UUID, e AuditEntry) error
GetSettingBool(ctx context.Context, key string) (bool, error)
SetSetting(ctx context.Context, key string, value any) error
}
// Limiter ограничивает частоту попыток входа.
type Limiter interface {
// Allow учитывает попытку по ключу. retryAfter — секунды до сброса окна, если попытка отклонена.
Allow(ctx context.Context, key string, limit int, window time.Duration) (ok bool, retryAfter int, err error)
}
// Clock — источник времени (подменяется в тестах).
type Clock interface{ Now() time.Time }
// IDGen — генератор UUIDv7.
type IDGen interface{ New() uuid.UUID }
// SystemClock — реальное время в UTC.
type SystemClock struct{}
// Now возвращает текущее время.
func (SystemClock) Now() time.Time { return time.Now().UTC() }
// UUIDv7 — генератор сортируемых по времени UUID.
type UUIDv7 struct{}
// New возвращает новый UUIDv7.
func (UUIDv7) New() uuid.UUID { return uuid.Must(uuid.NewV7()) }
+169
View File
@@ -0,0 +1,169 @@
package auth
import (
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
)
// Ошибки проверки access-токена.
var (
ErrTokenExpired = errors.New("auth: token expired")
ErrTokenInvalid = errors.New("auth: token invalid")
)
// AccessClaims — содержимое access-токена.
type AccessClaims struct {
UserID uuid.UUID
Role Role
SessionID uuid.UUID
TokenVersion int
IssuedAt time.Time
ExpiresAt time.Time
}
type jwtClaims struct {
Role string `json:"role"`
SID string `json:"sid"`
Ver int `json:"ver"`
jwt.RegisteredClaims
}
// TokenIssuer подписывает и проверяет access-токены (JWT, EdDSA). Подписывает активным ключом,
// проверяет активным и предыдущими (по kid) — так ключ можно сменить, не разлогинив всех.
type TokenIssuer struct {
kid string
key ed25519.PrivateKey
verify map[string]ed25519.PublicKey
ttl time.Duration
now func() time.Time
}
// NewTokenIssuer создаёт issuer. previous — ключи, которыми подписаны ещё живые токены.
func NewTokenIssuer(active ed25519.PrivateKey, previous []ed25519.PrivateKey, ttl time.Duration, now func() time.Time) *TokenIssuer {
t := &TokenIssuer{key: active, ttl: ttl, now: now, verify: map[string]ed25519.PublicKey{}}
t.kid = KeyID(active.Public().(ed25519.PublicKey))
t.verify[t.kid] = active.Public().(ed25519.PublicKey)
for _, k := range previous {
pub := k.Public().(ed25519.PublicKey)
t.verify[KeyID(pub)] = pub
}
return t
}
// TTL — срок жизни access-токена.
func (t *TokenIssuer) TTL() time.Duration { return t.ttl }
// KeyID — идентификатор ключа: первые 8 байт SHA-256 публичного ключа.
func KeyID(pub ed25519.PublicKey) string {
sum := sha256.Sum256(pub)
return hex.EncodeToString(sum[:8])
}
// Issue выпускает access-токен.
func (t *TokenIssuer) Issue(userID uuid.UUID, role Role, sessionID uuid.UUID, tokenVersion int) (string, error) {
now := t.now()
tok := jwt.NewWithClaims(jwt.SigningMethodEdDSA, jwtClaims{
Role: string(role),
SID: sessionID.String(),
Ver: tokenVersion,
RegisteredClaims: jwt.RegisteredClaims{
Subject: userID.String(),
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(t.ttl)),
},
})
tok.Header["kid"] = t.kid
s, err := tok.SignedString(t.key)
if err != nil {
return "", fmt.Errorf("auth: sign token: %w", err)
}
return s, nil
}
// Parse проверяет подпись и срок токена.
func (t *TokenIssuer) Parse(s string) (AccessClaims, error) {
var c jwtClaims
_, err := jwt.ParseWithClaims(s, &c, func(tok *jwt.Token) (any, error) {
kid, _ := tok.Header["kid"].(string)
pub, ok := t.verify[kid]
if !ok {
return nil, ErrTokenInvalid
}
return pub, nil
}, jwt.WithValidMethods([]string{jwt.SigningMethodEdDSA.Alg()}), jwt.WithTimeFunc(t.now),
jwt.WithExpirationRequired(), jwt.WithIssuedAt())
if err != nil {
if errors.Is(err, jwt.ErrTokenExpired) {
return AccessClaims{}, ErrTokenExpired
}
return AccessClaims{}, ErrTokenInvalid
}
uid, err1 := uuid.Parse(c.Subject)
sid, err2 := uuid.Parse(c.SID)
if err1 != nil || err2 != nil || !Role(c.Role).Valid() {
return AccessClaims{}, ErrTokenInvalid
}
return AccessClaims{
UserID: uid, Role: Role(c.Role), SessionID: sid, TokenVersion: c.Ver,
IssuedAt: c.IssuedAt.Time, ExpiresAt: c.ExpiresAt.Time,
}, nil
}
// ParsePrivateKey читает ключ Ed25519 из base64 (стандартный или url-алфавит): 32-байтовый seed
// или 64-байтовый приватный ключ.
func ParsePrivateKey(s string) (ed25519.PrivateKey, error) {
s = strings.TrimSpace(s)
var raw []byte
var err error
for _, enc := range []*base64.Encoding{base64.StdEncoding, base64.RawStdEncoding, base64.URLEncoding, base64.RawURLEncoding} {
if raw, err = enc.DecodeString(s); err == nil {
break
}
}
if err != nil {
return nil, fmt.Errorf("auth: jwt key is not base64: %w", err)
}
switch len(raw) {
case ed25519.SeedSize:
return ed25519.NewKeyFromSeed(raw), nil
case ed25519.PrivateKeySize:
return ed25519.PrivateKey(raw), nil
default:
return nil, fmt.Errorf("auth: jwt key must be %d or %d bytes, got %d", ed25519.SeedSize, ed25519.PrivateKeySize, len(raw))
}
}
// GenerateKey создаёт новый ключ Ed25519.
func GenerateKey() (ed25519.PrivateKey, error) {
_, k, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return nil, fmt.Errorf("auth: generate key: %w", err)
}
return k, nil
}
// NewRefreshToken возвращает случайный refresh-токен (32 байта, base64url) и его SHA-256 для БД.
func NewRefreshToken() (token string, hash []byte, err error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", nil, fmt.Errorf("auth: refresh token: %w", err)
}
token = base64.RawURLEncoding.EncodeToString(buf)
return token, HashRefreshToken(token), nil
}
// HashRefreshToken — SHA-256 от строки токена.
func HashRefreshToken(token string) []byte {
sum := sha256.Sum256([]byte(token))
return sum[:]
}
+146
View File
@@ -0,0 +1,146 @@
package auth
import (
_ "embed"
"regexp"
"strings"
"unicode/utf8"
"pipu/api/internal/platform/apperr"
)
// Коды ошибок валидации (переводятся клиентом, packages/i18n).
const (
CodePasswordTooShort = "validation.password_too_short"
CodePasswordTooLong = "validation.password_too_long"
CodePasswordSameAsUsername = "validation.password_same_as_username"
CodePasswordTooCommon = "validation.password_too_common"
CodePasswordUnchanged = "validation.password_unchanged"
CodeUsernameInvalid = "validation.username_invalid"
CodeLocaleUnsupported = "validation.locale_unsupported"
CodeDisplayNameTooLong = "validation.display_name_too_long"
CodeUIPrefsInvalid = "validation.ui_prefs_invalid"
)
// Пределы длины. Верхний предел пароля защищает argon2 от гигантских входов.
const (
MinPasswordUser = 8
MinPasswordPrivileged = 12
MaxPassword = 256
MaxDisplayName = 64
)
var usernameRe = regexp.MustCompile(`^[a-z0-9._-]{3,32}$`)
// NormalizeUsername приводит логин к нижнему регистру без пробелов по краям.
func NormalizeUsername(s string) string { return strings.ToLower(strings.TrimSpace(s)) }
// ValidateUsername проверяет нормализованный логин: 3..32 символа [a-z0-9._-].
func ValidateUsername(field, username string) error {
if !usernameRe.MatchString(username) {
return apperr.Validation(field, CodeUsernameInvalid, "username must be 3-32 chars of [a-z0-9._-]",
map[string]any{"min": 3, "max": 32})
}
return nil
}
// MinPasswordLen — минимальная длина пароля для роли.
func MinPasswordLen(r Role) int {
if r == RoleUser {
return MinPasswordUser
}
return MinPasswordPrivileged
}
// ValidatePassword проверяет новый пароль по политике: длина по роли, не совпадает с логином, не из списка частых.
func ValidatePassword(field, password, username string, role Role) error {
n := utf8.RuneCountInString(password)
if minLen := MinPasswordLen(role); n < minLen {
return apperr.Validation(field, CodePasswordTooShort, "password is too short", map[string]any{"min": minLen})
}
if n > MaxPassword {
return apperr.Validation(field, CodePasswordTooLong, "password is too long", map[string]any{"max": MaxPassword})
}
lower := strings.ToLower(password)
if username != "" && lower == strings.ToLower(username) {
return apperr.Validation(field, CodePasswordSameAsUsername, "password equals username", nil)
}
if _, ok := commonPasswords[lower]; ok {
return apperr.Validation(field, CodePasswordTooCommon, "password is too common", nil)
}
return nil
}
//go:embed common_passwords.txt
var commonPasswordsTxt string
var commonPasswords = func() map[string]struct{} {
m := make(map[string]struct{})
for line := range strings.SplitSeq(commonPasswordsTxt, "\n") {
line = strings.TrimSpace(line)
if line != "" && !strings.HasPrefix(line, "#") {
m[strings.ToLower(line)] = struct{}{}
}
}
return m
}()
var uiPrefEnums = map[string][]string{
"theme": {"dark", "light", "system"},
"wave_variant": {"orb", "liquid"},
"web_player": {"bar", "island"},
"mobile_player": {"cover", "wave"},
}
// ValidateUIPrefs проверяет значения перечислений и диапазон кроссфейда.
func ValidateUIPrefs(p UIPrefs) error {
check := func(name string, v *string) error {
if v == nil {
return nil
}
for _, allowed := range uiPrefEnums[name] {
if *v == allowed {
return nil
}
}
return apperr.Validation("ui_prefs."+name, CodeUIPrefsInvalid, "unknown value", nil)
}
for name, v := range map[string]*string{
"theme": p.Theme, "wave_variant": p.WaveVariant, "web_player": p.WebPlayer, "mobile_player": p.MobilePlayer,
} {
if err := check(name, v); err != nil {
return err
}
}
if p.CrossfadeS != nil && (*p.CrossfadeS < 0 || *p.CrossfadeS > 6) {
return apperr.Validation("ui_prefs.crossfade_s", CodeUIPrefsInvalid, "crossfade must be 0..6",
map[string]any{"min": 0, "max": 6})
}
return nil
}
// MergeUIPrefs накладывает заданные поля patch на base.
func MergeUIPrefs(base, patch UIPrefs) UIPrefs {
if patch.Theme != nil {
base.Theme = patch.Theme
}
if patch.WaveVariant != nil {
base.WaveVariant = patch.WaveVariant
}
if patch.WebPlayer != nil {
base.WebPlayer = patch.WebPlayer
}
if patch.MobilePlayer != nil {
base.MobilePlayer = patch.MobilePlayer
}
if patch.Ambient != nil {
base.Ambient = patch.Ambient
}
if patch.CrossfadeS != nil {
base.CrossfadeS = patch.CrossfadeS
}
if patch.Normalize != nil {
base.Normalize = patch.Normalize
}
return base
}
@@ -0,0 +1,71 @@
// Package apperr — доменные ошибки с машинным кодом для i18n. В problem+json их переводит
// единственное место — httpx.ResponseError; модули только возвращают *Error.
package apperr
import (
"fmt"
"net/http"
)
// FieldError — ошибка конкретного поля запроса.
type FieldError struct {
Field string `json:"field"`
Code string `json:"code"`
}
// Error — ошибка, которую можно показать клиенту: HTTP-статус, код и параметры для перевода.
type Error struct {
Status int
Code string
Detail string
Params map[string]any
Fields []FieldError
// RetryAfter — секунды для заголовка Retry-After (429).
RetryAfter int
}
func (e *Error) Error() string {
if e.Detail != "" {
return fmt.Sprintf("%s: %s", e.Code, e.Detail)
}
return e.Code
}
// Is сравнивает ошибки по коду: errors.Is(err, apperr.Unauthorized("auth.invalid_credentials", "")).
func (e *Error) Is(target error) bool {
t, ok := target.(*Error)
return ok && t.Code == e.Code
}
// New создаёт ошибку с произвольным статусом.
func New(status int, code, detail string) *Error {
return &Error{Status: status, Code: code, Detail: detail}
}
// Unauthorized — 401.
func Unauthorized(code, detail string) *Error { return New(http.StatusUnauthorized, code, detail) }
// Forbidden — 403.
func Forbidden(code, detail string) *Error { return New(http.StatusForbidden, code, detail) }
// NotFound — 404.
func NotFound(code, detail string) *Error { return New(http.StatusNotFound, code, detail) }
// Conflict — 409.
func Conflict(code, detail string) *Error { return New(http.StatusConflict, code, detail) }
// Validation — 422 с ошибкой одного поля.
func Validation(field, code, detail string, params map[string]any) *Error {
return &Error{
Status: http.StatusUnprocessableEntity,
Code: code,
Detail: detail,
Params: params,
Fields: []FieldError{{Field: field, Code: code}},
}
}
// TooManyRequests — 429 с Retry-After.
func TooManyRequests(code string, retryAfterSec int) *Error {
return &Error{Status: http.StatusTooManyRequests, Code: code, Detail: "rate limited", RetryAfter: retryAfterSec}
}
@@ -19,6 +19,18 @@ type API struct {
SupportedLocales []string `env:"SUPPORTED_LOCALES" envDefault:"ru,en" envSeparator:","`
MinAndroidVersionCode int `env:"MIN_ANDROID_VERSION_CODE" envDefault:"0"`
// JWTPrivateKey — Ed25519 (base64: 32-байтовый seed или 64-байтовый ключ). Пусто — случайный ключ
// на время жизни процесса: после перезапуска всем придётся обновить access-токен (refresh остаётся).
JWTPrivateKey string `env:"JWT_PRIVATE_KEY"`
// JWTPreviousKeys — прежние ключи через запятую: токены, подписанные ими, ещё принимаются.
JWTPreviousKeys []string `env:"JWT_PREVIOUS_KEYS" envSeparator:","`
AccessTokenTTL time.Duration `env:"ACCESS_TOKEN_TTL" envDefault:"15m"`
// CookieSecure — флаг Secure у refresh-cookie. Отключать только для разработки по http не на localhost.
CookieSecure bool `env:"COOKIE_SECURE" envDefault:"true"`
BootstrapAdminUsername string `env:"BOOTSTRAP_ADMIN_USERNAME" envDefault:"admin"`
BootstrapAdminPassword string `env:"BOOTSTRAP_ADMIN_PASSWORD"`
}
// StorageAgent — конфигурация внутреннего сервиса storage-agent.
+34 -12
View File
@@ -3,24 +3,49 @@ package httpx
import (
"encoding/json"
"errors"
"log/slog"
"net/http"
"strconv"
"pipu/api/internal/platform/apperr"
)
// Problem — тело ответа application/problem+json. Поле Code переводится клиентом через i18n.
type Problem struct {
Type string `json:"type"`
Status int `json:"status"`
Code string `json:"code"`
Detail string `json:"detail,omitempty"`
Params map[string]any `json:"params,omitempty"`
Type string `json:"type"`
Status int `json:"status"`
Code string `json:"code"`
Detail string `json:"detail,omitempty"`
Params map[string]any `json:"params,omitempty"`
Errors []apperr.FieldError `json:"errors,omitempty"`
}
// WriteProblem пишет ответ об ошибке.
func WriteProblem(w http.ResponseWriter, status int, code, detail string) {
writeProblem(w, Problem{Type: "about:blank", Status: status, Code: code, Detail: detail})
}
func writeProblem(w http.ResponseWriter, p Problem) {
w.Header().Set("Content-Type", "application/problem+json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(Problem{Type: "about:blank", Status: status, Code: code, Detail: detail})
w.WriteHeader(p.Status)
_ = json.NewEncoder(w).Encode(p)
}
// WriteError пишет доменную ошибку *apperr.Error; любую другую — как 500 (подробности в лог).
func WriteError(log *slog.Logger, w http.ResponseWriter, r *http.Request, err error) {
var ae *apperr.Error
if !errors.As(err, &ae) {
log.ErrorContext(r.Context(), "handler failed", "path", r.URL.Path, "err", err)
WriteProblem(w, http.StatusInternalServerError, "common.internal", "internal error")
return
}
if ae.RetryAfter > 0 {
w.Header().Set("Retry-After", strconv.Itoa(ae.RetryAfter))
}
writeProblem(w, Problem{
Type: "about:blank", Status: ae.Status, Code: ae.Code, Detail: ae.Detail, Params: ae.Params, Errors: ae.Fields,
})
}
// NotFound — обработчик неизвестных путей.
@@ -38,10 +63,7 @@ func RequestError(w http.ResponseWriter, _ *http.Request, err error) {
WriteProblem(w, http.StatusBadRequest, "common.bad_request", err.Error())
}
// ResponseError — непредвиденная ошибка обработчика: детали только в лог.
// ResponseError — ошибка обработчика: доменные — как есть, прочие — 500 с деталями только в логе.
func ResponseError(log *slog.Logger) func(http.ResponseWriter, *http.Request, error) {
return func(w http.ResponseWriter, r *http.Request, err error) {
log.ErrorContext(r.Context(), "handler failed", "path", r.URL.Path, "err", err)
WriteProblem(w, http.StatusInternalServerError, "common.internal", "internal error")
}
return func(w http.ResponseWriter, r *http.Request, err error) { WriteError(log, w, r, err) }
}
+17 -3
View File
@@ -9,6 +9,7 @@ import (
"github.com/go-chi/chi/v5/middleware"
"pipu/api/internal/api/gen"
"pipu/api/internal/auth"
"pipu/api/internal/platform/httpx"
"pipu/api/internal/system"
)
@@ -17,12 +18,22 @@ import (
type Deps struct {
Log *slog.Logger
System *system.Handler
Auth *auth.Handler
// AuthService проверяет access-токены в middleware. nil — только для тестов служебных эндпоинтов.
AuthService *auth.Service
}
// handlers объединяет модули в единую реализацию gen.StrictServerInterface.
// По мере появления модулей (auth, catalog, ...) они встраиваются сюда.
// По мере появления модулей (catalog, ...) они встраиваются сюда.
// Алиасы дают встроенным полям разные имена: у модулей одинаковое имя типа Handler.
type (
systemHandler = system.Handler
authHandler = auth.Handler
)
type handlers struct {
*system.Handler
*systemHandler
*authHandler
}
var _ gen.StrictServerInterface = handlers{}
@@ -36,10 +47,13 @@ func NewRouter(d Deps) http.Handler {
r := chi.NewRouter()
r.Use(middleware.RequestID, httpx.RealIP, httpx.AccessLog(log), middleware.Recoverer)
if d.AuthService != nil {
r.Use(auth.Middleware(d.AuthService, log))
}
r.NotFound(httpx.NotFound)
r.MethodNotAllowed(httpx.MethodNotAllowed)
strict := gen.NewStrictHandlerWithOptions(handlers{Handler: d.System}, nil, gen.StrictHTTPServerOptions{
strict := gen.NewStrictHandlerWithOptions(handlers{systemHandler: d.System, authHandler: d.Auth}, nil, gen.StrictHTTPServerOptions{
RequestErrorHandlerFunc: httpx.RequestError,
ResponseErrorHandlerFunc: httpx.ResponseError(log),
})
+11 -1
View File
@@ -26,6 +26,8 @@ type Info struct {
Version string
SupportedLocales []string
MinAndroidVersionCode int
// BootstrapRequired сообщает, что первый вход администратора не завершён (nil — false).
BootstrapRequired func(ctx context.Context) (bool, error)
}
// Handler реализует системную часть gen.StrictServerInterface.
@@ -86,8 +88,16 @@ func (h *Handler) GetReadyz(ctx context.Context, _ gen.GetReadyzRequestObject) (
}
// GetServerInfo отдаёт сведения о сервере для экрана подключения клиентов.
func (h *Handler) GetServerInfo(context.Context, gen.GetServerInfoRequestObject) (gen.GetServerInfoResponseObject, error) {
func (h *Handler) GetServerInfo(ctx context.Context, _ gen.GetServerInfoRequestObject) (gen.GetServerInfoResponseObject, error) {
var bootstrap bool
if h.info.BootstrapRequired != nil {
var err error
if bootstrap, err = h.info.BootstrapRequired(ctx); err != nil {
return nil, err
}
}
return gen.GetServerInfo200JSONResponse{
BootstrapRequired: bootstrap,
Name: "pipu-music",
Version: h.info.Version,
ApiVersion: "v1",
+55
View File
@@ -0,0 +1,55 @@
-- +goose Up
CREATE TYPE user_role AS ENUM ('admin', 'moderator', 'user');
CREATE TABLE users (
id uuid PRIMARY KEY,
username citext NOT NULL UNIQUE,
display_name text,
password_hash text NOT NULL,
role user_role NOT NULL DEFAULT 'user',
locale text NOT NULL DEFAULT 'ru',
ui_prefs jsonb NOT NULL DEFAULT '{}',
must_change_password boolean NOT NULL DEFAULT false,
must_change_credentials boolean NOT NULL DEFAULT false,
is_blocked boolean NOT NULL DEFAULT false,
token_version int NOT NULL DEFAULT 0,
failed_login_count int NOT NULL DEFAULT 0,
locked_until timestamptz,
created_by uuid REFERENCES users(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
last_login_at timestamptz
);
CREATE TABLE refresh_tokens (
id uuid PRIMARY KEY,
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
family_id uuid NOT NULL,
family_started_at timestamptz NOT NULL,
token_hash bytea NOT NULL UNIQUE,
device_name text,
client_type text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL,
used_at timestamptz,
revoked_at timestamptz
);
CREATE INDEX refresh_tokens_user_active_idx ON refresh_tokens (user_id) WHERE revoked_at IS NULL;
CREATE INDEX refresh_tokens_family_idx ON refresh_tokens (family_id);
CREATE TABLE audit_log (
id uuid PRIMARY KEY,
actor_id uuid REFERENCES users(id) ON DELETE SET NULL,
action text NOT NULL,
target_type text,
target_id uuid,
payload jsonb,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX audit_log_created_idx ON audit_log (created_at DESC, id DESC);
-- +goose Down
DROP TABLE audit_log;
DROP TABLE refresh_tokens;
DROP TABLE users;
DROP TYPE user_role;