Не так давно мне представилась возможность реализовать небольшой проект без особых требований по технической части. То есть, я был волен выбирать стек технологий на своё усмотрение. Потому не преминул возможностью как следует «пощупать» модные, молодёжные многообещающие, но малознакомые мне на практике Kotlin и Vue.js, добавив туда уже знакомый Spring Boot и примерив всё это на незамысловатое веб-приложение.
Приступив, я опрометчиво полагал, что в Интернете найдётся множество статей и руководств на эту тему. Материалов действительно достаточно, и все они хороши, но только до первого REST-контроллера. Затем начинаются трудности противоречия. А ведь даже в простом приложении хотелось бы иметь более сложную логику, чем отрисовка на странице текста, возвращаемого сервером.
Кое-как разобравшись, я решил написать собственное руководство, которое, надеюсь, будет кому-нибудь полезно.
О чём и для кого статья
Данный материал — руководство для «быстрого старта» разработки веб-приложения с бэкендом на Kotlin + Spring Boot и фронтендом на Vue.js. Сразу скажу, что я не «топлю» за них и не говорю о каких-то однозначных преимуществах данного стека. Цель данной статьи — поделиться опытом.
Материал рассчитан на разработчиков, имеющих опыт работы с Java, Spring Framework/Spring Boot, React/Angular или хотя бы чистым JavaScript. Подойдёт и тем, у кого нет такого опыта — например, начинающим программистам, но, боюсь, тогда придётся разбираться в некоторых деталях самостоятельно. Вообще, некоторые моменты этого руководства стоит рассмотреть подробнее, но, думаю, лучше сделать это в рамках других публикаций, чтобы сильно не отклоняться от темы и не делать статью громоздкой.
Быть может, кому это поможет сформировать представление о бэкенд-разработке на Kotlin без необходимости самому погружаться в данную тематику, а кому-то — сократить время работы, взяв за основу уже готовый скелет приложения.
Несмотря описание конкретных практических шагов, в целом, на мой взгляд, статья имеет экспериментально-обзорный характер. Сейчас такой подход, да и сама постановка вопроса видится, скорее, как хипстерская затея — собрать как можно больше модных слов в одном месте. Но в будущем, возможно, и займёт свою нишу в энтерпрайзной разработке. Быть может, среди нас есть начинающие (и продолжающие) программисты, которым предстоит жить и работать во времена, когда Kotlin и Vue.js будут так же популярны и востребованы, как сейчас Java и React. Ведь Kotlin и Vue.js действительно подают большие надежды.
За то время, пока я писал это руководство, в сети уже стали появляться похожие публикации, как, например, эта. Повторюсь, материалов, где разбирается порядок действий до первого REST-контроллера достаточно, но интересно интересно было бы увидеть более сложную логику — например, реализацию аутентификации с разделением по ролям, что является довольно необходимым функционалом. Именно этим я дополнил своё собственное руководство.
Содержание
- Краткая справка
- Инструменты разработки
- Инициализация проекта
- REST API
- Подключение к базе данных
- Аутентификация
- Пути улучшения
- Полезные ссылки
Краткая справка
Kotlin — язык программирования, работающий поверх JVM и разрабатываемый международной компанией JetBrains.
Vue.js — JavaScript -фреймворк для разработки одностраничных приложений в реактивном стиле.
Инструменты разработки
В качестве среды разработки я бы рекомендовал использовать IntelliJ IDEA — среду разработки от JetBrains, получившую широкую популярность в Java-сообществе, поскольку она имеет удобные инструменты и фичи для работы с Kotlin вплоть для преобразования Java-кода в код на Kotlin. Однако, не стоит рассчитывать, что таким образом можно мигрировать целый проект, и всё вдруг заработает само собой.
Счастливые обладатели IntelliJ IDEA Ultimate Edition могут для удобства работы с Vue.js установить соответствующий плагин. Если же вы ищете компромисс между халявой ценой и удобством, то очень рекомендую использовать Microsoft Visual Code с плагином Vetur.
Полагаю, для многих это очевидно, но на всякий случай напомню, что для работы c Vue.js требуется менеджер пакетов npm. Инструкцию по установке Vue.js можно найти на сайте Vue CLI.
В качестве сборщика проектов на Java в данном руководстве используется Maven, в качестве сервера баз данных — PostgreSQL.
Инициализация проекта
Создадим директорию проекта, назвав, например kotlin-spring-vue. Нашем проекте будут два модуля — backend и frontend. Сначала будет собираться фронтенд. Затем, при сборке бэкенд будет копировать себе index.hmtl, favicon.ico и все статические файлы (*.js, *.css, изображения и т.д.).
Таким образом, в корневом каталоге у нас будут находится две подпапки — /backend и /frontend. Однако, не стоит торопиться создавать их вручную.
Инициализировать модуль бэкенда можно несколькими путями:
- вручную (путь самурая)
- сгенерирован проект Spring Boot приложения средствами Spring Tool Suite или IntelliJ IDEA Ultimate Edition
- С помощью Spring Initializr, указав нужные настройки — это, пожалуй, самый распространенный способ
В нашем случае первичная конфигурация такова:
- Project: Maven Project
- Language: Kotlin
- Spring Boot: 2.1.6
- Project Metadata: Java 8, JAR packaging
- Dependencies: Spring Web Starter, Spring Boot Actuator, Spring Boot DevTools
pom.xml должен выглядеть следующим образом:
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>com.kotlin-spring-vue</groupId> <artifactId>demo</artifactId> <version>1.0-SNAPSHOT</version> </parent> <groupId>com.kotlin-spring-vue</groupId> <artifactId>backend</artifactId> <version>0.0.1-SNAPSHOT</version> <name>backend</name> <description>Backend module for Kotlin + Spring Boot + Vue.js</description> <properties> <java.version>1.8</java.version> <kotlin.version>1.2.71</kotlin.version> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding> <rest-assured.version>3.3.0</rest-assured.version> <start-class>com.kotlinspringvue.backend.BackendApplicationKt</start-class> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-actuator</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.fasterxml.jackson.module</groupId> <artifactId>jackson-module-kotlin</artifactId> </dependency> <dependency> <groupId>org.jetbrains.kotlin</groupId> <artifactId>kotlin-reflect</artifactId> </dependency> <dependency> <groupId>org.jetbrains.kotlin</groupId> <artifactId>kotlin-stdlib-jdk8</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-devtools</artifactId> <scope>runtime</scope> <optional>true</optional> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> </dependencies> <build> <sourceDirectory>${project.basedir}/src/main/kotlin</sourceDirectory> <testSourceDirectory>${project.basedir}/src/test/kotlin</testSourceDirectory> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <configuration> <mainClass>com.kotlinspringvue.backend.BackendApplicationKt</mainClass> </configuration> </plugin> <plugin> <groupId>org.jetbrains.kotlin</groupId> <artifactId>kotlin-maven-plugin</artifactId> <configuration> <args> <arg>-Xjsr305=strict</arg> </args> <compilerPlugins> <plugin>spring</plugin> </compilerPlugins> </configuration> <dependencies> <dependency> <groupId>org.jetbrains.kotlin</groupId> <artifactId>kotlin-maven-allopen</artifactId> <version>${kotlin.version}</version> </dependency> </dependencies> </plugin> <plugin> <artifactId>maven-resources-plugin</artifactId> <executions> <execution> <id>copy Vue.js frontend content</id> <phase>generate-resources</phase> <goals> <goal>copy-resources</goal> </goals> <configuration> <outputDirectory>src/main/resources/public</outputDirectory> <overwrite>true</overwrite> <resources> <resource> <directory>${project.parent.basedir}/frontend/target/dist</directory> <includes> <include>static/</include> <include>index.html</include> <include>favicon.ico</include> </includes> </resource> </resources> </configuration> </execution> </executions> </plugin> </plugins> </build> </project>
Обращаю внимание:
- Название главного класса заканчивается на Kt
- Выполняется копирование ресурсов из корневая_папка_проекта/frontend/target/dist в src/main/resources/public
- Родительский проект (parent) в лице spring-boot-starter-parent пренесён на уровень главного pom.xml
Чтобы инициализировать модуль фронтенда, переходим в корневую директорию проекта и выполняем команду:
$ vue create frontend
Далее можно выбрать все настройки по умолчанию — в нашем случае этого будет достаточно.
По умолчанию модуль будет собираться в подпапку /dist, однако нам нужно видеть собранные файлы в папке /target. Для этого создадим файл vue.config.js прямо в /frontend со следующими настройками:
module.exports = { outputDir: 'target/dist', assetsDir: 'static' }
Поместим в модуль frontend файл pom.xml такого вида:
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <artifactId>frontend</artifactId> <parent> <groupId>com.kotlin-spring-vue</groupId> <artifactId>demo</artifactId> <version>0.0.1-SNAPSHOT</version> </parent> <properties> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding> <java.version>1.8</java.version> <frontend-maven-plugin.version>1.6</frontend-maven-plugin.version> </properties> <build> <plugins> <plugin> <groupId>com.github.eirslett</groupId> <artifactId>frontend-maven-plugin</artifactId> <version>${frontend-maven-plugin.version}</version> <executions> <!-- Install our node and npm version to run npm/node scripts--> <execution> <id>install node and npm</id> <goals> <goal>install-node-and-npm</goal> </goals> <configuration> <nodeVersion>v11.8.0</nodeVersion> </configuration> </execution> <!-- Install all project dependencies --> <execution> <id>npm install</id> <goals> <goal>npm</goal> </goals> <!-- optional: default phase is "generate-resources" --> <phase>generate-resources</phase> <!-- Optional configuration which provides for running any npm command --> <configuration> <arguments>install</arguments> </configuration> </execution> <!-- Build and minify static files --> <execution> <id>npm run build</id> <goals> <goal>npm</goal> </goals> <configuration> <arguments>run build</arguments> </configuration> </execution> </executions> </plugin> </plugins> </build> </project>
И, наконец, поместим pom.xml в корневую директорию проекта:
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <groupId>com.kotlin-spring-vue</groupId> <artifactId>demo</artifactId> <packaging>pom</packaging> <version>1.0-SNAPSHOT</version> <modules> <module>backend</module> <module>frontend</module> </modules> <name>kotlin-spring-vue</name> <description>Kotlin + Spring Boot + Vue.js</description> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.1.3.RELEASE</version> <relativePath/> <!-- lookup parent from repository --> </parent> <properties> <main.basedir>${project.basedir}</main.basedir> <!-- Analysis Tools for CI --> <build-plugin.jacoco.version>0.8.2</build-plugin.jacoco.version> <build-plugin.coveralls.version>4.3.0</build-plugin.coveralls.version> <kotlin.version>1.2.71</kotlin.version> </properties> <dependencies> <dependency> <groupId>org.jetbrains.kotlin</groupId> <artifactId>kotlin-stdlib-jdk8</artifactId> <version>${kotlin.version}</version> </dependency> <dependency> <groupId>org.jetbrains.kotlin</groupId> <artifactId>kotlin-test</artifactId> <version>${kotlin.version}</version> <scope>test</scope> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.jacoco</groupId> <artifactId>jacoco-maven-plugin</artifactId> <version>${build-plugin.jacoco.version}</version> <executions> <!-- Prepares the property pointing to the JaCoCo runtime agent which is passed as VM argument when Maven the Surefire plugin is executed. --> <execution> <id>pre-unit-test</id> <goals> <goal>prepare-agent</goal> </goals> </execution> <!-- Ensures that the code coverage report for unit tests is created after unit tests have been run. --> <execution> <id>post-unit-test</id> <phase>test</phase> <goals> <goal>report</goal> </goals> </execution> </executions> </plugin> <plugin> <groupId>org.eluder.coveralls</groupId> <artifactId>coveralls-maven-plugin</artifactId> <version>${build-plugin.coveralls.version}</version> </plugin> <plugin> <groupId>org.jetbrains.kotlin</groupId> <artifactId>kotlin-maven-plugin</artifactId> <version>${kotlin.version}</version> <executions> <execution> <id>compile</id> <phase>compile</phase> <goals> <goal>compile</goal> </goals> </execution> <execution> <id>test-compile</id> <phase>test-compile</phase> <goals> <goal>test-compile</goal> </goals> </execution> </executions> <configuration> <jvmTarget>1.8</jvmTarget> </configuration> </plugin> </plugins> </build> </project>
где мы видим два наших модуля — frontend и backend, а также parent — spring-boot-starter-parent.
Важно: модули должны собираться именно в таком порядке — сначала фронтенд, потом бэкенд.
Теперь мы можем выполнить сборку проекта:
$ mvn install
И, если всё собралось, запустить приложение:
$ mvn --project backend spring-boot:run
По адресу http://localhost:8080/ будет доступна страничка Vue.js по умолчанию:

REST API
Теперь давайте создадим какой-нибудь простенький REST-сервис. Например, «Hello, [имя_пользователя]!» (по умолчанию — World), который считает, сколько раз мы его дёрнули.
Для этого нам понадобится структура данных состоящая из числа и строки — класс, единственным назначением которого является хранение данных. Для этого в Kotlin существуют классы данных. И наш класс будет выглядеть так:
data class Greeting(val id: Long, val content: String)
Всё. Теперь можем написать непосредственно сервис.
Примечание: для удобства будет вынесить все сервисы в отдельный маршрут /api с помощью аннотации @RequestMapping перед объявлением класса:
import org.springframework.web.bind.annotation.* import com.kotlinspringvue.backend.model.Greeting import java.util.concurrent.atomic.AtomicLong @RestController @RequestMapping("/api") class BackendController() { val counter = AtomicLong() @GetMapping("/greeting") fun greeting(@RequestParam(value = "name", defaultValue = "World") name: String) = Greeting(counter.incrementAndGet(), "Hello, $name") }
Теперь перезапустим приложение и посмотрим результат http://localhost:8080/api/greeting?name=Vadim:
{"id":1,"content":"Hello, Vadim"}
Обновим страничку и убедимся, что счётчик работает:
{"id":2,"content":"Hello, Vadim"}
Теперь поработаем над фронтендом, чтобы красиво отрисовывать результат на странице.
Установим vue-router для того, чтобы реализовать навигацию по «страницам» (по факту — по маршрутам и компонентам, поскольку страница у нас всего одна) в нашем приложении:
$ npm install --save vue-router
Добавим router.js в /src — этот компонент будет отвечать за маршрутизацию:
import Vue from 'vue' import Router from 'vue-router' import HelloWorld from '@/components/HelloWorld' import Greeting from '@/components/Greeting' Vue.use(Router) export default new Router({ mode: 'history', routes: [ { path: '/', name: 'Greeting', component: Greeting }, { path: '/hello-world', name: 'HelloWorld', component: HelloWorld } ] })
Примечание: по корневому маршруту ("/") нам будет доступен компонент Greeting.vue, который мы напишем чуть позже.
Сейчас же заимпортируем наш роутер. Для этого внесём изменения в
import Vue from 'vue' import App from './App.vue' import router from './router' Vue.config.productionTip = false new Vue({ router, render: h => h(App), }).$mount('#app')
Затем
<template> <div id="app"> <router-view></router-view> </div> </template> <script> export default { name: 'app' } </script> <style> </style>
Для выполнения запросов к серверу воспользуемся HTTP-клиентом AXIOS:
$ npm install --save axios
Для того, чтобы не писать каждый раз одни и те же настройки (например, маршрут запросов — "/api") в каждом компоненте, я рекомендую вынести их в отельный компонент http-commons.js:
import axios from 'axios' export const AXIOS = axios.create({ baseURL: `/api` })
Примечание: чтобы избежать предупреждений при в выводе в консоль (console.log()), я рекомендую прописать эту строку в package.json:
"rules": { "no-console": "off" }
Теперь, наконец, создадим компонент (в /src/components)
import {AXIOS} from './http-common' <template> <div id="greeting"> <h3>Greeting component</h3> <p>Counter: {{ counter }}</p> <p>Username: {{ username }}</p> </div> </template> export default { name: 'Greeting', data() { return { counter: 0, username: '' } }, methods: { loadGreeting() { AXIOS.get('/greeting', { params: { name: 'Vadim' } }) .then(response => { this.$data.counter = response.data.id; this.$data.username = response.data.content; }) .catch(error => { console.log('ERROR: ' + error.response.data); }) } }, mounted() { this.loadGreeting(); } }
Примечание:
- Параметры запросы захардкожены для того, чтобы просто посмотреть, как работает метод
- Функция загрузки и отрисовки данных (
loadGreeting()) вызывается сразу после загрузки страницы (mounted()) - мы импортировали AXIOS уже с нашими кастомными настройками из http-common
Подключение к базе данных
Теперь давайте рассмотрим процесс взаимодействия с базой данных на примере PostgreSQL и Spring Data.
Для начала создадим тестовую табличку:
CREATE TABLE public."person" ( id serial NOT NULL, name character varying, PRIMARY KEY (id) );
и наполним её данными:
INSERT INTO person (name) VALUES ('John'), ('Griselda'), ('Bobby');
<properties> ... <postgresql.version>42.2.5</postgresql.version> ... </properties> ... <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <dependency> <groupId>org.postgresql</groupId> <artifactId>postgresql</artifactId> <version>${postgresql.version}</version> </dependency> ... <plugin> <groupId>org.jetbrains.kotlin</groupId> <artifactId>kotlin-maven-plugin</artifactId> <configuration> <args> <arg>-Xjsr305=strict</arg> </args> <compilerPlugins> <plugin>spring</plugin> <plugin>jpa</plugin> </compilerPlugins> </configuration> ... <dependency> <groupId>org.jetbrains.kotlin</groupId> <artifactId>kotlin-maven-noarg</artifactId> <version>${kotlin.version}</version> </dependency>
Теперь дополним файл application.properties модуля бэкенда настройками подключения к БД:
spring.datasource.url=${SPRING_DATASOURCE_URL} spring.datasource.username=${SPRING_DATASOURCE_USERNAME} spring.datasource.password=${SPRING_DATASOURCE_PASSWORD} spring.jpa.generate-ddl=true spring.jpa.properties.hibernate.temp.use_jdbc_metadata_defaults = false spring.jpa.database-platform=org.hibernate.dialect.PostgreSQL9Dialect spring.jpa.properties.hibernate.jdbc.lob.non_contextual_creation=true
Примечание: в таком виде первые три параметра ссылаются на переменные среды. Я настоятельно рекомендую передавать конфиденциальные параметры через переменные среды или параметры запуска. Но, если вы точно уверены, что они не попадут в руки коварных злоумышленников, то можете задать их явно.
Создадим сущность (entity-класс) для объектно-реляционного отображения:
import javax.persistence.Column import javax.persistence.Entity import javax.persistence.GeneratedValue import javax.persistence.GenerationType import javax.persistence.Id import javax.persistence.Table @Entity @Table (name="person") data class Person( @Id @GeneratedValue(strategy = GenerationType.AUTO) val id: Long, @Column(nullable = false) val name: String )
И CRUD-репозиторий для работы с нашей таблицей:
import com.kotlinspringvue.backend.jpa.Person import org.springframework.stereotype.Repository import org.springframework.data.repository.CrudRepository import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.repository.query.Param @Repository interface PersonRepository: CrudRepository<Person, Long> {}
Примечание: Мы будем пользоваться методом findAll(), который нет необходимости переопределять, поэтому оставим тело пустым.
И, наконец, обновим наш контроллер, чтобы увидеть работу с базой данных в действии:
import com.kotlinspringvue.backend.repository.PersonRepository import org.springframework.beans.factory.annotation.Autowired … @Autowired lateinit var personRepository: PersonRepository … @GetMapping("/persons") fun getPersons() = personRepository.findAll()
Запустим приложение, перейдём по ссылке https://localhost:8080/api/persons, чтобы убедиться, что всё работает:
[{"id":1,"name":"John"},{"id":2,"name":"Griselda"},{"id":3,"name":"Bobby"}]
Аутентификация
Теперь мы можем перейти к аутентификации — также одной из базовых функций приложений, где предусмотрено разграничение доступа к данным.
Рассмотрим реализацию собственного сервера авторизации с использованием JWT (JSON Web Token).
Почему не Basic Authentication?
- На мой взгляд, Basic Authentication не отвечает современному вызову угроз даже в относительно безопасной среде использования.
- На эту тему можно найти гораздо больше материалов.
Почему не OAuth из коробки Spring Security OAuth?
- Потому что по OAuth больше материалов.
- Такой подход может диктоваться внешними обстоятельствами: требованиями заказчика, прихотью архитектора и т.д.
- Если Вы начинающий разработчик, то в стратегической перспективе будет полезно поковыряться с функционалом безопасности более детально.
Бэкенд
Пусть в нашем приложении помимо гостей будет две группы пользователей — рядовые пользователи и администраторы. Создадим три таблицы: users — для хранения данных пользователей, roles — для хранения информации о ролях и users_roles — для связывания первых двух таблиц.
CREATE TABLE public.users ( id serial NOT NULL, username character varying, first_name character varying, last_name character varying, email character varying, password character varying, enabled boolean, PRIMARY KEY (id) ); CREATE TABLE public.roles ( id serial NOT NULL, name character varying, PRIMARY KEY (id) ); CREATE TABLE public.users_roles ( id serial NOT NULL, user_id integer, role_id integer, PRIMARY KEY (id) ); ALTER TABLE public.users_roles ADD CONSTRAINT users_roles_users_fk FOREIGN KEY (user_id) REFERENCES public.users (id) MATCH SIMPLE ON UPDATE CASCADE ON DELETE CASCADE; ALTER TABLE public.users_roles ADD CONSTRAINT users_roles_roles_fk FOREIGN KEY (role_id) REFERENCES public.roles (id) MATCH SIMPLE ON UPDATE CASCADE ON DELETE CASCADE; INSERT INTO roles (name) VALUES ('ROLE_USER'), ('ROLE_ADMIN');
Создадим Entity-классы:
import javax.persistence.* @Entity @Table(name = "users") data class User ( @Id @GeneratedValue(strategy = GenerationType.AUTO) val id: Long? = 0, @Column(name="username") var username: String?=null, @Column(name="first_name") var firstName: String?=null, @Column(name="last_name") var lastName: String?=null, @Column(name="email") var email: String?=null, @Column(name="password") var password: String?=null, @Column(name="enabled") var enabled: Boolean = false, @ManyToMany(fetch = FetchType.EAGER) @JoinTable( name = "users_roles", joinColumns = [JoinColumn(name = "user_id", referencedColumnName = "id")], inverseJoinColumns = [JoinColumn(name = "role_id", referencedColumnName = "id")] ) var roles: Collection<Role>? = null )
Примечание: таблицы users и roles находятся в отношении «многие-ко-многим» — у одного пользователя может быть несколько ролей (например, рядовой пользователь и администратор), и одной ролью могут быть наделены несколько пользователей.
Информация к размышлению: Существует подход, когда пользователей наделяют отдельными полномочиями (authorities), в то время как роль подразумевает группы полномочий. Подробнее о разнице между ролями и полномочиями можно прочитать здесь: Granted Authority Versus Role in Spring Security.
import javax.persistence.* @Entity @Table(name = "roles") data class Role ( @Id @GeneratedValue(strategy = GenerationType.AUTO) val id: Long, @Column(name="name") val name: String )
Создадим репозитории для работы с таблицами:
import java.util.Optional import com.kotlinspringvue.backend.jpa.User import org.springframework.data.repository.CrudRepository import org.springframework.data.repository.query.Param import org.springframework.data.jpa.repository.JpaRepository import javax.transaction.Transactional interface UserRepository: JpaRepository<User, Long> { fun existsByUsername(@Param("username") username: String): Boolean fun findByUsername(@Param("username") username: String): Optional<User> fun findByEmail(@Param("email") email: String): Optional<User> @Transactional fun deleteByUsername(@Param("username") username: String) }
import com.kotlinspringvue.backend.jpa.Role import org.springframework.data.repository.CrudRepository import org.springframework.data.repository.query.Param import org.springframework.data.jpa.repository.JpaRepository interface RoleRepository : JpaRepository<Role, Long> { fun findByName(@Param("name") name: String): Role }
Добавим новые зависимости в
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>com.fasterxml.jackson.module</groupId> <artifactId>jackson-module-kotlin</artifactId> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.9.0</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.10.6</version> </dependency>
И добавим новые параметры для работы с токенами в application.properties:
assm.app.jwtSecret=jwtAssmSecretKey assm.app.jwtExpiration=86400
Теперь создадим классы для хранения данных, приходящих с форм авторизации и регистрации:
class LoginUser : Serializable { @JsonProperty("username") var username: String? = null @JsonProperty("password") var password: String? = null constructor() {} constructor(username: String, password: String) { this.username = username this.password = password } companion object { private const val serialVersionUID = -1764970284520387975L } }
import com.fasterxml.jackson.annotation.JsonProperty import java.io.Serializable class NewUser : Serializable { @JsonProperty("username") var username: String? = null @JsonProperty("firstName") var firstName: String? = null @JsonProperty("lastName") var lastName: String? = null @JsonProperty("email") var email: String? = null @JsonProperty("password") var password: String? = null constructor() {} constructor(username: String, firstName: String, lastName: String, email: String, password: String, recaptchaToken: String) { this.username = username this.firstName = firstName this.lastName = lastName this.email = email this.password = password } companion object { private const val serialVersionUID = -1764970284520387975L } }
Сделаем специальные классы для ответов сервера — возвращающий токен аутентификации и универсальный (строка):
import org.springframework.security.core.GrantedAuthority class JwtResponse(var accessToken: String?, var username: String?, val authorities: Collection<GrantedAuthority>) { var type = "Bearer" }
class ResponseMessage(var message: String?)
Также нам понадобится исключение «User Already Exists»
class UserAlreadyExistException : RuntimeException { constructor() : super() {} constructor(message: String, cause: Throwable) : super(message, cause) {} constructor(message: String) : super(message) {} constructor(cause: Throwable) : super(cause) {} companion object { private val serialVersionUID = 5861310537366287163L } }
Для определения ролей пользователей нам необходим дополнительный сервис, реализующий интерфейс UserDetailsService:
import com.kotlinspringvue.backend.repository.UserRepository import org.springframework.beans.factory.annotation.Autowired import org.springframework.security.core.userdetails.UserDetails import org.springframework.security.core.userdetails.UserDetailsService import org.springframework.security.core.userdetails.UsernameNotFoundException import org.springframework.stereotype.Service import org.springframework.security.core.GrantedAuthority import org.springframework.security.core.authority.SimpleGrantedAuthority import java.util.stream.Collectors @Service class UserDetailsServiceImpl: UserDetailsService { @Autowired lateinit var userRepository: UserRepository @Throws(UsernameNotFoundException::class) override fun loadUserByUsername(username: String): UserDetails { val user = userRepository.findByUsername(username).get() ?: throw UsernameNotFoundException("User '$username' not found") val authorities: List<GrantedAuthority> = user.roles!!.stream().map({ role -> SimpleGrantedAuthority(role.name)}).collect(Collectors.toList<GrantedAuthority>()) return org.springframework.security.core.userdetails.User .withUsername(username) .password(user.password) .authorities(authorities) .accountExpired(false) .accountLocked(false) .credentialsExpired(false) .disabled(false) .build() } }
Для работы с JWT нам потребуются три класса:
JwtAuthEntryPoint — для обработки ошибок авторизации и дальнейшего использования в настройках веб-безопасности:
import javax.servlet.ServletException import javax.servlet.http.HttpServletRequest import javax.servlet.http.HttpServletResponse import org.slf4j.Logger import org.slf4j.LoggerFactory import org.springframework.security.core.AuthenticationException import org.springframework.security.web.AuthenticationEntryPoint import org.springframework.stereotype.Component @Component class JwtAuthEntryPoint : AuthenticationEntryPoint { @Throws(IOException::class, ServletException::class) override fun commence(request: HttpServletRequest, response: HttpServletResponse, e: AuthenticationException) { logger.error("Unauthorized error. Message - {}", e!!.message) response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid credentials") } companion object { private val logger = LoggerFactory.getLogger(JwtAuthEntryPoint::class.java) } }
JwtProvider — чтобы генерировать и валидировать токены, а также определять пользователя по его токену:
import io.jsonwebtoken.* import org.springframework.beans.factory.annotation.Autowired import org.slf4j.Logger import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Value import org.springframework.security.core.Authentication import org.springframework.stereotype.Component import org.springframework.security.core.GrantedAuthority import org.springframework.security.core.authority.SimpleGrantedAuthority import com.kotlinspringvue.backend.repository.UserRepository import java.util.Date @Component public class JwtProvider { private val logger: Logger = LoggerFactory.getLogger(JwtProvider::class.java) @Autowired lateinit var userRepository: UserRepository @Value("\${assm.app.jwtSecret}") lateinit var jwtSecret: String @Value("\${assm.app.jwtExpiration}") var jwtExpiration:Int?=0 fun generateJwtToken(username: String): String { return Jwts.builder() .setSubject(username) .setIssuedAt(Date()) .setExpiration(Date((Date()).getTime() + jwtExpiration!! * 1000)) .signWith(SignatureAlgorithm.HS512, jwtSecret) .compact() } fun validateJwtToken(authToken: String): Boolean { try { Jwts.parser().setSigningKey(jwtSecret).parseClaimsJws(authToken) return true } catch (e: SignatureException) { logger.error("Invalid JWT signature -> Message: {} ", e) } catch (e: MalformedJwtException) { logger.error("Invalid JWT token -> Message: {}", e) } catch (e: ExpiredJwtException) { logger.error("Expired JWT token -> Message: {}", e) } catch (e: UnsupportedJwtException) { logger.error("Unsupported JWT token -> Message: {}", e) } catch (e: IllegalArgumentException) { logger.error("JWT claims string is empty -> Message: {}", e) } return false } fun getUserNameFromJwtToken(token: String): String { return Jwts.parser() .setSigningKey(jwtSecret) .parseClaimsJws(token) .getBody().getSubject() } }
JwtAuthTokenFilter — чтобы аутентифицировать пользователей и фильтровать запросы:
import java.io.IOException import javax.servlet.FilterChain import javax.servlet.ServletException import javax.servlet.http.HttpServletRequest import javax.servlet.http.HttpServletResponse import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Autowired import org.springframework.security.authentication.UsernamePasswordAuthenticationToken import org.springframework.security.core.context.SecurityContextHolder import org.springframework.security.core.userdetails.UserDetails import org.springframework.security.web.authentication.WebAuthenticationDetailsSource import org.springframework.web.filter.OncePerRequestFilter import com.kotlinspringvue.backend.service.UserDetailsServiceImpl class JwtAuthTokenFilter : OncePerRequestFilter() { @Autowired private val tokenProvider: JwtProvider? = null @Autowired private val userDetailsService: UserDetailsServiceImpl? = null @Throws(ServletException::class, IOException::class) override fun doFilterInternal(request: HttpServletRequest, response: HttpServletResponse, filterChain: FilterChain) { try { val jwt = getJwt(request) if (jwt != null && tokenProvider!!.validateJwtToken(jwt)) { val username = tokenProvider.getUserNameFromJwtToken(jwt) val userDetails = userDetailsService!!.loadUserByUsername(username) val authentication = UsernamePasswordAuthenticationToken( userDetails, null, userDetails.getAuthorities()) authentication.setDetails(WebAuthenticationDetailsSource().buildDetails(request)) SecurityContextHolder.getContext().setAuthentication(authentication) } } catch (e: Exception) { logger.error("Can NOT set user authentication -> Message: {}", e) } filterChain.doFilter(request, response) } private fun getJwt(request: HttpServletRequest): String? { val authHeader = request.getHeader("Authorization") return if (authHeader != null && authHeader.startsWith("Bearer ")) { authHeader.replace("Bearer ", "") } else null } companion object { private val logger = LoggerFactory.getLogger(JwtAuthTokenFilter::class.java) } }
Теперь мы можем сконфигурировать бин, ответственный за веб-безопасность:
import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.beans.factory.annotation.Autowired import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter import org.springframework.security.config.http.SessionCreationPolicy import org.springframework.security.authentication.AuthenticationManager import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity import org.springframework.security.config.annotation.web.builders.HttpSecurity import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder import com.kotlinspringvue.backend.jwt.JwtAuthEntryPoint import com.kotlinspringvue.backend.jwt.JwtAuthTokenFilter import com.kotlinspringvue.backend.service.UserDetailsServiceImpl @Configuration @EnableWebSecurity @EnableGlobalMethodSecurity(prePostEnabled = true) class WebSecurityConfig : WebSecurityConfigurerAdapter() { @Autowired internal var userDetailsService: UserDetailsServiceImpl? = null @Autowired private val unauthorizedHandler: JwtAuthEntryPoint? = null @Bean fun bCryptPasswordEncoder(): BCryptPasswordEncoder { return BCryptPasswordEncoder() } @Bean fun authenticationJwtTokenFilter(): JwtAuthTokenFilter { return JwtAuthTokenFilter() } @Throws(Exception::class) override fun configure(authenticationManagerBuilder: AuthenticationManagerBuilder) { authenticationManagerBuilder .userDetailsService(userDetailsService) .passwordEncoder(bCryptPasswordEncoder()) } @Bean @Throws(Exception::class) override fun authenticationManagerBean(): AuthenticationManager { return super.authenticationManagerBean() } @Throws(Exception::class) override protected fun configure(http: HttpSecurity) { http.csrf().disable().authorizeRequests() .antMatchers("/**").permitAll() .anyRequest().authenticated() .and() .exceptionHandling().authenticationEntryPoint(unauthorizedHandler).and() .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) http.addFilterBefore(authenticationJwtTokenFilter(), UsernamePasswordAuthenticationFilter::class.java) } }
Создадим контроллер для регистрации и авторизации:
import javax.validation.Valid import java.util.* import java.util.stream.Collectors import org.springframework.security.core.Authentication import org.springframework.beans.factory.annotation.Autowired import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity import org.springframework.security.authentication.AuthenticationManager import org.springframework.security.authentication.UsernamePasswordAuthenticationToken import org.springframework.security.core.context.SecurityContextHolder import org.springframework.security.core.userdetails.UserDetails import org.springframework.security.crypto.password.PasswordEncoder import org.springframework.security.access.prepost.PreAuthorize import org.springframework.security.core.GrantedAuthority import org.springframework.security.core.authority.SimpleGrantedAuthority import org.springframework.web.bind.annotation.CrossOrigin import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController import com.kotlinspringvue.backend.model.LoginUser import com.kotlinspringvue.backend.model.NewUser import com.kotlinspringvue.backend.web.response.JwtResponse import com.kotlinspringvue.backend.web.response.ResponseMessage import com.kotlinspringvue.backend.jpa.User import com.kotlinspringvue.backend.repository.UserRepository import com.kotlinspringvue.backend.repository.RoleRepository import com.kotlinspringvue.backend.jwt.JwtProvider @CrossOrigin(origins = ["*"], maxAge = 3600) @RestController @RequestMapping("/api/auth") class AuthController() { @Autowired lateinit var authenticationManager: AuthenticationManager @Autowired lateinit var userRepository: UserRepository @Autowired lateinit var roleRepository: RoleRepository @Autowired lateinit var encoder: PasswordEncoder @Autowired lateinit var jwtProvider: JwtProvider @PostMapping("/signin") fun authenticateUser(@Valid @RequestBody loginRequest: LoginUser): ResponseEntity<*> { val userCandidate: Optional <User> = userRepository.findByUsername(loginRequest.username!!) if (userCandidate.isPresent) { val user: User = userCandidate.get() val authentication = authenticationManager.authenticate( UsernamePasswordAuthenticationToken(loginRequest.username, loginRequest.password)) SecurityContextHolder.getContext().setAuthentication(authentication) val jwt: String = jwtProvider.generateJwtToken(user.username!!) val authorities: List<GrantedAuthority> = user.roles!!.stream().map({ role -> SimpleGrantedAuthority(role.name)}).collect(Collectors.toList<GrantedAuthority>()) return ResponseEntity.ok(JwtResponse(jwt, user.username, authorities)) } else { return ResponseEntity(ResponseMessage("User not found!"), HttpStatus.BAD_REQUEST) } } @PostMapping("/signup") fun registerUser(@Valid @RequestBody newUser: NewUser): ResponseEntity<*> { val userCandidate: Optional <User> = userRepository.findByUsername(newUser.username!!) if (!userCandidate.isPresent) { if (usernameExists(newUser.username!!)) { return ResponseEntity(ResponseMessage("Username is already taken!"), HttpStatus.BAD_REQUEST) } else if (emailExists(newUser.email!!)) { return ResponseEntity(ResponseMessage("Email is already in use!"), HttpStatus.BAD_REQUEST) } // Creating user's account val user = User( 0, newUser.username!!, newUser.firstName!!, newUser.lastName!!, newUser.email!!, encoder.encode(newUser.password), true ) user!!.roles = Arrays.asList(roleRepository.findByName("ROLE_USER")) userRepository.save(user) return ResponseEntity(ResponseMessage("User registered successfully!"), HttpStatus.OK) } else { return ResponseEntity(ResponseMessage("User already exists!"), HttpStatus.BAD_REQUEST) } } private fun emailExists(email: String): Boolean { return userRepository.findByUsername(email).isPresent } private fun usernameExists(username: String): Boolean { return userRepository.findByUsername(username).isPresent } }
Мы реализовали два метода:
- signin — проверяет, существует ли пользователь и, если да, то возвращает сгенерированный токен, имя пользователя и его роли (вернее, authorities — полномочия)
- signup — проверяет, существует ли пользователь и, если нет, создаёт новую запись в таблице users с внешней ссылкой на роль ROLE_USER
И, наконец, дополним BackendController двумя методами: один будет возвращать данные, доступные только администратору (пользователь с полномочиями ROLE_USER и ROLE_ADMIN) и рядовому пользователю (ROLE_USER).
import org.springframework.security.access.prepost.PreAuthorize import org.springframework.security.core.Authentication import com.kotlinspringvue.backend.repository.UserRepository import com.kotlinspringvue.backend.jpa.User … @Autowired lateinit var userRepository: UserRepository … @GetMapping("/usercontent") @PreAuthorize("hasRole('USER') or hasRole('ADMIN')") @ResponseBody fun getUserContent(authentication: Authentication): String { val user: User = userRepository.findByUsername(authentication.name).get() return "Hello " + user.firstName + " " + user.lastName + "!" } @GetMapping("/admincontent") @PreAuthorize("hasRole('ADMIN')") @ResponseBody fun getAdminContent(): String { return "Admin's content" }
Фронтенд
Создадим несколько новых компонентов:
- Home
- SignIn
- SignUp
- AdminPage
- UserPage
С шаблонным содержимым (для удобного копипаста начала):
<template> <div> </div> </template> <script> </script> <style> </style>
Добавим id=«название_компонента» в каждый div внутри template и export default {name: ‘[component_name]’} в script.
Теперь добавим новые маршруты:
import Vue from 'vue' import Router from 'vue-router' import Home from '@/components/Home' import SignIn from '@/components/SignIn' import SignUp from '@/components/SignUp' import AdminPage from '@/components/AdminPage' import UserPage from '@/components/UserPage' Vue.use(Router) export default new Router({ mode: 'history', routes: [ { path: '/', name: 'Home', component: Home }, { path: '/home', name: 'Home', component: Home }, { path: '/login', name: 'SignIn', component: SignIn }, { path: '/register', name: 'SignUp', component: SignUp }, { path: '/user', name: 'UserPage', component: UserPage }, { path: '/admin', name: 'AdminPage', component: AdminPage } ] })
Для хранения токенов и использования их при запросах к серверу воспользуемся Vuex. Vuex — это паттерн управления состоянием + библиотека Vue.js. Он служит централизованным хранилищем данных для всех компонентов приложения с правилами, гарантирующими, что состояние может быть изменено только предсказуемым образом.
$ npm install --save vuex
Добавим store в виде отдельного файла в src/store:
import Vue from 'vue'; import Vuex from 'vuex'; Vue.use(Vuex); const state = { token: localStorage.getItem('user-token') || '', role: localStorage.getItem('user-role') || '', username: localStorage.getItem('user-name') || '', authorities: localStorage.getItem('authorities') || '', }; const getters = { isAuthenticated: state => { if (state.token != null && state.token != '') { return true; } else { return false; } }, isAdmin: state => { if (state.role === 'admin') { return true; } else { return false; } }, getUsername: state => { return state.username; }, getAuthorities: state => { return state.authorities; }, getToken: state => { return state.token; } }; const mutations = { auth_login: (state, user) => { localStorage.setItem('user-token', user.token); localStorage.setItem('user-name', user.name); localStorage.setItem('user-authorities', user.roles); state.token = user.token; state.username = user.username; state.authorities = user.roles; var isUser = false; var isAdmin = false; for (var i = 0; i < user.roles.length; i++) { if (user.roles[i].authority === 'ROLE_USER') { isUser = true; } else if (user.roles[i].authority === 'ROLE_ADMIN') { isAdmin = true; } } if (isUser) { localStorage.setItem('user-role', 'user'); state.role = 'user'; } if (isAdmin) { localStorage.setItem('user-role', 'admin'); state.role = 'admin'; } }, auth_logout: () => { state.token = ''; state.role = ''; state.username = ''; state.authorities = []; localStorage.removeItem('user-token'); localStorage.removeItem('user-role'); localStorage.removeItem('user-name'); localStorage.removeItem('user-authorities'); } }; const actions = { login: (context, user) => { context.commit('auth_login', user) }, logout: (context) => { context.commit('auth_logout'); } }; export const store = new Vuex.Store({ state, getters, mutations, actions });
Посмотрим, что у нас тут есть:
- store — собственно, данные для передачи между компонентами — имя пользователя, токен, полномочия и роль (в данном контексте роль — обещающая сущность для полномочий (authorities): посколько полномочия простого пользователя — это подмножество полномочий администратора, то мы можем просто сказать, что пользователь с полномочиями admin и user — администратор
- getters — функции для определения особых аспектов состояния
- mutations — функции для изменения состояния
- actions — функции для фиксации мутаций, они могут содержать асинхронные операции
Важно: использование мутаций (mutations) — это единственный правильный способ изменения состояния.
Внесём соответствующие изменения в
import { store } from './store'; ... new Vue({ router, store, render: h => h(App) }).$mount('#app')
Для того, чтобы интерфейс сразу выглядел красиво и опрятно даже в экспериментальном приложении я использую . Но это, как говорится, дело вкуса, и на базовую функциональность не влияет:
$ npm install --save bootstrap bootstrap-vue
import BootstrapVue from 'bootstrap-vue' import 'bootstrap/dist/css/bootstrap.css' import 'bootstrap-vue/dist/bootstrap-vue.css' … Vue.use(BootstrapVue)
Теперь поработаем над компонентом App:
- Добавим возможность «разлогинивания» для всех авторизованных пользователей
- Добавим автоматическую переадресацию на домашнюю страницу после выхода (logout)
- Будем показывать кнопки меню навигации «User» и «Logout» для всех авторизованных пользователей и «Login» — для неавторизованных
- Будем показывать кнопку «Admin» меню навигации только авторизованным администраторам
Для этого:
methods: { logout() { this.$store.dispatch('logout'); this.$router.push('/') } }
<template> <div id="app"> <b-navbar style="width: 100%" type="dark" variant="dark"> <b-navbar-brand id="nav-brand" href="#">Kotlin+Spring+Vue</b-navbar-brand> <router-link to="/"><img height="30px" src="./assets/img/kotlin-logo.png" alt="Kotlin+Spring+Vue"/></router-link> <router-link to="/"><img height="30px" src="./assets/img/spring-boot-logo.png" alt="Kotlin+Spring+Vue"/></router-link> <router-link to="/"><img height="30px" src="./assets/img/vuejs-logo.png" alt="Kotlin+Spring+Vue"/></router-link> <router-link to="/user" class="nav-link text-light" v-if="this.$store.getters.isAuthenticated">User</router-link> <router-link to="/admin" class="nav-link text-light" v-if="this.$store.getters.isAuthenticated && this.$store.getters.isAdmin">Admin</router-link> <router-link to="/register" class="nav-link text-light" v-if="!this.$store.getters.isAuthenticated">Register</router-link> <router-link to="/login" class="nav-link text-light" v-if="!this.$store.getters.isAuthenticated">Login</router-link> <a href="#" class="nav-link text-light" v-if="this.$store.getters.isAuthenticated" v-on:click="logout">Logout </a> </b-navbar> <router-view></router-view> </div> </template>
Примечание:
- Через store мы получаем информацию о полномочиях пользователя и о том, авторизован ли он. В зависимости от этого принимаем решение, какие кнопки показывать, а какие скрывать («v-if»)
- В панель навигации я добавил логотипы Kotlin, Spring Boot и Vue.js, лежащие в /assets/img/. Их можно либо убрать совсем, либо взять из репозитория моего приложения (ссылка есть в конце статьи)
Обновим компоненты:
<template> <div div="home"> <b-jumbotron> <template slot="header">Kotlin + Spring Boot + Vue.js</template> <template slot="lead"> This is the demo web-application written in Kotlin using Spring Boot and Vue.js for frontend </template> <hr class="my-4" /> <p v-if="!this.$store.getters.isAuthenticated"> Login and start </p> <router-link to="/login" v-if="!this.$store.getters.isAuthenticated"> <b-button variant="primary">Login</b-button> </router-link> </b-jumbotron> </div> </template> <script> </script> <style> </style>
<template> <div div="signin"> <div class="login-form"> <b-card title="Login" tag="article" style="max-width: 20rem;" class="mb-2" > <div> <b-alert :show="dismissCountDown" dismissible variant="danger" @dismissed="dismissCountDown=0" @dismiss-count-down="countDownChanged" > {{ alertMessage }} </b-alert> </div> <div> <b-form-input type="text" placeholder="Username" v-model="username" /> <div class="mt-2"></div> <b-form-input type="password" placeholder="Password" v-model="password" /> <div class="mt-2"></div> </div> <b-button v-on:click="login" variant="primary">Login</b-button> <hr class="my-4" /> <b-button variant="link">Forget password?</b-button> </b-card> </div> </div> </template> <script> import {AXIOS} from './http-common' export default { name: 'SignIn', data() { return { username: '', password: '', dismissSecs: 5, dismissCountDown: 0, alertMessage: 'Request error', } }, methods: { login() { AXIOS.post(`/auth/signin`, {'username': this.$data.username, 'password': this.$data.password}) .then(response => { this.$store.dispatch('login', {'token': response.data.accessToken, 'roles': response.data.authorities, 'username': response.data.username}); this.$router.push('/home') }, error => { this.$data.alertMessage = (error.response.data.message.length < 150) ? error.response.data.message : 'Request error. Please, report this error website owners'; console.log(error) }) .catch(e => { console.log(e); this.showAlert(); }) }, countDownChanged(dismissCountDown) { this.dismissCountDown = dismissCountDown }, showAlert() { this.dismissCountDown = this.dismissSecs }, } } </script> <style> .login-form { margin-left: 38%; margin-top: 50px; } </style>
Что тут происходит:
- Запрос авторизации отправляется на сервер с помощью POST-запроса
- От сервера мы получаем токен и сохраняем его в storage
- Показываем «красивое» сообщение от Bootstrap об ошибке в случае ошибки
- Если авторизация проходит успешно, переадресовываем пользователя на /home
<template> <div div="signup"> <div class="login-form"> <b-card title="Register" tag="article" style="max-width: 20rem;" class="mb-2" > <div> <b-alert :show="dismissCountDown" dismissible variant="danger" @dismissed="dismissCountDown=0" @dismiss-count-down="countDownChanged" > {{ alertMessage }} </b-alert> </div> <div> <b-alert variant="success" :show="successfullyRegistered"> You have been successfully registered! Now you can login with your credentials <hr /> <router-link to="/login"> <b-button variant="primary">Login</b-button> </router-link> </b-alert> </div> <div> <b-form-input type="text" placeholder="Username" v-model="username" /> <div class="mt-2"></div> <b-form-input type="text" placeholder="First Name" v-model="firstname" /> <div class="mt-2"></div> <b-form-input type="text" placeholder="Last name" v-model="lastname" /> <div class="mt-2"></div> <b-form-input type="text" placeholder="Email" v-model="email" /> <div class="mt-2"></div> <b-form-input type="password" placeholder="Password" v-model="password" /> <div class="mt-2"></div> <b-form-input type="password" placeholder="Confirm Password" v-model="confirmpassword" /> <div class="mt-2"></div> </div> <b-button v-on:click="register" variant="primary">Register</b-button> </b-card> </div> </div> </template> <script> import {AXIOS} from './http-common' export default { name: 'SignUp', data () { return { username: '', firstname: '', lastname: '', email: '', password: '', confirmpassword: '', dismissSecs: 5, dismissCountDown: 0, alertMessage: '', successfullyRegistered: false } }, methods: { register: function () { if (this.$data.username === '' || this.$data.username == null) { this.$data.alertMessage = 'Please, fill "Username" field'; this.showAlert(); } else if (this.$data.firstname === '' || this.$data.firstname == null) { this.$data.alertMessage = 'Please, fill "First name" field'; this.showAlert(); } else if (this.$data.lastname === '' || this.$data.lastname == null) { this.$data.alertMessage = 'Please, fill "Last name" field'; this.showAlert(); } else if (this.$data.email === '' || this.$data.email == null) { this.$data.alertMessage = 'Please, fill "Email" field'; this.showAlert(); } else if (!this.$data.email.includes('@')) { this.$data.alertMessage = 'Email is incorrect'; this.showAlert(); } else if (this.$data.password === '' || this.$data.password == null) { this.$data.alertMessage = 'Please, fill "Password" field'; this.showAlert(); } else if (this.$data.confirmpassword === '' || this.$data.confirmpassword == null) { this.$data.alertMessage = 'Please, confirm password'; this.showAlert(); } else if (this.$data.confirmpassword !== this.$data.password) { this.$data.alertMessage = 'Passwords are not match'; this.showAlert(); } else { var newUser = { 'username': this.$data.username, 'firstName': this.$data.firstname, 'lastName': this.$data.lastname, 'email': this.$data.email, 'password': this.$data.password }; AXIOS.post('/auth/signup', newUser) .then(response => { console.log(response); this.successAlert(); }, error => { this.$data.alertMessage = (error.response.data.message.length < 150) ? error.response.data.message : 'Request error. Please, report this error website owners' this.showAlert(); }) .catch(error => { console.log(error); this.$data.alertMessage = 'Request error. Please, report this error website owners'; this.showAlert(); }); } }, countDownChanged(dismissCountDown) { this.dismissCountDown = dismissCountDown }, showAlert() { this.dismissCountDown = this.dismissSecs }, successAlert() { this.username = ''; this.firstname = ''; this.lastname = ''; this.email = ''; this.password = ''; this.confirmpassword = ''; this.successfullyRegistered = true; } } } </script> <style> .login-form { margin-left: 38%; margin-top: 50px; } </style>
Что тут происходит:
- Данные с формы регистрации передаются на сервер с помощью POST-запроса
- Показывается сообщение об ошибке от Bootstrap в случае ошибки
- Если регистрация прошла успешно, выводим Bootstrap-овское сообщение с предложением авторизоваться
- Перед отправкой запроса происходит валидация полей
<template> <div div="userpage"> <h2>{{ pageContent }}</h2> </div> </template> <script> import {AXIOS} from './http-common' export default { name: 'UserPage', data() { return { pageContent: '' } }, methods: { loadUserContent() { const header = {'Authorization': 'Bearer ' + this.$store.getters.getToken}; AXIOS.get('/usercontent', { headers: header }) .then(response => { this.$data.pageContent = response.data; }) .catch(error => { console.log('ERROR: ' + error.response.data); }) } }, mounted() { this.loadUserContent(); } } </script> <style> </style>
Что тут происходит:
- Загрузка данных с сервера происходит сразу после загрузки страницы
- Вместе с запросом мы передаём токен, хранящийся в storage
- Полученные данные мы отрисовываем на странице
<template> <div div="adminpage"> <h2>{{ pageContent }}</h2> </div> </template> <script> import {AXIOS} from './http-common' export default { name: 'AdminPage', data() { return { pageContent: '' } }, methods: { loadUserContent() { const header = {'Authorization': 'Bearer ' + this.$store.getters.getToken}; AXIOS.get('/admincontent', { headers: header }) .then(response => { this.$data.pageContent = response.data; }) .catch(error => { console.log('ERROR: ' + error.response.data); }) } }, mounted() { this.loadUserContent(); } } </script> <style> </style>
Здесь происходит всё то же самое, что и в UserPage.
Запуск приложения
Зарегистрируем нашего первого администратора:


Важно: по умолчанию все новые пользователи — обычные. Дадим первому администратору его полномочия:
INSERT INTO users_roles (user_id, role_id) VALUES (1, 2);
Затем:
- Зайдём под учётной записью администратора
- Проверим страницу User:

- Проверим страницу Admin:

- Выйдем из администраторской учётной записи
- Зарегистрируем аккаунт обычного пользователя
- Проверим доступность страницы User
- Попробуем получить администраторские данные, используя REST API: http://localhost:8080/api/admincontent
ERROR 77100 --- [nio-8080-exec-2] c.k.backend.jwt.JwtAuthEntryPoint : Unauthorized error. Message - Full authentication is required to access this resource
Пути улучшения
Вообще говоря, их в любом деле всегда очень много. Перечислю самые очевидные:
- Использовать для сборки Gradle (если считать это улучшением)
- Сразу покрывать код модульными тестами (это уже, без сомнения, хорошая практика)
- С самого начала выстраивать CI/CD Pipeline: размещать код в репозитории, контейнизировать приложение, автоматизировать сборку и деплой
- Добавить PUT и DELETE запросы (например, обновление данных пользователей и удаление учётных записей)
- Реализовать активацию/деактивацию уча юных записей
- Не использовать local storage для хранения токена — это не безопасно
- Использовать OAuth
- Верифицировать адреса электронной почты при регистрации нового пользователя
- Использовать защиту от спама, например, reCAPTCHA
Полезные ссылки
- То же самое руководство, только более подробное, где также рассматривается разворачивание приложения в Heroku, reCAPTCHA и работа с почтой. На английском языке, зато с картинками
- GitHub репозиторий
- Готовое приложение
- Отдельное спасибо — этот материал вдохновил меня на написание этой статьи
- Vue.js, Spring Boot, Kotlin, and GraphQL: Building Modern Apps
- Baeldung — Java, Spring and Web Development tutorials
- Vue.js Frontend with a Spring Boot Backend
- Creating a RESTful Web Service with Spring Boot (Kotlin)
- Data Classes (Kotlin)
- Data Classes in Kotlin
- JWT authentication: When and how to use it
- What is Vuex?
- Managing state in Vue.js with Vuex
ссылка на оригинал статьи https://habr.com/ru/post/467161/

Добавить комментарий