Веб-приложение на Kotlin + Spring Boot + Vue.js

от автора

Добрый день, дорогие обитатели Хабрахабра!

Не так давно мне представилась возможность реализовать небольшой проект без особых требований по технической части. То есть, я был волен выбирать стек технологий на своё усмотрение. Потому не преминул возможностью как следует «пощупать» модные, молодёжные многообещающие, но малознакомые мне на практике 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-контроллера достаточно, но интересно интересно было бы увидеть более сложную логику — например, реализацию аутентификации с разделением по ролям, что является довольно необходимым функционалом. Именно этим я дополнил своё собственное руководство.

Содержание

Краткая справка

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 должен выглядеть следующим образом:

pom.xml — backend

<?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 такого вида:

pom.xml — frontend

<?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 в корневую директорию проекта:

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 — этот компонент будет отвечать за маршрутизацию:

router.js

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, который мы напишем чуть позже.

Сейчас же заимпортируем наш роутер. Для этого внесём изменения в

main.js

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')  

Затем

App.vue

<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)

Greeting.vue

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');

Дополним pom.xml модуля бэкенда:

<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-класс) для объектно-реляционного отображения:

Person.kt

 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-репозиторий для работы с нашей таблицей:

Repository.kt

 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(), который нет необходимости переопределять, поэтому оставим тело пустым.

И, наконец, обновим наш контроллер, чтобы увидеть работу с базой данных в действии:

BackendController.kt

 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 — для связывания первых двух таблиц.

Создадим таблицы, добавим ограничения и заполним таблицу 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-классы:

User.kt

 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.

Role.kt

 import javax.persistence.*  @Entity @Table(name = "roles") data class Role (         @Id        @GeneratedValue(strategy = GenerationType.AUTO)        val id: Long,         @Column(name="name")        val name: String  ) 

Создадим репозитории для работы с таблицами:

UsersRepository.kt

 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)  } 

RolesRepository.kt

 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 } 

Добавим новые зависимости в

pom.xml модуля бэкенда

<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 

Теперь создадим классы для хранения данных, приходящих с форм авторизации и регистрации:

LoginUser.kt

 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    } } 

NewUser.kt

 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    } } 

Сделаем специальные классы для ответов сервера — возвращающий токен аутентификации и универсальный (строка):

JwtRespons.kt

 import org.springframework.security.core.GrantedAuthority  class JwtResponse(var accessToken: String?, var username: String?, val authorities:      Collection<GrantedAuthority>) {      var type = "Bearer" } 

ResponseMessage.kt

 class ResponseMessage(var message: String?) 

Также нам понадобится исключение «User Already Exists»

UserAlreadyExistException.kt

 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:

UserDetailsServiceImpl.kt

  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 — для обработки ошибок авторизации и дальнейшего использования в настройках веб-безопасности:

JwtAuthEntryPoint.kt

 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 — чтобы генерировать и валидировать токены, а также определять пользователя по его токену:

JwtProvider.kt

 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 — чтобы аутентифицировать пользователей и фильтровать запросы:

JwtAuthTokenFilter.kt

 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)    } } 

Теперь мы можем сконфигурировать бин, ответственный за веб-безопасность:

WebSecurityConfig.kt

 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)    } } 

Создадим контроллер для регистрации и авторизации:

AuthController.kt

 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).

BackendController.kt

 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.

Теперь добавим новые маршруты:

router.js

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:

index.js

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) — это единственный правильный способ изменения состояния.

Внесём соответствующие изменения в

main.js

import { store } from './store';  ...  new Vue({      router,      store,      render: h => h(App) }).$mount('#app') 

Для того, чтобы интерфейс сразу выглядел красиво и опрятно даже в экспериментальном приложении я использую . Но это, как говорится, дело вкуса, и на базовую функциональность не влияет:

$ npm install --save bootstrap bootstrap-vue

Bootstrap в main.js

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» меню навигации только авторизованным администраторам

Для этого:

добавим метод logout()

methods: {      logout() {           this.$store.dispatch('logout');           this.$router.push('/')      } } 

и отредактируем шаблон (template)

<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/. Их можно либо убрать совсем, либо взять из репозитория моего приложения (ссылка есть в конце статьи)

Обновим компоненты:

Home.vue

<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> 

SignIn.vue

<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

SignUp.vue

<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-овское сообщение с предложением авторизоваться
  • Перед отправкой запроса происходит валидация полей

UserPage.vue

<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
  • Полученные данные мы отрисовываем на странице

Admin.vue

<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); 

Затем:

  1. Зайдём под учётной записью администратора
  2. Проверим страницу User:

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

  4. Выйдем из администраторской учётной записи
  5. Зарегистрируем аккаунт обычного пользователя
  6. Проверим доступность страницы User
  7. Попробуем получить администраторские данные, используя 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

Полезные ссылки


ссылка на оригинал статьи https://habr.com/ru/post/467161/


Комментарии

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

Ваш адрес email не будет опубликован. Обязательные поля помечены *