усердно учись, совершенствуйся каждый день
Эта статья была включена в мой репозиторий Github.DayDayUP: github.com/RobodLee/DayDayUP, добро пожаловать в Star, больше статей можно найти здесь:Навигация по каталогу
предисловие
Spring Security — это мощная и настраиваемая среда аутентификации и контроля доступа. обеспечивает полныйМеханизм аутентификациии уровень методаФункция авторизации. Это очень хорошая структура управления разрешениями. Его ядром является набор цепочек фильтров, разные функции проходят через разные фильтры. Эта статья предназначена для интеграции Spring Security в SpringBoot через небольшой корпус. Функция, которая должна быть реализована, состоит в том, чтобы войти на сервер аутентификации, затем получить токен, а затем получить доступ к ресурсам на сервере ресурсов.
основная концепция
-
войти
Что такое единый вход? То есть в системе с несколькими приложениями, пока вы входите в одну из систем, вы можете получить доступ к ее содержимому без входа в другие системы. Например, сложная система, как JD.com, точно будет не монолитной структурой, а микросервисной архитектурой, например, функция заказа — это система, а транзакция — это система... разместите заказ и оплатите его.Нужно ли Qian снова войти в систему, если да, то пользовательский опыт очень плохой. Процесс реализации заключается в том, что когда я размещаю заказ, система обнаруживает, что я не авторизован, и позволяет авторизоваться. Токен снова, когда я хочу заплатить. Зайдите в торговую систему, и затем торговая система проверит токен, чтобы узнать, кто это, поэтому мне не нужно снова входить в систему.
-
JWT
Упомянутый выше токенJWT(JSON Web Token), представляет собой краткую, безопасную для URL спецификацию репрезентативного объявления, используемую для передачи информации о безопасности между двумя взаимодействующими сторонами. JWT на самом деле представляет собой строку, состоящую из трех частей: заголовка, полезной нагрузки и подписи. Для того, чтобы наглядно увидеть структуру JWT, я нарисовал ментальную карту:
Окончательный сгенерированный токен JWT выглядит следующим образом: он состоит из трех частей:.
разделены.
base64UrlEncode(заголовок JWT)+"."+base64UrlEncode(полезная нагрузка)+"."+HMACSHA256(base64UrlEncode(заголовок JWT) + "." + base64UrlEncode(полезная нагрузка),ключ)
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ
-
RSA
Как видно из приведенного выше примера, JWT использует один и тот же ключ для шифрования и дешифрования.robod666", это принесет недостаток. Если хакер знает содержимое ключа, то он может подделать Token. Таким образом, для безопасности мы можем использовать алгоритм асимметричного шифрованияRSA.
Есть два основных принципа RSA:
- Шифрование с закрытым ключом, можно расшифровать только закрытый или открытый ключ.
- Шифрование с открытым ключом, расшифровать можно только закрытый ключ
Функция входа пользователя на сервер аутентификации
Предварительная подготовка
После ознакомления с основными понятиями можно приступать к интеграции. Из-за нехватки места публикуется только основной код. Для остального контента перейдите к исходному коду, чтобы найти адрес. Адрес указан в конце статьи. Для начала нужно подготовить базу:
-- ----------------------------
-- Table structure for sys_role
-- ----------------------------
DROP TABLE IF EXISTS `sys_role`;
CREATE TABLE `sys_role` (
`ID` int(11) NOT NULL AUTO_INCREMENT COMMENT '编号',
`ROLE_NAME` varchar(30) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '角色名称',
`ROLE_DESC` varchar(60) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '角色描述',
PRIMARY KEY (`ID`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 6 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Compact;
-- ----------------------------
-- Records of sys_role
-- ----------------------------
INSERT INTO `sys_role` VALUES (1, 'ROLE_USER', '基本角色');
INSERT INTO `sys_role` VALUES (2, 'ROLE_ADMIN', '超级管理员');
INSERT INTO `sys_role` VALUES (3, 'ROLE_PRODUCT', '管理产品');
INSERT INTO `sys_role` VALUES (4, 'ROLE_ORDER', '管理订单');
-- ----------------------------
-- Table structure for sys_user
-- ----------------------------
DROP TABLE IF EXISTS `sys_user`;
CREATE TABLE `sys_user` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`username` varchar(32) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '用户名称',
`password` varchar(120) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '密码',
`status` int(1) NULL DEFAULT 1 COMMENT '1开启0关闭',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 4 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Compact;
-- ----------------------------
-- Records of sys_user
-- ----------------------------
INSERT INTO `sys_user` VALUES (1, 'xiaoming', '$2a$10$CYX9OMv0yO8wR8rE19N2fOaXDJondci5uR68k2eQJm50q8ESsDMlC', 1);
INSERT INTO `sys_user` VALUES (2, 'xiaoma', '$2a$10$CYX9OMv0yO8wR8rE19N2fOaXDJondci5uR68k2eQJm50q8ESsDMlC', 1);
-- ----------------------------
-- Table structure for sys_user_role
-- ----------------------------
DROP TABLE IF EXISTS `sys_user_role`;
CREATE TABLE `sys_user_role` (
`UID` int(11) NOT NULL COMMENT '用户编号',
`RID` int(11) NOT NULL COMMENT '角色编号',
PRIMARY KEY (`UID`, `RID`) USING BTREE,
INDEX `FK_Reference_10`(`RID`) USING BTREE,
CONSTRAINT `FK_Reference_10` FOREIGN KEY (`RID`) REFERENCES `sys_role` (`ID`) ON DELETE RESTRICT ON UPDATE RESTRICT,
CONSTRAINT `FK_Reference_9` FOREIGN KEY (`UID`) REFERENCES `sys_user` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Compact;
-- ----------------------------
-- Records of sys_user_role
-- ----------------------------
INSERT INTO `sys_user_role` VALUES (1, 1);
INSERT INTO `sys_user_role` VALUES (2, 1);
INSERT INTO `sys_user_role` VALUES (1, 3);
INSERT INTO `sys_user_role` VALUES (2, 4);
SET FOREIGN_KEY_CHECKS = 1;
Всего существует три таблицы, а именно таблица пользователей, таблица ролей и таблица ролей пользователей. Пользователь используется для входа в систему, пароль на самом деле является зашифрованной строкой, содержание "123”; роли используются для контроля разрешений.
Затем создайте пустой родительский проектSpringSecurityDemo, а затем создайте модуль в качестве службы проверки подлинности в родительском проекте с именемauthentication_server. Добавьте необходимые зависимости. (内容较占篇幅,有需要的去源码中获取,源码地址见文末
).
Содержимое конфигурационного файла проекта перехватывает основную часть и вставляет ее ниже:
…………
# 配置了公钥和私钥的位置
rsa:
key:
pubKeyPath: C:\Users\robod\Desktop\auth_key\id_key_rsa.pub
priKeyPath: C:\Users\robod\Desktop\auth_key\id_key_rsa
Последние метки открытого и закрытого ключей настраиваются, а не метки, предоставляемые Spring, Мы загрузим эту часть содержимого в класс конфигурации RSA позже.
Для удобства мы также можем подготовить несколько классов инструментов (内容较占篇幅,有需要的去源码中获取,源码地址见文末
):
- JsonUtils: предоставляет некоторые операции, связанные с json;
- JwtUtils: сгенерируйте токен и проверьте методы, связанные с токеном;
- RsaUtils: создание файлов открытых и закрытых ключей и чтение открытых и закрытых ключей из файлов.
Мы можем инкапсулировать полезную нагрузку отдельно как объект:
@Data
public class Payload<T> {
private String id;
private T userInfo;
private Date expiration;
}
Теперь приступайте к написанию тестового класса и вызывайте соответствующие методы в RsaUtils для генерации открытых и закрытых ключей. Как получить открытый ключ и закрытый ключ при его создании и использовании? Чтобы решить эту проблему, нам нужно создать класс конфигурации RSA,
@Data
@ConfigurationProperties("rsa.key") //指定配置文件的key
public class RsaKeyProperties {
private String pubKeyPath;
private String priKeyPath;
private PublicKey publicKey;
private PrivateKey privateKey;
@PostConstruct
public void createKey() throws Exception {
this.publicKey = RsaUtils.getPublicKey(pubKeyPath);
this.privateKey = RsaUtils.getPrivateKey(priKeyPath);
}
}
Сначала мы используем аннотацию **@ConfigurationProperties**, чтобы указать ключ открытого ключа и путь к закрытому ключу, а затем мы можем получить содержимое открытого ключа и закрытого ключа в методе построения. Таким образом, этот класс можно вызывать напрямую, когда требуются открытый и закрытый ключи. Но как вызвать этот класс, не помещая его в контейнер Spring, поэтому добавьте аннотацию к классу запуска:
@EnableConfigurationProperties(RsaKeyProperties.class)
Это означает размещение классов конфигурации для RSA в контейнере Spring.
Логин пользователя
Прежде чем реализовать функцию входа пользователя в систему, давайте поговорим о соответствующем содержании входа в систему. Что касается процесса входа в систему, я прочитал статью в Интернете и почувствовал себя очень хорошо, я разместил ее для своих друзей:
Woohoo.Краткое описание.com/afraid/ah 65 отправлено 883…
войдет первымUsernamePasswordAuthenticationFilterИ установите для разрешения значение null и разрешать ли его на false, затем введитеProviderManagerНайти поддержкуUsernamepasswordAuthenticationTokenизproviderи позвониprovider.authenticate(authentication);а потом
UserDetailsService
Класс реализации интерфейса (т.е. свое реальное конкретное дело), после того, как в это время его проверили, он перезвонитUsernamePasswordAuthenticationFilterИ установите разрешения (разрешения, найденные конкретным бизнесом) и установите для авторизации значение true (поскольку все уровни были проверены в это время).
В приведенном выше абзаце упоминается UsernamePasswordAuthenticationFilter, и мы ввели этот фильтр в начале.attemptAuthentication()метод, но этот метод заключается в получении имени пользователя и пароля из формы form, что не соответствует нашим потребностям, поэтому нам нужно переписать этот метод. Затем, после ряда поворотов, мы вошли вUserDetailsService.loadUserByUsername()метод, поэтому нам нужно реализовать этот метод, чтобы реализовать нашу собственную бизнес-логику. Этот метод возвращаетUserDetailsОбъект интерфейса, если вы хотите вернуть пользовательский объект, вы можете реализовать этот интерфейс. После успешной аутентификации конечного пользователя вызовUsernamePasswordAuthenticationFilterродительский классМетод AbstractAuthenticationProcessingFilter.successfulAuthentication(), нам также нужно переопределить этот метод для достижения наших собственных нужд.
Итак, теперь давайте реализуем вещи, упомянутые выше👇
@Data
public class SysUser implements UserDetails {
private Integer id;
private String username;
private String password;
private Integer status;
private List<SysRole> roles = new ArrayList<>(); //SysRole封装了角色信息,和登录无关,我放在后面讲
//这里还有几个UserDetails中的方法,我就不贴代码了
}
Мы настроили класс SysUser для реализации интерфейса UserDetails, а затем добавили несколько настраиваемых полей☝
public interface UserService extends UserDetailsService {
}
//-----------------------------------------------------------
@Service("userService")
public class UserServiceImpl implements UserService {
…………
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
SysUser sysUser = userMapper.findByUsername(username);
return sysUser;
}
}
В этом коде мы сначала определяем интерфейс UserService для наследования UserDetailsService, а затем используем UserServiceImpl для реализации UserService, который эквивалентен UserServiceImpl реализует UserDetailsService, так что мы можем реализовать метод loadUserByUsername(), содержимое очень простое, просто используйте Имя пользователя отправляется в базу данных для поиска соответствующего SysUser, а затем конкретный процесс проверки может быть передан другим фильтрам для реализации, и нам не нужно об этом беспокоиться.
Как упоминалось ранее, вам нужно переписать **attemptAuthentication()а такжеsuccessAuthentication()**, затем настройте фильтр для наследования UsernamePasswordAuthenticationFilter, а затем перепишите эти два метода👇
public class JwtLoginFilter extends UsernamePasswordAuthenticationFilter {
private AuthenticationManager authenticationManager;
private RsaKeyProperties rsaKeyProperties;
public JwtLoginFilter(AuthenticationManager authenticationManager, RsaKeyProperties rsaKeyProperties) {
this.authenticationManager = authenticationManager;
this.rsaKeyProperties = rsaKeyProperties;
}
//这个方法是用来去尝试验证用户的
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
try {
SysUser user = JSONObject.parseObject(request.getInputStream(),SysUser.class);
return authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
user.getUsername(),
user.getPassword())
);
} catch (Exception e) {
try {
response.setContentType("application/json;charset=utf-8");
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
PrintWriter out = response.getWriter();
Map<String, Object> map = new HashMap<>();
map.put("code", HttpServletResponse.SC_UNAUTHORIZED);
map.put("message", "账号或密码错误!");
out.write(new ObjectMapper().writeValueAsString(map));
out.flush();
out.close();
} catch (Exception e1) {
e1.printStackTrace();
}
throw new RuntimeException(e);
}
}
//成功之后执行的方法
@Override
public void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
SysUser sysUser = new SysUser();
sysUser.setUsername(authResult.getName());
sysUser.setRoles((List<SysRole>) authResult.getAuthorities());
String token = JwtUtils.generateTokenExpireInMinutes(sysUser,rsaKeyProperties.getPrivateKey(),24*60);
response.addHeader("Authorization", "RobodToken " + token); //将Token信息返回给用户
try {
//登录成功时,返回json格式进行提示
response.setContentType("application/json;charset=utf-8");
response.setStatus(HttpServletResponse.SC_OK);
PrintWriter out = response.getWriter();
Map<String, Object> map = new HashMap<String, Object>(4);
map.put("code", HttpServletResponse.SC_OK);
map.put("message", "登陆成功!");
out.write(new ObjectMapper().writeValueAsString(map));
out.flush();
out.close();
} catch (Exception e1) {
e1.printStackTrace();
}
}
}
Логика кода по-прежнему очень ясна, поэтому я не буду ее объяснять.
Теперь дело в том, как Spring Security узнает, что мы собираемся вызывать нашу собственную службу UserService и настраиваемые фильтры? Поэтому нам нужно настроить его, что также является ядром использования Spring Security -> класс конфигурации👇
@Configuration
@EnableWebSecurity //这个注解的意思是这个类是Spring Security的配置类
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
…………
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
//认证用户的来源
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userService).passwordEncoder(passwordEncoder());
}
//配置SpringSecurity相关信息
@Override
public void configure(HttpSecurity http) throws Exception {
http.csrf().disable() //关闭csrf
.addFilter(new JwtLoginFilter(super.authenticationManager(),rsaKeyProperties))
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS); //禁用session
}
}
В классе конфигурации настраивается источник аутентифицированного пользователя и добавляется настраиваемый фильтр. Это активирует функцию входа в систему.
Вы можете видеть, что вы успешно вошли в систему сейчас, но откуда берется этот **/login**? Это предоставляется самой Spring Security. Ключ имени пользователя должен быть «имя пользователя», а ключ пароля должен быть "пароль" ", метод отправки должен быть POST.
Подводя итог, что нужно сделать для реализации функции входа в систему:
- Аутентифицированный пользователь реализует интерфейс UserDetails.
- Сервис источника пользователя реализует интерфейс UserDetailsService, реализует метод loadUserByUsername() и получает данные из базы данных
- Реализуйте свой собственный фильтр для наследования UsernamePasswordAuthenticationFilter, перепишите методы tryAuthentication() и SuccessAuthentication() для реализации собственной логики.
- Класс конфигурации Spring Security наследуется от WebSecurityConfigurerAdapter, переписывая два метода config() внутри
- Если вы используете асимметричное шифрование RSA, подготовьте класс конфигурации RSA, а затем добавьте аннотации к классу запуска, чтобы добавить его в контейнер IOC.
Проверка разрешения сервера ресурсов
В этом разделе мы реализуем операцию доступа к ресурсам на сервере ресурсов и выполнения аутентификации. Создайте еще один модуль в родительском проекте SpringSecurityDemo.recourse_server. Потому что сейчас нам не нужно получать информацию о пользователе из базы данных. Таким образом, вам не нужно самостоятельно определять Service и Mapper. Также нет необходимости в фильтре входа. Следующая диаграмма структуры каталогов — это все, что нужно проекту службы ресурсов.
SysRole используется в предыдущем разделе, но не подробно. Этот класс используется для инкапсуляции информации о роли, используемой для аутентификации, и реализует интерфейс GrantedAuthority:
@Data
public class SysRole implements GrantedAuthority {
private Integer id;
private String roleName;
private String roleDesc;
/**
* 如果授予的权限可以当作一个String的话,就可以返回一个String
* @return
*/
@JsonIgnore
@Override
public String getAuthority() {
return roleName;
}
}
Он реализует метод getAuthority и напрямую возвращает имя роли. roleName — это имя роли.
Клиент отправляет токен на сервер ресурсов, и сервер должен проверить токен и извлечь информацию о полезной нагрузке. Таким образом, мы можем настроить фильтр, который наследуется отBasicAuthenticationFilter, а затем переопределите метод **doFilterInternal()** для реализации собственной логики.
public class JwtVerifyFilter extends BasicAuthenticationFilter {
…………
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws IOException, ServletException {
String header = request.getHeader("Authorization");
//没有登录
if (header == null || !header.startsWith("RobodToken ")) {
chain.doFilter(request, response);
response.setContentType("application/json;charset=utf-8");
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
PrintWriter out = response.getWriter();
Map<String, Object> map = new HashMap<String, Object>(4);
map.put("code", HttpServletResponse.SC_FORBIDDEN);
map.put("message", "请登录!");
out.write(new ObjectMapper().writeValueAsString(map));
out.flush();
out.close();
return;
}
//登录之后从token中获取用户信息
String token = header.replace("RobodToken ","");
SysUser sysUser = JwtUtils.getInfoFromToken(token, rsaKeyProperties.getPublicKey(), SysUser.class).getUserInfo();
if (sysUser != null) {
Authentication authResult = new UsernamePasswordAuthenticationToken
(sysUser.getUsername(),null,sysUser.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authResult);
chain.doFilter(request, response);
}
}
}
В этом коде значение "Авторизация" сначала получается из заголовка запроса. Если значение не равно null или не начинается с указанного нами "RobodToken", значит это не тот Токен, который мы установили, или он не вошел в систему, и пользователю предлагается войти в систему. Если есть токен, вызовите JwtUtils.getInfoFromToken(), чтобы проверить и получить содержимое полезной нагрузки. Если проверка пройдена, информация о роли передается в конструктор проверки подлинности, а затем передается другим фильтрам для выполнения.
Закрытый ключ должен храниться только на сервере аутентификации, поэтому на сервере ресурсов должен храниться только открытый ключ.
…………
rsa:
key:
pubKeyPath: C:\Users\robod\Desktop\auth_key\id_key_rsa.pub
@Data
@ConfigurationProperties("rsa.key") //指定配置文件的key
public class RsaKeyProperties {
private String pubKeyPath;
private PublicKey publicKey;
@PostConstruct
public void createKey() throws Exception {
this.publicKey = RsaUtils.getPublicKey(pubKeyPath);
}
}
Далее конфигурационный файл ядра Spring Security👇
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(securedEnabled = true) //开启权限控制的注解支持,securedEnabled表示SpringSecurity内部的权限控制注解开关
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
…………
//配置SpringSecurity相关信息
@Override
public void configure(HttpSecurity http) throws Exception {
http.csrf().disable() //关闭csrf
.authorizeRequests()
.antMatchers("/**").hasAnyRole("USER") //角色信息
.anyRequest() //其它资源
.authenticated() //表示其它资源认证通过后
.and()
.addFilter(new JwtVerifyFilter(super.authenticationManager(),rsaKeyProperties))
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS); //禁用session
}
}
Здесь есть заметка@EnableGlobalMethodSecurity(securedEnabled = true), эта аннотация означает включение поддержки аннотаций для управления разрешениями. Затем добавил настраиваемый фильтр разбора токенов. Наконец, добавьте примечание о методе, который требует контроля разрешений👇
@RestController
@RequestMapping("/product")
public class ProductController {
@Secured("ROLE_PRODUCT")
@RequestMapping("/findAll")
public String findAll() {
return "产品列表查询成功";
}
}
Что ж, метод findAll должен иметь разрешение «ROLE_PRODUCT» для доступа к нему. Давайте проверим это:
После успешного входа в систему сервер возвращает информацию о токене в заголовке ответа, копирует ее, а затем добавляет в заголовок запроса нашего запроса.
Как видите, доступ к ресурсу успешно завершен. Давайте войдем в систему с другим пользователем без разрешения для проверки:
Запрос был отклонен, что указывает на отсутствие проблем с функцией контроля разрешений. Подводя итоги шагов:
- Класс, который инкапсулирует информацию о разрешениях, реализует интерфейс GrantedAuthority и реализует внутри него метод getAuthority().
- Реализуйте собственный фильтр проверки токена, унаследованный от BasicAuthenticationFilter, и перепишите метод doFilterInternal() для реализации собственной бизнес-логики.
- Напишите класс конфигурации Spring Security, чтобы он наследовал WebSecurityConfigurerAdapter, перепишите метод configure(), чтобы добавить настраиваемые фильтры, и добавьте аннотацию @EnableGlobalMethodSecurity(securedEnabled = true), чтобы включить функцию управления разрешениями аннотаций.
- Если вы используете асимметричное шифрование RSA, подготовьте класс конфигурации RSA, а затем добавьте аннотацию к классу запуска, чтобы добавить его в контейнер IOC.Обратите внимание, что здесь вам нужно не только настроить открытый ключ.
Суммировать
На этом интеграция SpringBoot с Spring Security заканчивается. В статье лишь вкратце рассказывалось о процессе интеграции, и многие другие вещи не упоминались, например, какую роль играет каждый фильтр. Также может быть интегрирован используемый здесь метод разделения сервера аутентификации и сервера ресурсов. Подобных задач много, и друзья могут изучить их самостоятельно. Я попросил, чтобы статья не была слишком раздутой, и многие коды не были размещены.Кому это нужно, может нажать на ссылку ниже, чтобы скачать.
Нажмите, чтобы загрузить исходный код
Если моя статья была вам полезна, не забудьтеподобно,собирать,Вперед,обрати внимание на. Если у вас есть хорошие комментарии, пожалуйста, оставьте сообщение ниже. Увидимся в следующий раз!