package storage import ( "context" "crypto/rand" "encoding/base64" "errors" "time" "github.com/jackc/pgx/v5" "golang.org/x/crypto/bcrypt" ) var ErrBadCredentials = errors.New("bad credentials") const sessionTTL = 12 * time.Hour type AdminUser struct { ID int64 `json:"id"` Email string `json:"email"` Name string `json:"name"` Role string `json:"role"` } // EnsureAdmin создаёт первого администратора, если таблица пуста. func (s *Storage) EnsureAdmin(ctx context.Context, email, password string) error { if email == "" || password == "" { return nil } var n int if err := s.pool.QueryRow(ctx, `SELECT count(*) FROM admin_users`).Scan(&n); err != nil { return err } if n > 0 { return nil } hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { return err } _, err = s.pool.Exec(ctx, `INSERT INTO admin_users (email, password_hash, name, role) VALUES ($1,$2,'Администратор','owner')`, email, string(hash)) return err } // Login проверяет пароль и заводит сессию, возвращая её токен. func (s *Storage) Login(ctx context.Context, email, password, ip, ua string) (string, *AdminUser, error) { var ( u AdminUser hash string ) err := s.pool.QueryRow(ctx, `SELECT id, email, name, role, password_hash FROM admin_users WHERE email = $1 AND active`, email).Scan(&u.ID, &u.Email, &u.Name, &u.Role, &hash) if errors.Is(err, pgx.ErrNoRows) { return "", nil, ErrBadCredentials } if err != nil { return "", nil, err } if bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) != nil { return "", nil, ErrBadCredentials } raw := make([]byte, 32) if _, err := rand.Read(raw); err != nil { return "", nil, err } token := base64.RawURLEncoding.EncodeToString(raw) var ipVal any if ip != "" { ipVal = ip } if _, err := s.pool.Exec(ctx, `INSERT INTO admin_sessions (token, user_id, expires_at, ip, user_agent) VALUES ($1,$2,$3,$4,$5)`, token, u.ID, time.Now().Add(sessionTTL), ipVal, ua); err != nil { return "", nil, err } _, _ = s.pool.Exec(ctx, `UPDATE admin_users SET last_login_at = now() WHERE id = $1`, u.ID) s.Audit(ctx, u.ID, "login", "admin_user", u.ID, ip) return token, &u, nil } func (s *Storage) Logout(ctx context.Context, token string) error { _, err := s.pool.Exec(ctx, `DELETE FROM admin_sessions WHERE token = $1`, token) return err } // UserByToken возвращает пользователя действующей сессии. func (s *Storage) UserByToken(ctx context.Context, token string) (*AdminUser, error) { var u AdminUser err := s.pool.QueryRow(ctx, ` SELECT u.id, u.email, u.name, u.role FROM admin_sessions s JOIN admin_users u ON u.id = s.user_id WHERE s.token = $1 AND s.expires_at > now() AND u.active`, token). Scan(&u.ID, &u.Email, &u.Name, &u.Role) if errors.Is(err, pgx.ErrNoRows) { return nil, ErrNotFound } if err != nil { return nil, err } return &u, nil } // Audit пишет действие в журнал; ошибки журнала не должны ломать запрос. func (s *Storage) Audit(ctx context.Context, userID int64, action, entity string, entityID int64, ip string) { var ipVal, idVal any if ip != "" { ipVal = ip } if entityID != 0 { idVal = entityID } _, _ = s.pool.Exec(ctx, `INSERT INTO audit_log (user_id, action, entity, entity_id, ip) VALUES ($1,$2,$3,$4,$5)`, userID, action, entity, idVal, ipVal) }