feat(api): sign-in, refresh rotation and bootstrap admin (M1)
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m15s
ci / api / api (push) Successful in 1m31s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 47s
ci / ts / ts (packages + web) (push) Successful in 39s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 26s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m15s
ci / api / api (push) Successful in 1m31s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 47s
ci / ts / ts (packages + web) (push) Successful in 39s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 26s
- users, refresh_tokens and audit_log schema (migration 00002) - argon2id passwords with rehash on login, password policy, lockout with exponential back-off and a per-IP rate limit in Redis - Ed25519 JWT access tokens with kid-based key rotation; rotating refresh tokens with reuse detection (cookie for web, body for Android) - bootstrap admin on first start; must_change_credentials and must_change_password gate every other endpoint - /auth/login, /auth/refresh, /auth/logout(-all), /me, /me/password, /me/bootstrap-credentials, /me/sessions; server-info reports bootstrap_required - role policy from docs/auth-and-roles.md with table tests - problem+json errors with field errors; ru/en translations of the codes - install.sh and update.sh generate JWT_PRIVATE_KEY Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b6def39e29
commit
ed4a492dba
37 files changed
+5826
-49
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) }
|
||||
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()) }
|
||||
@@ -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[:]
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
Reference in new issue
Block a user