ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m17s
ci / ts / ts (packages + web) (push) Successful in 3m13s
ci / api / api (push) Successful in 59s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m31s
ci / db / db (push) Successful in 2m50s
140 lines
4.4 KiB
Go
140 lines
4.4 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
|
|
"pipu/api/internal/platform/apperr"
|
|
)
|
|
|
|
const MaxArchivedUsers = 10000
|
|
const MaxUserArchiveBytes = 16 << 20
|
|
|
|
// ArchivedUser is deliberately limited to identity, credentials and access rights.
|
|
// IDs, sessions and personal music data never cross the transfer boundary.
|
|
type ArchivedUser struct {
|
|
Username string
|
|
DisplayName *string
|
|
Role Role
|
|
PasswordHash string
|
|
IsBlocked bool
|
|
MustChangePassword bool
|
|
MustChangeCredentials bool
|
|
ReviewsUploads bool
|
|
ManagesUpdates bool
|
|
}
|
|
|
|
func archiveInvalid() error {
|
|
return apperr.Validation("file", "validation.user_archive_invalid", "invalid user archive", nil)
|
|
}
|
|
|
|
func (s *Service) ExportUsers(ctx context.Context, p Principal) ([]ArchivedUser, error) {
|
|
if p.Role != RoleAdmin {
|
|
return nil, forbidden()
|
|
}
|
|
out := make([]ArchivedUser, 0)
|
|
err := s.store.WithTx(ctx, func(tx Store) error {
|
|
after := ""
|
|
for {
|
|
users, err := tx.ListUsers(ctx, UserFilter{After: after, Limit: 200})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(users) == 0 {
|
|
break
|
|
}
|
|
for _, u := range users {
|
|
if len(out) == MaxArchivedUsers {
|
|
return archiveInvalid()
|
|
}
|
|
out = append(out, ArchivedUser{
|
|
Username: u.Username, DisplayName: u.DisplayName, Role: u.Role, PasswordHash: u.PasswordHash,
|
|
IsBlocked: u.IsBlocked, MustChangePassword: u.MustChangePassword,
|
|
MustChangeCredentials: u.MustChangeCredentials, ReviewsUploads: u.ReviewsUploads, ManagesUpdates: u.ManagesUpdates,
|
|
})
|
|
}
|
|
after = users[len(users)-1].Username
|
|
}
|
|
return tx.InsertAudit(ctx, s.ids.New(), AuditEntry{
|
|
ActorID: &p.UserID, Action: "users.export", TargetType: "users", Payload: map[string]any{"count": len(out)},
|
|
})
|
|
})
|
|
return out, err
|
|
}
|
|
|
|
// ImportUsers creates all missing accounts in one transaction. Existing accounts
|
|
// retain their passwords, roles, sessions and personal music data.
|
|
func (s *Service) ImportUsers(ctx context.Context, p Principal, version int, entries []ArchivedUser) (int, []string, error) {
|
|
if p.Role != RoleAdmin {
|
|
return 0, nil, forbidden()
|
|
}
|
|
if version != 1 || len(entries) > MaxArchivedUsers {
|
|
return 0, nil, archiveInvalid()
|
|
}
|
|
seen := make(map[string]bool, len(entries))
|
|
for _, a := range entries {
|
|
username := NormalizeUsername(a.Username)
|
|
if username != a.Username || ValidateUsername("username", username) != nil || seen[username] || !a.Role.Valid() {
|
|
return 0, nil, archiveInvalid()
|
|
}
|
|
seen[username] = true
|
|
if a.DisplayName != nil {
|
|
if _, err := displayName(*a.DisplayName); err != nil {
|
|
return 0, nil, archiveInvalid()
|
|
}
|
|
}
|
|
if len(a.PasswordHash) > 512 {
|
|
return 0, nil, archiveInvalid()
|
|
}
|
|
params, salt, key, err := decodePHC(a.PasswordHash)
|
|
// Imported PHC parameters are used on the next login; bound CPU and RAM
|
|
// before accepting them, without performing expensive password hashing.
|
|
if err != nil || params.Threads == 0 || params.Threads > 16 || params.Time == 0 || params.Time > 10 ||
|
|
params.MemoryKiB < 8*uint32(params.Threads) || params.MemoryKiB > 256*1024 ||
|
|
len(salt) < 8 || len(salt) > 64 || len(key) < 16 || len(key) > 64 {
|
|
return 0, nil, archiveInvalid()
|
|
}
|
|
}
|
|
created := 0
|
|
skipped := make([]string, 0)
|
|
err := s.store.WithTx(ctx, func(tx Store) error {
|
|
for _, a := range entries {
|
|
_, err := tx.GetUserByUsername(ctx, a.Username)
|
|
if err == nil {
|
|
skipped = append(skipped, a.Username)
|
|
continue
|
|
}
|
|
if !errors.Is(err, ErrNotFound) {
|
|
return err
|
|
}
|
|
locale := "ru"
|
|
if len(s.cfg.SupportedLocales) > 0 {
|
|
locale = s.cfg.SupportedLocales[0]
|
|
}
|
|
u := User{
|
|
ID: s.ids.New(), Username: a.Username, DisplayName: a.DisplayName, PasswordHash: a.PasswordHash,
|
|
Role: a.Role, Locale: locale, CreatedAt: s.clock.Now(), MustChangePassword: a.MustChangePassword,
|
|
MustChangeCredentials: a.MustChangeCredentials, ReviewsUploads: a.ReviewsUploads, ManagesUpdates: a.ManagesUpdates,
|
|
}
|
|
if err := tx.CreateUser(ctx, u, &p.UserID); err != nil {
|
|
return err
|
|
}
|
|
if a.IsBlocked {
|
|
blocked := true
|
|
if _, err := tx.UpdateUser(ctx, u.ID, UserUpdate{IsBlocked: &blocked}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
created++
|
|
}
|
|
return tx.InsertAudit(ctx, s.ids.New(), AuditEntry{
|
|
ActorID: &p.UserID, Action: "users.import", TargetType: "users",
|
|
Payload: map[string]any{"created": created, "skipped": len(skipped)},
|
|
})
|
|
})
|
|
if err != nil {
|
|
return 0, nil, err
|
|
}
|
|
return created, skipped, nil
|
|
}
|