Перейти к содержанию

Этап 3. Реализация WinForms/WPF приложения

Задание

Реализуйте настольное приложение с авторизацией, регистрацией, CAPTCHA и основными разделами предметной области.

Ход работы

На этом этапе создаются сервисы PasswordHasher, CaptchaService, AuthService, формы входа и регистрации, главное окно и первый CRUD-раздел. В примерах используется SQLite и предметная область «Библиотека». Для своего варианта замените книги, авторов и выдачи на сущности из выбранной темы.

Сервисы входа

Пароль сохраняется только в виде хэша и соли:

using System.Security.Cryptography;
using System.Text;

public static class PasswordHasher
{
    public static string GenerateSalt()
    {
        return Convert.ToBase64String(RandomNumberGenerator.GetBytes(16));
    }

    public static string HashPassword(string password, string salt)
    {
        using var sha256 = SHA256.Create();
        byte[] bytes = Encoding.UTF8.GetBytes(password + salt);
        return Convert.ToBase64String(sha256.ComputeHash(bytes));
    }

    public static bool Verify(string password, string salt, string expectedHash)
    {
        return HashPassword(password, salt) == expectedHash;
    }
}

CAPTCHA хранит текущий код и проверяет ввод пользователя:

public class CaptchaService
{
    private readonly Random _random = new Random();
    private const string Alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";

    public string CurrentCode { get; private set; } = "";

    public string Generate()
    {
        CurrentCode = new string(
            Enumerable.Range(0, 5)
                .Select(_ => Alphabet[_random.Next(Alphabet.Length)])
                .ToArray());

        return CurrentCode;
    }

    public bool Validate(string input)
    {
        return string.Equals(CurrentCode, input?.Trim(), StringComparison.OrdinalIgnoreCase);
    }
}

Сервис авторизации отвечает за проверку пользователя, запись попыток входа и временную блокировку:

public class AuthService
{
    private readonly UserRepository _users;
    private int _failedAttempts;
    private DateTime? _blockedUntil;

    public AuthService(UserRepository users)
    {
        _users = users;
    }

    public User Login(string login, string password)
    {
        if (_blockedUntil.HasValue && DateTime.Now < _blockedUntil.Value)
            throw new InvalidOperationException("Вход временно заблокирован.");

        User? user = _users.FindByLogin(login);
        if (user == null || !PasswordHasher.Verify(password, user.PasswordSalt, user.PasswordHash))
        {
            RegisterFailure(login, "Неверный логин или пароль");
            throw new InvalidOperationException("Неверный логин или пароль.");
        }

        _failedAttempts = 0;
        _blockedUntil = null;
        _users.AddLoginAttempt(login, true, "Успешный вход");
        return user;
    }

    private void RegisterFailure(string login, string message)
    {
        _failedAttempts++;
        _users.AddLoginAttempt(login, false, message);

        if (_failedAttempts >= 3)
        {
            _blockedUntil = DateTime.Now.AddSeconds(30);
            _failedAttempts = 0;
        }
    }
}

Репозиторий пользователей

UserRepository работает с таблицами Users, Roles и LoginAttempts. Параметры SQL-запросов защищают приложение от ошибок при вводе кавычек и специальных символов.

Модель пользователя хранит данные, которые нужны после входа:

public class User
{
    public int Id { get; set; }
    public string Login { get; set; } = "";
    public string PasswordHash { get; set; } = "";
    public string PasswordSalt { get; set; } = "";
    public string FullName { get; set; } = "";
    public string RoleName { get; set; } = "";
    public bool IsActive { get; set; }
}
using Microsoft.Data.Sqlite;

public class UserRepository
{
    public User? FindByLogin(string login)
    {
        using var connection = DbConnectionFactory.Create();
        connection.Open();

        using var command = connection.CreateCommand();
        command.CommandText = """
            SELECT u.Id, u.Login, u.PasswordHash, u.PasswordSalt,
                   u.FullName, u.IsActive, r.Name AS RoleName
            FROM Users u
            JOIN Roles r ON r.Id = u.RoleId
            WHERE u.Login = $login
            """;
        command.Parameters.AddWithValue("$login", login);

        using var reader = command.ExecuteReader();
        if (!reader.Read())
            return null;

        return new User
        {
            Id = reader.GetInt32(0),
            Login = reader.GetString(1),
            PasswordHash = reader.GetString(2),
            PasswordSalt = reader.GetString(3),
            FullName = reader.GetString(4),
            IsActive = reader.GetInt32(5) == 1,
            RoleName = reader.GetString(6)
        };
    }

    public bool LoginExists(string login)
    {
        return FindByLogin(login) != null;
    }

    public void CreateUser(string login, string password, string fullName, int roleId)
    {
        string salt = PasswordHasher.GenerateSalt();
        string hash = PasswordHasher.HashPassword(password, salt);

        using var connection = DbConnectionFactory.Create();
        connection.Open();

        using var command = connection.CreateCommand();
        command.CommandText = """
            INSERT INTO Users (Login, PasswordHash, PasswordSalt, FullName, RoleId, IsActive, CreatedAt)
            VALUES ($login, $hash, $salt, $fullName, $roleId, 1, $createdAt)
            """;
        command.Parameters.AddWithValue("$login", login);
        command.Parameters.AddWithValue("$hash", hash);
        command.Parameters.AddWithValue("$salt", salt);
        command.Parameters.AddWithValue("$fullName", fullName);
        command.Parameters.AddWithValue("$roleId", roleId);
        command.Parameters.AddWithValue("$createdAt", DateTime.Now.ToString("s"));
        command.ExecuteNonQuery();
    }

    public void AddLoginAttempt(string login, bool success, string message)
    {
        using var connection = DbConnectionFactory.Create();
        connection.Open();

        using var command = connection.CreateCommand();
        command.CommandText = """
            INSERT INTO LoginAttempts (UserLogin, IsSuccess, Message, CreatedAt)
            VALUES ($login, $success, $message, $createdAt)
            """;
        command.Parameters.AddWithValue("$login", login);
        command.Parameters.AddWithValue("$success", success ? 1 : 0);
        command.Parameters.AddWithValue("$message", message);
        command.Parameters.AddWithValue("$createdAt", DateTime.Now.ToString("s"));
        command.ExecuteNonQuery();
    }
}

Регистрация

В форме регистрации создаются поля ФИО, логина, пароля и подтверждения. Пользователь сохраняется с обычной ролью, например user.

private readonly UserRepository _users = new UserRepository();

private void btnCreateAccount_Click(object sender, EventArgs e)
{
    string login = txtLogin.Text.Trim();
    string password = txtPassword.Text;
    string confirm = txtConfirmPassword.Text;
    string fullName = txtFullName.Text.Trim();

    if (string.IsNullOrWhiteSpace(fullName))
    {
        MessageBox.Show("Введите ФИО.");
        return;
    }

    if (login.Length < 4)
    {
        MessageBox.Show("Логин должен содержать не менее 4 символов.");
        return;
    }

    if (password.Length < 6)
    {
        MessageBox.Show("Пароль должен содержать не менее 6 символов.");
        return;
    }

    if (password != confirm)
    {
        MessageBox.Show("Пароли не совпадают.");
        return;
    }

    if (_users.LoginExists(login))
    {
        MessageBox.Show("Такой логин уже занят.");
        return;
    }

    const int defaultUserRoleId = 3;
    _users.CreateUser(login, password, fullName, defaultUserRoleId);
    MessageBox.Show("Пользователь зарегистрирован.");
    Close();
}

Минимальные элементы окна входа

Элемент Назначение
TextBox Login ввод логина
PasswordBox или TextBox Password ввод пароля
Label CaptchaText отображение CAPTCHA
TextBox CaptchaInput ввод CAPTCHA
Button Login вход
Button Register переход к регистрации

Окно входа Windows Forms

На форме используются элементы txtLogin, txtPassword, lblCaptcha, txtCaptcha, btnLogin, btnRegister.

private readonly CaptchaService _captcha = new CaptchaService();
private readonly AuthService _authService = new AuthService(new UserRepository());

private void LoginForm_Load(object sender, EventArgs e)
{
    lblCaptcha.Text = _captcha.Generate();
}

private void btnLogin_Click(object sender, EventArgs e)
{
    try
    {
        if (!_captcha.Validate(txtCaptcha.Text))
        {
            lblCaptcha.Text = _captcha.Generate();
            txtCaptcha.Clear();
            MessageBox.Show("CAPTCHA введена неверно.");
            return;
        }

        User user = _authService.Login(txtLogin.Text.Trim(), txtPassword.Text);
        var mainForm = new MainForm(user);
        mainForm.Show();
        Hide();
    }
    catch (Exception ex)
    {
        lblCaptcha.Text = _captcha.Generate();
        txtCaptcha.Clear();
        MessageBox.Show(ex.Message);
    }
}

private void btnRegister_Click(object sender, EventArgs e)
{
    var form = new RegisterForm();
    form.ShowDialog();
}

Окно входа WPF

Пример XAML-разметки:

<Window x:Class="PracticeApp.LoginWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="Вход" Height="360" Width="420"
        Loaded="Window_Loaded">
    <Grid Margin="24">
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="Auto"/>
        </Grid.RowDefinitions>

        <TextBox x:Name="LoginTextBox" Grid.Row="0" Margin="0 0 0 12" Height="32"/>
        <PasswordBox x:Name="PasswordBox" Grid.Row="1" Margin="0 0 0 12" Height="32"/>

        <StackPanel Grid.Row="2" Orientation="Horizontal" Margin="0 0 0 12">
            <TextBlock x:Name="CaptchaTextBlock" FontSize="24" FontWeight="Bold" Width="140"/>
            <TextBox x:Name="CaptchaTextBox" Width="180" Height="32"/>
        </StackPanel>

        <Button Grid.Row="3" Height="36" Content="Войти" Click="LoginButton_Click"/>
        <Button Grid.Row="4" Height="32" Margin="0 12 0 0" Content="Регистрация" Click="RegisterButton_Click"/>
    </Grid>
</Window>

Код обработчика:

private readonly CaptchaService _captcha = new CaptchaService();
private readonly AuthService _authService = new AuthService(new UserRepository());

private void Window_Loaded(object sender, RoutedEventArgs e)
{
    CaptchaTextBlock.Text = _captcha.Generate();
}

private void LoginButton_Click(object sender, RoutedEventArgs e)
{
    try
    {
        if (!_captcha.Validate(CaptchaTextBox.Text))
        {
            CaptchaTextBlock.Text = _captcha.Generate();
            CaptchaTextBox.Clear();
            MessageBox.Show("CAPTCHA введена неверно.");
            return;
        }

        User user = _authService.Login(LoginTextBox.Text.Trim(), PasswordBox.Password);
        var window = new MainWindow(user);
        window.Show();
        Close();
    }
    catch (Exception ex)
    {
        CaptchaTextBlock.Text = _captcha.Generate();
        CaptchaTextBox.Clear();
        MessageBox.Show(ex.Message);
    }
}

Порядок подключения форм

Program.cs
  -> DatabaseInitializer.Initialize()
  -> LoginForm
      -> RegisterForm
      -> MainForm
          -> BooksForm
          -> AuthorsForm
          -> ReadersForm

Реализация CRUD-раздела

Для каждой сущности используется одинаковая схема:

Model -> Repository -> Form

Например:

Слой Файл Что делает
модель Book.cs описывает поля книги
репозиторий BookRepository.cs выполняет SQL-запросы
форма BooksForm.cs показывает таблицу и обрабатывает кнопки

На форме нужны методы загрузки списка, чтения данных из полей, добавления, изменения, удаления, поиска и очистки формы после сохранения.

Модель книги:

public class Book
{
    public int Id { get; set; }
    public string Title { get; set; } = "";
    public int AuthorId { get; set; }
    public string AuthorName { get; set; } = "";
    public int? Year { get; set; }
    public string InventoryNumber { get; set; } = "";
    public bool IsAvailable { get; set; }
}
using Microsoft.Data.Sqlite;

public class BookRepository
{
    public List<Book> GetAll(string search = "")
    {
        using var connection = DbConnectionFactory.Create();
        connection.Open();

        using var command = connection.CreateCommand();
        command.CommandText = """
            SELECT b.Id, b.Title, b.AuthorId, a.FullName, b.Year, b.InventoryNumber, b.IsAvailable
            FROM Books b
            JOIN Authors a ON a.Id = b.AuthorId
            WHERE $search = ''
               OR b.Title LIKE '%' || $search || '%'
               OR b.InventoryNumber LIKE '%' || $search || '%'
               OR a.FullName LIKE '%' || $search || '%'
            ORDER BY b.Title
            """;
        command.Parameters.AddWithValue("$search", search.Trim());

        var result = new List<Book>();
        using var reader = command.ExecuteReader();
        while (reader.Read())
        {
            result.Add(new Book
            {
                Id = reader.GetInt32(0),
                Title = reader.GetString(1),
                AuthorId = reader.GetInt32(2),
                AuthorName = reader.GetString(3),
                Year = reader.IsDBNull(4) ? null : reader.GetInt32(4),
                InventoryNumber = reader.GetString(5),
                IsAvailable = reader.GetInt32(6) == 1
            });
        }

        return result;
    }

    public void Add(Book book)
    {
        using var connection = DbConnectionFactory.Create();
        connection.Open();

        using var command = connection.CreateCommand();
        command.CommandText = """
            INSERT INTO Books (Title, AuthorId, Year, InventoryNumber, IsAvailable)
            VALUES ($title, $authorId, $year, $inventoryNumber, $isAvailable)
            """;
        FillParameters(command, book);
        command.ExecuteNonQuery();
    }

    public void Update(Book book)
    {
        using var connection = DbConnectionFactory.Create();
        connection.Open();

        using var command = connection.CreateCommand();
        command.CommandText = """
            UPDATE Books
            SET Title = $title,
                AuthorId = $authorId,
                Year = $year,
                InventoryNumber = $inventoryNumber,
                IsAvailable = $isAvailable
            WHERE Id = $id
            """;
        command.Parameters.AddWithValue("$id", book.Id);
        FillParameters(command, book);
        command.ExecuteNonQuery();
    }

    public void Delete(int id)
    {
        using var connection = DbConnectionFactory.Create();
        connection.Open();

        using var command = connection.CreateCommand();
        command.CommandText = "DELETE FROM Books WHERE Id = $id";
        command.Parameters.AddWithValue("$id", id);
        command.ExecuteNonQuery();
    }

    private static void FillParameters(SqliteCommand command, Book book)
    {
        command.Parameters.AddWithValue("$title", book.Title.Trim());
        command.Parameters.AddWithValue("$authorId", book.AuthorId);
        command.Parameters.AddWithValue("$year", (object?)book.Year ?? DBNull.Value);
        command.Parameters.AddWithValue("$inventoryNumber", book.InventoryNumber.Trim());
        command.Parameters.AddWithValue("$isAvailable", book.IsAvailable ? 1 : 0);
    }
}

Фрагмент BooksForm для Windows Forms:

private readonly BookRepository _books = new BookRepository();

private void BooksForm_Load(object sender, EventArgs e)
{
    LoadBooks();
}

private void LoadBooks()
{
    booksGrid.DataSource = _books.GetAll(txtSearch.Text);
    booksGrid.Columns[nameof(Book.Id)].Visible = false;
    booksGrid.Columns[nameof(Book.AuthorId)].Visible = false;
}

private void btnAdd_Click(object sender, EventArgs e)
{
    var book = ReadBookFromInputs();
    if (book == null)
        return;

    _books.Add(book);
    ClearInputs();
    LoadBooks();
}

private void btnUpdate_Click(object sender, EventArgs e)
{
    if (booksGrid.CurrentRow?.DataBoundItem is not Book selected)
    {
        MessageBox.Show("Выберите книгу.");
        return;
    }

    var book = ReadBookFromInputs();
    if (book == null)
        return;

    book.Id = selected.Id;
    _books.Update(book);
    LoadBooks();
}

private void btnDelete_Click(object sender, EventArgs e)
{
    if (booksGrid.CurrentRow?.DataBoundItem is not Book selected)
    {
        MessageBox.Show("Выберите книгу.");
        return;
    }

    _books.Delete(selected.Id);
    LoadBooks();
}

Проверка ролей в интерфейсе

В главном окне нужно скрыть или отключить элементы, которые недоступны роли:

usersMenuItem.Visible = _currentUser.RoleName == "admin";
loginAttemptsMenuItem.Visible = _currentUser.RoleName == "admin";

Для роли operator можно оставить предметные разделы, но скрыть управление пользователями.

В конце этапа в проекте должны быть реализованы вход, регистрация, CAPTCHA, главное окно с учетом роли пользователя и не менее одного полностью работающего CRUD-раздела. В отчет добавляются скриншоты окон и пояснение, какие классы отвечают за базу данных, авторизацию и интерфейс.