Этап 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-раздела¶
Для каждой сущности используется одинаковая схема:
Например:
| Слой | Файл | Что делает |
|---|---|---|
| модель | 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-раздела. В отчет добавляются скриншоты окон и пояснение, какие классы отвечают за базу данных, авторизацию и интерфейс.