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

Как и следует из названия, данная статья является дополнением к написанной ранее Веб-приложение на Kotlin + Spring Boot + Vue.js, позволяющим усовершенствовать скелет будущего приложения и сделать удобнее работу с ним.

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

Содержание




Настройка CI/CD (Heroku)


Рассмотрим реализацию непрерывной интеграции и доставки на примере облачной PaaS-платформы Heroku.

Первое, что нам необходимо сделать — разместить код приложения в репозитории на GitHub. Для того, чтобы в репозитории не оказалось ничего лишнего, рекомендую следующее содержание файла .gitignore:

.gitignore
*.class

# Help #
backend/*.md

# Package Files #
*.jar
*.war
*.ear

# Eclipse #
.settings
.project
.classpath
.studio
target

# NetBeans #
backend/nbproject/private/
backend/nbbuild/
backend/dist/
backend/nbdist/
backend/.nb-gradle/
backend/build/

# Apple # 
.DS_Store

# Intellij #
.idea
*.iml
*.log

# logback
logback.out.xml

backend/src/main/resources/public/
backend/target
backend/.mvn
backend/mvnw
frontend/dist/
frontend/node/
frontend/node_modules/
frontend/npm-debug.log
frontend/target

!.mvn/wrapper/maven-wrapper.jar


Важно: перед тем, как начать работу с Heroku, добавьте в корневую директорию файл с названием Procfile (без какого-либо расширения) со строкой:
web: java -Dserver.port=$PORT -jar backend/target/backend-0.0.1-SNAPSHOT.jar, где backend-0.0.1-SNAPSHOT.jar — имя собирающегося JAR-файла. И обязательно сделайте commit и push.

Примечание: также в корневую директорию можно добавить файл travis.yaml, чтобы сократить время сборки и развёртывания приложения на Heroku:

travis.yaml
language: java
jdk:
- oraclejdk8
script: mvn clean install jacoco:report coveralls:report
cache:
     directories:
     - node_modules 


Затем:

#1 Зарегистрируйтесь на Heroku.

#2 Создайте новое приложение:

Создание нового приложения


#3 Heroku позволяет подключать к приложению дополнительные ресурсы, например, базу данных PostreSQL. Для того, чтобы сделать это, выполните: Application -> Resources -> Add-ons -> Heroku Postgres:

Heroku Postgres


#4 Выберите план:

Выбор плана


#5 Теперь вы можете увидеть подключённый ресурс:

Подключённый ресурс


#6 Посмотрите учётные данные, они понадобятся для настройки переменных окружения: Settings -> View Credentials:

View Credentials


#7 Настройте переменные окружения: Application -> Settings -> Reveal Config Vars:

Переменные окружения


#8 Задайте переменные окружения для подключения в следующем формате:

SPRING_DATASOURCE_URL = jdbc:postgresql://<i>hostname:port</i>/<i>db_name</i>
SPRING_DATASOURCE_USERNAME = <i>username</i>
SPRING_DATASOURCE_PASSWORD = <i>password</i>

Как это выглядит


#9 Создайте все необходимые таблицы в новой базе данных.

#10 Файл application.properties, соотвественно, должен иметь примерно такой вид:

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 


#11 Создайте новый пайплайн — Create new pipeline:

Create new pipeline


#12 Deployment method — GitHub (нажмите Connect to GitHub и следуйте инструкциям в новом окне).

#13 Активируйте автоматическое развёртывание — Enable Automatic Deploys:

Enable Automatic Deploys


#14 Manual Deploy — нажмите Deploy Branch для первого развёртывания. Прямо в браузере вы увидите вывод командной строки.

Manual Deploy


#15 Нажмите View после успешной сборки, чтобы открыть развёрнутое приложение:

View



Защита от ботов (reCAPTCHA)


Первый шаг для подключения проверки reCAPTCHA в нашем приложении — создание новой reCAPTCH'и в администраторской панели Google. Там создаём новый сайт (Add new site / Create) и устанавливаем следующие настройки:

Настройки reCAPTCHA


В разделе Domains стоит указать помимо адреса, по которому будет жить приложение, следует указать localhost, чтобы при отладке избежать неприятностей в виде невозможности авторизоваться в своём же приложении.

Бэкенд

Сохраним site key и secret key

site key / secret key


… чтобы потом присвоить их переменным окружения, а названия переменных, в свою очередь, присвоить новым свойствам application.properties:

google.recaptcha.key.site=${GOOGLE_RECAPTCHA_KEY_SITE}
google.recaptcha.key.secret=${GOOGLE_RECAPTCHA_KEY_SECRET}

Добавим новую зависимость в pom.xml для верификации на стороне Google reCAPTCHA-токенов, которые будет присылать нам клиент:

<dependency>
     <groupId>com.mashape.unirest</groupId>
     <artifactId>unirest-java</artifactId>
     <version>1.4.9</version>
</dependency>

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

LoginUser.kt
import com.fasterxml.jackson.annotation.JsonProperty
import java.io.Serializable

class LoginUser : Serializable {

   @JsonProperty("username")
   var username: String? = null

   @JsonProperty("password")
   var password: String? = null

   @JsonProperty("recapctha_token")
   var recaptchaToken: String? = null

   constructor() {}

   constructor(username: String, password: String, recaptchaToken: String) {
       this.username = username
       this.password = password
       this.recaptchaToken = recaptchaToken
   }

   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

   @JsonProperty("recapctha_token")
   var recaptchaToken: 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
       this.recaptchaToken = recaptchaToken
   }

   companion object {

       private const val serialVersionUID = -1764970284520387975L
   }
}


Добавим небольшой сервис, который будет транслировать reCAPTCHA-токен специальному сервису Google и сообщать в ответ, прошёл ли токен верификацию:

ReCaptchaService.kt
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
import org.springframework.web.client.RestOperations
import org.springframework.beans.factory.annotation.Autowired
import com.mashape.unirest.http.HttpResponse
import com.mashape.unirest.http.JsonNode
import com.mashape.unirest.http.Unirest


@Service("captchaService")
class ReCaptchaService {

   val BASE_VERIFY_URL: String = "https://www.google.com/recaptcha/api/siteverify"

   @Autowired
   private val restTemplate: RestOperations? = null

   @Value("\${google.recaptcha.key.site}")
   lateinit var keySite: String

   @Value("\${google.recaptcha.key.secret}")
   lateinit var keySecret: String

   fun validateCaptcha(token: String): Boolean {

       val url: String = String.format(BASE_VERIFY_URL + "?secret=%s&response=%s", keySecret, token)

       val jsonResponse: HttpResponse<JsonNode> = Unirest.get(url)
               .header("accept", "application/json").queryString("apiKey", "123")
               .asJson()

       return (jsonResponse.getStatus() == 200)
   }

}


Этот сервис необходимо задействовать в контроллере регистрации и авторизации пользователей:

AuthController.kt
import com.kotlinspringvue.backend.service.ReCaptchaService

… 

@Autowired
lateinit var captchaService: ReCaptchaService

… 

if (!captchaService.validateCaptcha(loginRequest.recaptchaToken!!)) {
     return ResponseEntity(ResponseMessage("Validation failed (ReCaptcha v2)"),
     HttpStatus.BAD_REQUEST)
}
else [if]...

… 

if (!captchaService.validateCaptcha(newUser.recaptchaToken!!)) {
     return ResponseEntity(ResponseMessage("Validation failed (ReCaptcha v2)"),
     HttpStatus.BAD_REQUEST)
} else...


Фронтенд

Первым шагом установим и сохраним пакет reCAPTHA:

$ npm install --save vue-recaptcha

Затем подключим в скрипт в index.html:

<script src="https://www.google.com/recaptcha/api.js onload=vueRecaptchaApiLoaded&render=explicit" async defer></script>

В любое свободное место на странице добавим саму капчу:

<vue-recaptcha
     ref="recaptcha"
     size="invisible"
     :sitekey="sitekey"
     @verify="onCapthcaVerified"
     @expired="onCaptchaExpired"
/>

А кнопка целевого действия (авторзации или регистрации) теперь будет сначала вызывать метод валидации:

<b-button v-on:click="validateCaptcha" variant="primary">Login</b-button>

Добавим зависимость в компоненты:

import VueRecaptcha from 'vue-recaptcha'

Отредактируем export default:


components: { VueRecaptcha },
…
data() {
…
siteKey: <i>наш ключ сайта</i>
…
}

И добавим новые методы:

  • validateCaptcha() — который вызывается кликом на кнопку
  • onCapthcaVerified(recaptchaToken) и onCaptchaExpired() — которые вызывает сама капча

Новые методы

validateCaptcha() {
     this.$refs.recaptcha.execute()
},

onCapthcaVerified(recaptchaToken) {

     AXIOS.post(`/auth/signin`, {'username': this.$data.username, 'password': this.$data.password, 'recapctha_token': recaptchaToken})
     .then(response => {
          this.$store.dispatch('login', {'token': response.data.accessToken, 'roles': response.data.authorities, 'username': response.data.username});
          this.$router.push('/home')
     }, error => {
          this.showAlert(error.response.data.message);
  })
     .catch(e => {
     console.log(e);
            this.showAlert('Server error. Please, report this error website owners');
  })
},
onCaptchaExpired() {
   this.$refs.recaptcha.reset()
}


Результат



Отправка электронной почты


Рассмотрим возможность отправки писем нашим приложением через публичный почтовый сервер, например Google или Mail.ru.

Первым шагом, соотвественно, станет создание аккаунта на выбранном почтовом сервере, если его ещё нет.

Вторым шагом нам необходимо добавить следующие зависимости в pom.xml:

Зависимости
<dependency>
     <groupId>org.springframework.boot</groupId>
     <artifactId>spring-boot-starter-mail</artifactId>
</dependency>
<dependency>
     <groupId>org.springframework.boot</groupId>
     <artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
     <groupId>commons-io</groupId>
     <artifactId>commons-io</artifactId>
     <version>2.4</version>
</dependency>


Также необходимо добавить новые свойства в application.properties:

SMTP-свойства
spring.mail.host=${SMTP_MAIL_HOST}
spring.mail.port=${SMTP_MAIL_PORT}
spring.mail.username=${SMTP_MAIL_USERNAME}
spring.mail.password=${SMTP_MAIL_PASSWORD}
spring.mail.properties.mail.smtp.auth=true
spring.mail.properties.mail.smtp.starttls.enable=true
spring.mail.properties.mail.smtp.ssl.enable=true
spring.mail.properties.mail.smtp.connectiontimeout=5000
spring.mail.properties.mail.smtp.timeout=5000
spring.mail.properties.mail.smtp.writetimeout=5000


Настройки SMTP можно уточнить тут: Google и Mail.ru

Создадим интерфейс, где объявим несколько методов:

  • Для отправки обычного текстового письма
  • Для отправки HTML-письма
  • Для отправки письма с использованием шаблона

EmailService.kt
package com.kotlinspringvue.backend.email

import org.springframework.mail.SimpleMailMessage

internal interface EmailService {
    fun sendSimpleMessage(to: String,
                          subject: String,
                          text: String)

    fun sendSimpleMessageUsingTemplate(to: String,
                                       subject: String,
                                       template: String,
                                       params:MutableMap<String, Any>)

    fun sendHtmlMessage(to: String,
                        subject: String,
                        htmlMsg: String)
}


Теперь создадим реализацию этого интерфейса — сервис отправки электронных писем:

EmailServiceImpl.kt
package com.kotlinspringvue.backend.email

import org.springframework.beans.factory.annotation.Autowired
import org.springframework.beans.factory.annotation.Value
import org.springframework.core.io.FileSystemResource
import org.springframework.mail.MailException
import org.springframework.mail.SimpleMailMessage
import org.springframework.mail.javamail.JavaMailSender
import org.springframework.mail.javamail.MimeMessageHelper
import org.springframework.stereotype.Component
import org.thymeleaf.spring5.SpringTemplateEngine
import org.thymeleaf.context.Context
import java.io.File
import javax.mail.MessagingException
import javax.mail.internet.MimeMessage
import org.apache.commons.io.IOUtils
import org.springframework.core.env.Environment

@Component
class EmailServiceImpl : EmailService {

    @Value("\${spring.mail.username}")
    lateinit var sender: String

    @Autowired
    lateinit var environment: Environment

    @Autowired
    var emailSender: JavaMailSender? = null

    @Autowired
    lateinit var templateEngine: SpringTemplateEngine

    override fun sendSimpleMessage(to: String, subject: String, text: String) {
        try {
            val message = SimpleMailMessage()
            message.setTo(to)
            message.setFrom(sender)
            message.setSubject(subject)
            message.setText(text)

            emailSender!!.send(message)
        } catch (exception: MailException) {
            exception.printStackTrace()
        }

    }

    override fun sendSimpleMessageUsingTemplate(to: String,
                                                subject: String,
                                                template: String,
                                                params:MutableMap<String, Any>) {
        val message = emailSender!!.createMimeMessage()
        val helper = MimeMessageHelper(message, true, "utf-8")
        var context: Context = Context()
        context.setVariables(params)
        val html: String = templateEngine.process(template, context)

        helper.setTo(to)
        helper.setFrom(sender)
        helper.setText(html, true)
        helper.setSubject(subject)

        emailSender!!.send(message)
    }

    override fun sendHtmlMessage(to: String, subject: String, htmlMsg: String) {
        try {
            val message = emailSender!!.createMimeMessage()
            message.setContent(htmlMsg, "text/html")

            val helper = MimeMessageHelper(message, false, "utf-8")

            helper.setTo(to)
            helper.setFrom(sender)
            helper.setSubject(subject)

            emailSender!!.send(message)
        } catch (exception: MailException) {
            exception.printStackTrace()
        }
    }
}


  • Мы используем автоматически конфигурируемый JavaMailSender от Spring для отправки писем
  • Отправка обычных писем предельно проста — необходимо только добавить текст в тело письма и отправить его
  • HTML-письма определяются как сообщения Mime Type, а их содержание — как text/html
  • Для обработки HTML-шаблона сообщения мы используем Spring Template Engine

Давайте же создадим простенький шаблон для письма с использованием фреймворка Thymeleaf, поместив его в src/main/resources/templates/:

emailTemplate.html
<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<head>
   <meta charset="UTF-8">
   <title>Hello</title>
</head>
<body style="font-family: Arial, Helvetica, sans-serif;">
<h3>Hello!</h3>
<div style="margin-top: 20px; margin-bottom: 30px; margin-left: 20px;">
   <p>Hello, dear: <b><span th:text="${addresseeName}"></span></b></p>
</div>
<div>
   <img th:src="${signatureImage}" width="200px;"/>
</div>
</body>
</html>


Изменяющиеся элементы шаблона (в нашем случае — имя адресата и путь картинки для подписи) объявлены с помощью плейсхолдеров.

Теперь создадим или обновим контроллер, который будет посылать письма:

BackendController.kt
import com.kotlinspringvue.backend.email.EmailServiceImpl
import com.kotlinspringvue.backend.web.response.ResponseMessage
import org.springframework.beans.factory.annotation.Value
import org.springframework.http.ResponseEntity
import org.springframework.http.HttpStatus

… 

@Autowired
lateinit var emailService: EmailService

@Value("\${spring.mail.username}")
lateinit var addressee: String

… 

@GetMapping("/sendSimpleEmail")
@PreAuthorize("hasRole('USER')")
fun sendSimpleEmail(): ResponseEntity<*> {
     try {
          emailService.sendSimpleMessage(addressee, "Simple Email", "Hello! This is simple email")
     } catch (e: Exception) {
          return ResponseEntity(ResponseMessage("Error while sending message"),
          HttpStatus.BAD_REQUEST)
     }
     return ResponseEntity(ResponseMessage("Email has been sent"), HttpStatus.OK)
}

@GetMapping("/sendTemplateEmail")
@PreAuthorize("hasRole('USER')")
fun sendTemplateEmail(): ResponseEntity<*> {
     try {
          var params:MutableMap<String, Any> = mutableMapOf()
          params["addresseeName"] = addressee
          params["signatureImage"] = "https://coderlook.com/wp-content/uploads/2019/07/spring-by-pivotal.png"
          emailService.sendSimpleMessageUsingTemplate(addressee, "Template Email", "emailTemplate", params)
     } catch (e: Exception) {
          return ResponseEntity(ResponseMessage("Error while sending message"),
          HttpStatus.BAD_REQUEST)
     }
     return ResponseEntity(ResponseMessage("Email has been sent"), HttpStatus.OK)
}

@GetMapping("/sendHtmlEmail")
@PreAuthorize("hasRole('USER')")
fun sendHtmlEmail(): ResponseEntity<*> {
     try {
          emailService.sendHtmlMessage(addressee, "HTML Email", "<h1>Hello!</h1><p>This is HTML email</p>")
     } catch (e: Exception) {
          return ResponseEntity(ResponseMessage("Error while sending message"),
          HttpStatus.BAD_REQUEST)
     }
     return ResponseEntity(ResponseMessage("Email has been sent"), HttpStatus.OK)
}


Примечание: Чтобы убедиться, что всё работает, для начала будем посылать письма самим себе.

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

Email.vue

<template>
   <div id="email">
       <b-button  v-on:click="sendSimpleMessage" variant="primary">Simple Email</b-button><br/>
       <b-button  v-on:click="sendEmailUsingTemplate" variant="primary">Template Email</b-button><br/>
       <b-button v-on:click="sendHTMLEmail" variant="primary">HTML Email</b-button><br/>
       </div>
</template>

<script>
import {AXIOS} from './http-common'
export default {
   name: 'EmailPage',
   data() {
       return {
           counter: 0,
           username: '',
           header: {'Authorization': 'Bearer ' + this.$store.getters.getToken}
       }
   },
   methods: {
       sendSimpleMessage() {
           AXIOS.get('/sendSimpleEmail', { headers: this.$data.header })
           .then(response => {
               console.log(response);
               alert("OK");
           })
           .catch(error => {
               console.log('ERROR: ' + error.response.data);
           })
       },
       sendEmailUsingTemplate() {
           AXIOS.get('/sendTemplateEmail', { headers: this.$data.header })
           .then(response => {
               console.log(response);
               alert("OK")
           })
           .catch(error => {
               console.log('ERROR: ' + error.response.data);
           })
       },
       sendHTMLEmail() {
           AXIOS.get('/sendHtmlEmail', { headers: this.$data.header })
           .then(response => {
               console.log(response);
               alert("OK")
           })
           .catch(error => {
               console.log('ERROR: ' + error.response.data);
           })
       }
   }
}
</script>

<style>
#email {
   margin-left: 38%;
   margin-top: 50px;
}
button {
   width: 150px;
}
</style>


Примечание: не забудьте обновить router.js и добавить ссылку в панель навигации App.vue, если создаёте новый компонент.


Миграция на Gradle


Сразу уточню: считать ли этот пункт усовершенствованием, пусть каждый решает сам для своего проекта. Мы же просто рассмотрим, как это сделать.

Вообще, можно воспользоваться инструкцией Moving from Maven to Gradle in under 5 minutes, но вряд ли результат оправдает ожидания. Я бы порекомендовал всё-таки осуществлять миграцию вручную, это займёт не намного больше времени.

Первое, что нам нужно сделать — установить Gradle.

Затем нам необходимо выполнить следующий порядок действий для обоих подпроектов — backend и fronted:

#1 Удалить файлы Maven — pom.xml, .mvn.

#2 В каталоге подпроект выполнить gradle init и ответить на вопросы:

  • Select type of project to generate: basic
  • Select implementation language: Kotlin
  • Select build script DSL: Kotlin (раз уж мы пишем проект на Kotlin)

#3 Удалить settings.gradle.kts — этот файл нужен только для корневого проекта.

#4 Выполнить gradle wrapper.

Теперь обратимся к нашему корневому проекту. Для него нужно выполнить шаги 1, 2 и 4 описанные выше для под проектов — всё то же самое, кроме удаления settings.gradle.kts.

Конфигурация сборки для проекта backend будет выглядеть следующим образом:

build.gradle.kts
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

plugins {
    id("org.springframework.boot") version "2.1.3.RELEASE"
    id("io.spring.dependency-management") version "1.0.8.RELEASE"
    kotlin("jvm") version "1.3.50"
    kotlin("plugin.spring") version "1.3.50"
    id("org.jetbrains.kotlin.plugin.jpa") version "1.3.50"
}

group = "com.kotlin-spring-vue"
version = "0.0.1-SNAPSHOT"
java.sourceCompatibility = JavaVersion.VERSION_1_8

repositories {
    mavenCentral()
    maven {
        url = uri("https://plugins.gradle.org/m2/")
    }
}

dependencies {
    runtimeOnly(project(":frontend"))
    implementation("org.springframework.boot:spring-boot-starter-actuator:2.1.3.RELEASE")
    implementation("org.springframework.boot:spring-boot-starter-web:2.1.3.RELEASE")
    implementation("org.springframework.boot:spring-boot-starter-data-jpa:2.1.3.RELEASE")
    implementation("org.springframework.boot:spring-boot-starter-mail:2.1.3.RELEASE")
    implementation("org.springframework.boot:spring-boot-starter-security:2.1.3.RELEASE")
    implementation("org.postgresql:postgresql:42.2.5")
    implementation("org.springframework.boot:spring-boot-starter-thymeleaf:2.1.3.RELEASE")
    implementation("commons-io:commons-io:2.4")
    implementation("io.jsonwebtoken:jjwt:0.9.0")
    implementation("io.jsonwebtoken:jjwt-api:0.10.6")
    implementation("com.mashape.unirest:unirest-java:1.4.9")
    implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.9.8")
    runtimeOnly("org.springframework.boot:spring-boot-devtools:2.1.3.RELEASE")
    implementation("org.jetbrains.kotlin:kotlin-reflect")
    implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
    implementation("org.jetbrains.kotlin:kotlin-noarg:1.3.50")
    testImplementation("org.springframework.boot:spring-boot-starter-test") {
        exclude(group = "org.junit.vintage", module = "junit-vintage-engine")
    }
}

tasks.withType<KotlinCompile> {
    kotlinOptions {
        freeCompilerArgs = listOf("-Xjsr305=strict")
        jvmTarget = "1.8"
    }
}


  • Следует указать все необходимые плагины Kotlin и Spring
  • Не следует забывать про плагин org.jetbrains.kotlin.plugin.jpa — он необходим для подключения к базе данных
  • В зависимостях необходимо указать runtimeOnly(project(":frontend")) — нам нужно собирать проект frontend в первую очередь

Конфигурация сборки для проекта frontend:

build.gradle.kts
plugins {
    id("org.siouan.frontend") version "1.2.1"
    id("java")
}

group = "com.kotlin-spring-vue"
version = "0.0.1-SNAPSHOT"

java {
    targetCompatibility = JavaVersion.VERSION_1_8
}

buildscript {
    repositories {
        mavenCentral()
        maven {
            url = uri("https://plugins.gradle.org/m2/")
        }
    }
}

frontend {
    nodeVersion.set("10.16.0")
    cleanScript.set("run clean")
    installScript.set("install")
    assembleScript.set("run build")
}

tasks.named("jar", Jar::class) {
    dependsOn("assembleFrontend")
    from("$buildDir/dist")
    into("static")
}


  • В моём примере для сборки проекта используется плагин org.siouan.frontend
  • В разделе frontend {...} следует указать версию Node.js, а также команды, вызывающие скрипты очистки, установки и сборки, прописанные в package.json
  • Теперь мы упаковываем наш подпроект frontend в JAR-файл и используем его как зависимость (runtimeOnly(project(":frontend")) в backend'е), так что нам необходимо описать задание (task), которое копирует файлы из сборочной директории в /public и создаёт JAR-файл

Примечание:

  • Отредактируйте vue.config.js, изменив директорию сборки на build/dist.
  • Укажите в файле package.json build scriptvue-cli-service build или убедитесь, что он указан

Файл settings.gradle.kts в корневом проекте должен содержать следующий код:…

rootProject.name = "demo"

include(":frontend", ":backend")

… — название проекта и подпроектов.

И теперь мы можем собрать проект, выполнив команду: ./gradlew build

Примечание: если для плейсхолдеров, указанных в application.properties (например, ${SPRING_DATASOURCE_URL}) нет соответствующих переменных среды, сборка завершится неудачно. Чтобы этого избежать, следует использовать /gradlew build -x

Проверить структуру проектов можно с помощью команды gradle -q projects, результат должен выглядеть подобным образом:

Root project 'demo'
+--- Project ':backend'
\--- Project ':frontend'

И, наконец, чтобы запустить приложение, необходимо выполнить ./gradlew bootRun.

.gitignore


В файл .gitignore следует добавить следующие файлы и папки:

  • backend/build/
  • frontend/build/
  • build
  • .gradle

Важно: не следует добавлять файлы gradlew в .gitignore — ничего опасного в них нет, однако они нужны для успешной сборки на удалённом сервере.

Деплой на Heroku


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

#1 Procfile

Нам необходимо задать Heroku новые инструкции для запуска приложения:

web: java -Dserver.port=$PORT -jar backend/build/libs/backend-0.0.1-SNAPSHOT.jar

#2 Переменные среды

Heroku способна переделать тип приложения (например, приложение Spring Boot) и выполнять соотвествующие инструкции для сборки. Но наше приложение (корневой проект) не выглядит для Heroku как приложение Spring Boot. Если мы оставим всё, как есть, Heroku попросит нас определить таску stage. Честно говоря, не знаю, где заканчивается этот путь, потому что я по нему не шёл. Проще определить переменную GRADLE_TASK со значением build:



#3 reCAPTCHA

При размещении приложения в новом домене не забудьте обновить капчу, переменные среды GOOGLE_RECAPTCHA_KEY_SITE и GOOGLE_RECAPTCHA_KEY_SECRET, а также обновить Site Key в подпроекте фронтенда.


Хранение токена JWT в Cookies


Прежде всего, настоятельно рекомендую ознакомиться со статьей Please Stop Using Local Storage, особенно с разделом Why Local Storage is Insecure and You Shouldn’t Use it to Store Sensitive Data.

Давайте рассмотрим, как можно хранить токен JWT в более безопасном месте — куках в флагом httpOnly, где он будет недоступен для чтения/изменения с помощью JavaScript'а.

#1 Удаление всей логики, относящейся к JWT из фронтенда:
Поскольку с новым способом хранения токен всё равно не доступен для JavaScript'а, можно смело удалить все упоминания о нём из нашего подпроекта.

А вот роль пользователя без привязки к каким-либо другим данных — не столь не столь важная информация, её можно по-прежнему хранить в Local Storage и определять, авторизован пользователь или нет, в зависимости от того, определена ли эта роль.

store/index.js

import Vue from 'vue';
import Vuex from 'vuex';

Vue.use(Vuex);

const state = {
  role: localStorage.getItem('user-role') || '',
  username: localStorage.getItem('user-name') || '',
  authorities: localStorage.getItem('authorities') || '',
};

const getters = {
  isAuthenticated: state => {
    if (state.role != null && state.role != '') {
      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;
  }
};

const mutations = {
  auth_login: (state, user) => {
    localStorage.setItem('user-name', user.username);
    localStorage.setItem('user-authorities', user.roles);
    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-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/index.js: если авторизация и деавторизация не будут работать корректно, в консоль будут назойливо сыпаться сообщения об ошибках.

#2 Возвращение JWT как cookie в контроллере авторизации (не в теле ответа):

AuthController.kt
@Value("\${ksvg.app.authCookieName}")
    lateinit var authCookieName: String

@Value("\${ksvg.app.isCookieSecure}")
    var isCookieSecure: Boolean = true

@PostMapping("/signin")
    fun authenticateUser(@Valid @RequestBody loginRequest: LoginUser, response: HttpServletResponse): ResponseEntity<*> {

        val userCandidate: Optional <User> = userRepository.findByUsername(loginRequest.username!!)

        if (!captchaService.validateCaptcha(loginRequest.recaptchaToken!!)) {
            return ResponseEntity(ResponseMessage("Validation failed (ReCaptcha v2)"),
                    HttpStatus.BAD_REQUEST)
        }
        else 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 cookie: Cookie = Cookie(authCookieName, jwt)
            cookie.maxAge = jwtProvider.jwtExpiration!!
            cookie.secure = isCookieSecure
            cookie.isHttpOnly = true
            cookie.path = "/"
            response.addCookie(cookie)

            val authorities: List<GrantedAuthority> = user.roles!!.stream().map({ role -> SimpleGrantedAuthority(role.name)}).collect(Collectors.toList<GrantedAuthority>())
            return ResponseEntity.ok(SuccessfulSigninResponse(user.username, authorities))
        } else {
            return ResponseEntity(ResponseMessage("User not found!"),
                    HttpStatus.BAD_REQUEST)
        }
    }


Важно: обратите внимание, что я поместил параметры authCookieName и isCookieSecure в application.properties — отправка куков с флагом secure возможна только по https, что делает крайне затруднительным отладку на localhost. НО в продакшене, конечно, лучше использовать куки с этим флагом.

Также ответов контроллера теперь целесообразно использовать сущность без специального поля для JWT.

#3 Обновление JwtAuthTokenFilter:
Раньше мы брали токен из заголовка запроса, теперь мы берём его из куки:

JwtAuthTokenFilter.kt
@Value("\${ksvg.app.authCookieName}")
          lateinit var authCookieName: String

...

private fun getJwt(request: HttpServletRequest): String? {
     for (cookie in request.cookies) {
          if (cookie.name == authCookieName) {
               return cookie.value
          }
     }
     return null
}


#4 Включение CORS
Если в предыдущей моей статье ещё можно было незаметно пропустить этот вопрос, то сейчас было бы странно защищать JWT-токен, так и не включив CORS на стороне бэкенда.

Исправить это можно отредактировав WebSecurityConfig.kt:

WebSecurityConfig.kt
@Bean
fun corsConfigurationSource(): CorsConfigurationSource {
     val configuration = CorsConfiguration()
     configuration.allowedOrigins = Arrays.asList("http://localhost:8080", "http://localhost:8081", "https://kotlin-spring-vue-gradle-demo.herokuapp.com")
     configuration.allowedHeaders = Arrays.asList("*")
     configuration.allowedMethods = Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS")
     configuration.allowCredentials = true
     configuration.maxAge = 3600
     val source = UrlBasedCorsConfigurationSource()
     source.registerCorsConfiguration("/**", configuration)
     return source
}

@Throws(Exception::class)
override fun configure(http: HttpSecurity) {
     http
          .cors().and()
          .csrf().disable().authorizeRequests()
          .antMatchers("/**").permitAll()
          .anyRequest().authenticated()
          .and()
          .exceptionHandling().authenticationEntryPoint(unauthorizedHandler).and()
          .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)

     http.addFilterBefore(authenticationJwtTokenFilter(), UsernamePasswordAuthenticationFilter::class.java)
     http.headers().cacheControl().disable()
}


И теперь можно удалить все аннотации @CrossOrigin из контроллеров.

Важно: параметр AllowCredentials необходим для отправки запросов со стороны фронтенда. Подробнее об этом можно почитать здесь.

#5 Актуализация заголовков на стороне фронтенда:

http-commons.js

export const AXIOS = axios.create({
     baseURL: `/api`,
     headers: {
          'Access-Control-Allow-Origin': ['http://localhost:8080', 'http://localhost:8081', 'https://kotlin-spring-vue-gradle-demo.herokuapp.com'],
          'Access-Control-Allow-Methods': 'GET,POST,DELETE,PUT,OPTIONS',
          'Access-Control-Allow-Headers': '*',
          'Access-Control-Allow-Credentials': true
     }
})


Проверка


Давайте попробуем авторизоваться в приложении, зайдя с хоста, не входящего в список разрешённых в WebSecurityConfig.kt. Для этого запустим бэкенд на порту 8080, а фронтенд, например, на 8082 и попробуем авторизоваться:

Результат


Запрос авторизации отклонён политикой CORS.

Теперь давайте посмотрим, как вообще работаю куки с флагом httpOnly. Для этого зайдём, например, на сайт https://kotlinlang.org и выполним в консоли браузера:

document.cookie 

Результат


В консоли появятся не-httpOnly куки, относящиеся к этому сайту, которые, как мы видим, доступны через JavaScript.

Теперь зайдём в наше приложение, авторизуемся (чтобы браузер сохранил куку с JWT) и повторим то же самое:

Результат


Примечание: такой способ хранения JWT-токена является более надёжным, чем с использованием Local Storage, но стоит понимать, что он не является панацеей.


Подтверждение регистрации по электронной почте


Краткий алгоритм выполнения этой задачи таков:

  1. Для всех новых пользователей атрибуту isEnabled в базе данных присваивается значение false
  2. Из произвольных символов генерируется строковый токен, который будет служить ключом для подтверждения регистрации
  3. Токен отправляется пользователю на почте как часть ссылки
  4. Атрибут isEnabled принимает значение true, если пользователь переходит по ссылке в течение установленного периода времени

Теперь рассмотрим этот процесс более детально.

Нам понадобится таблица для хранения токен для подтверждения регистрации:

CREATE TABLE public.verification_token
(
     id serial NOT NULL,
     token character varying,
     expiry_date timestamp without time zone,
     user_id integer,
     PRIMARY KEY (id)
);

ALTER TABLE public.verification_token
ADD CONSTRAINT verification_token_users_fk FOREIGN KEY (user_id)
REFERENCES public.users (id) MATCH SIMPLE
ON UPDATE CASCADE
ON DELETE CASCADE;

И, соотвественно, новая сущность для объектно-реляционного отображения…:

VerificationToken.kt
package com.kotlinspringvue.backend.jpa

import java.sql.*
import javax.persistence.*
import java.util.Calendar

@Entity
@Table(name = "verification_token")
data class VerificationToken(

        @Id
        @GeneratedValue(strategy = GenerationType.AUTO)
        val id: Long? = 0,

        @Column(name = "token")
        var token: String? = null,

        @Column(name = "expiry_date")
        val expiryDate: Date,

        @OneToOne(targetEntity = User::class, fetch = FetchType.EAGER, cascade = [CascadeType.PERSIST])
        @JoinColumn(nullable = false, name = "user_id")
        val user: User
) {
    constructor(token: String?, user: User) : this(0, token, calculateExpiryDate(1440), user)
}

private fun calculateExpiryDate(expiryTimeInMinutes: Int): Date {
    val cal = Calendar.getInstance()
    cal.time = Timestamp(cal.time.time)
    cal.add(Calendar.MINUTE, expiryTimeInMinutes)
    return Date(cal.time.time)
}


… и репозиторий:

VerificationTokenRepository.kt
package com.kotlinspringvue.backend.repository

import com.kotlinspringvue.backend.jpa.VerificationToken
import org.springframework.data.jpa.repository.JpaRepository
import java.util.*

interface VerificationTokenRepository : JpaRepository<VerificationToken, Long> {
    fun findByToken(token: String): Optional<VerificationToken>
}


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

UserDetailsServiceImpl.kt
override fun createVerificationTokenForUser(token: String, user: User) {
     tokenRepository.save(VerificationToken(token, user))
}

override fun validateVerificationToken(token: String): String {
     val verificationToken: Optional<VerificationToken> = tokenRepository.findByToken(token)

     if (verificationToken.isPresent) {
          val user: User = verificationToken.get().user
          val cal: Calendar = Calendar.getInstance()
          if ((verificationToken.get().expiryDate.time - cal.time.time) <= 0) {
               tokenRepository.delete(verificationToken.get())
               return TOKEN_EXPIRED
          }

          user.enabled = true
          tokenRepository.delete(verificationToken.get())
          userRepository.save(user)
          return TOKEN_VALID
     } else {
          return TOKEN_INVALID
     }
}


Теперь добавим метод для отправки письма с ссылкой для подтверждения в EmailServiceImpl:

EmailServiceImpl.kt
    @Value("\${host.url}")
    lateinit var hostUrl: String

    @Autowired
    lateinit var userDetailsService: UserDetailsServiceImpl

...

    override fun sendRegistrationConfirmationEmail(user: User) {
        val token = UUID.randomUUID().toString()
        userDetailsService.createVerificationTokenForUser(token, user)
        val link = "$hostUrl/?token=$token&confirmRegistration=true"
        val msg = "<p>Please, follow the link to complete your registration:</p><p><a href=\"$link\">$link</a></p>"
        user.email?.let{sendHtmlMessage(user.email!!, "KSVG APP: Registration Confirmation", msg)}
    }


Примечание:

  • Я бы рекомендовал хранить URL хоста в application.properties
  • В нашей ссылке мы передаём два GET-параметра (token и confirmRegistration) на адрес, где развёрнуто приложение. Чуть позже я объясню, для чего.

Модифицируем контроллер регистрации следующим образом:

  • Всем новым пользователям будем выставлять значение false для поля isEnabled
  • После создания нового аккаунта будем отправлять электронное письмо для подтверждения регистрации
  • Создадим отдельный контроллер для валидация токена
  • Важно: при авторизации будем проверять, подтверждена ли учётная запись:

AuthController.kt
package com.kotlinspringvue.backend.controller

import com.kotlinspringvue.backend.email.EmailService
import javax.validation.Valid
import java.util.*
import java.util.stream.Collectors

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.crypto.password.PasswordEncoder
import org.springframework.security.core.GrantedAuthority
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.ui.Model

import com.kotlinspringvue.backend.model.LoginUser
import com.kotlinspringvue.backend.model.NewUser
import com.kotlinspringvue.backend.web.response.SuccessfulSigninResponse
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
import com.kotlinspringvue.backend.service.ReCaptchaService
import com.kotlinspringvue.backend.service.UserDetailsService
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.ApplicationEventPublisher
import org.springframework.web.bind.annotation.*
import org.springframework.web.context.request.WebRequest
import java.io.UnsupportedEncodingException
import javax.servlet.http.Cookie
import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse
import com.kotlinspringvue.backend.service.UserDetailsServiceImpl.Companion.TOKEN_VALID
import com.kotlinspringvue.backend.service.UserDetailsServiceImpl.Companion.TOKEN_INVALID
import com.kotlinspringvue.backend.service.UserDetailsServiceImpl.Companion.TOKEN_EXPIRED

@RestController
@RequestMapping("/api/auth")
class AuthController() {

    @Value("\${ksvg.app.authCookieName}")
    lateinit var authCookieName: String

    @Value("\${ksvg.app.isCookieSecure}")
    var isCookieSecure: Boolean = true

    @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

    @Autowired
    lateinit var captchaService: ReCaptchaService

    @Autowired
    lateinit var userService: UserDetailsService

    @Autowired
    lateinit var emailService: EmailService

    @PostMapping("/signin")
    fun authenticateUser(@Valid @RequestBody loginRequest: LoginUser, response: HttpServletResponse): ResponseEntity<*> {

        val userCandidate: Optional <User> = userRepository.findByUsername(loginRequest.username!!)

        if (!captchaService.validateCaptcha(loginRequest.recaptchaToken!!)) {
            return ResponseEntity(ResponseMessage("Validation failed (ReCaptcha v2)"),
                    HttpStatus.BAD_REQUEST)
        }
        else if (userCandidate.isPresent) {
            val user: User = userCandidate.get()

            if (!user.enabled) {
                return ResponseEntity(ResponseMessage("Account is not verified yet! Please, follow the link in the confirmation email."),
                        HttpStatus.UNAUTHORIZED)
            }

            val authentication = authenticationManager.authenticate(
                    UsernamePasswordAuthenticationToken(loginRequest.username, loginRequest.password))
            SecurityContextHolder.getContext().setAuthentication(authentication)
            val jwt: String = jwtProvider.generateJwtToken(user.username!!)

            val cookie: Cookie = Cookie(authCookieName, jwt)
            cookie.maxAge = jwtProvider.jwtExpiration!!
            cookie.secure = isCookieSecure
            cookie.isHttpOnly = true
            cookie.path = "/"
            response.addCookie(cookie)

            val authorities: List<GrantedAuthority> = user.roles!!.stream().map({ role -> SimpleGrantedAuthority(role.name)}).collect(Collectors.toList<GrantedAuthority>())
            return ResponseEntity.ok(SuccessfulSigninResponse(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 (!captchaService.validateCaptcha(newUser.recaptchaToken!!)) {
            return ResponseEntity(ResponseMessage("Validation failed (ReCaptcha v2)"),
                    HttpStatus.BAD_REQUEST)
        } else 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)
            }

            try {
                // Creating user's account
                val user = User(
                        0,
                        newUser.username!!,
                        newUser.firstName!!,
                        newUser.lastName!!,
                        newUser.email!!,
                        encoder.encode(newUser.password),
                        false
                )
                user.roles = Arrays.asList(roleRepository.findByName("ROLE_USER"))
                val registeredUser = userRepository.save(user)

                emailService.sendRegistrationConfirmationEmail(registeredUser)
            } catch (e: Exception) {
                return ResponseEntity(ResponseMessage("Server error. Please, contact site owner"),
                        HttpStatus.SERVICE_UNAVAILABLE)
            }

            return ResponseEntity(ResponseMessage("Please, follow the link in the confirmation email to complete the registration."), HttpStatus.OK)
        } else {
            return ResponseEntity(ResponseMessage("User already exists!"),
                    HttpStatus.BAD_REQUEST)
        }
    }

    @PostMapping("/registrationConfirm")
    @CrossOrigin(origins = ["*"])
    @Throws(UnsupportedEncodingException::class)
    fun confirmRegistration(request: HttpServletRequest, model: Model, @RequestParam("token") token: String): ResponseEntity<*> {

        when(userService.validateVerificationToken(token)) {
            TOKEN_VALID -> return ResponseEntity.ok(ResponseMessage("Registration confirmed"))
            TOKEN_INVALID -> return ResponseEntity(ResponseMessage("Token is invalid!"), HttpStatus.BAD_REQUEST)
            TOKEN_EXPIRED -> return ResponseEntity(ResponseMessage("Token is invalid!"), HttpStatus.UNAUTHORIZED)
        }

        return ResponseEntity(ResponseMessage("Server error. Please, contact site owner"), HttpStatus.SERVICE_UNAVAILABLE)
    }

    @PostMapping("/logout")
    fun logout(response: HttpServletResponse): ResponseEntity<*> {
        val cookie: Cookie = Cookie(authCookieName, null)
        cookie.maxAge = 0
        cookie.secure = isCookieSecure
        cookie.isHttpOnly = true
        cookie.path = "/"
        response.addCookie(cookie)

        return ResponseEntity.ok(ResponseMessage("Successfully logged"))
    }

    private fun emailExists(email: String): Boolean {
        return userRepository.findByUsername(email).isPresent
    }

    private fun usernameExists(username: String): Boolean {
        return userRepository.findByUsername(username).isPresent
    }

}


Теперь поработаем на фронтендом:

#1 Создадим компонент RegistrationConfirmPage.vue

#2 Добавим новый путь в router.js с параметром :token:


{
        path: '/registration-confirm/:token',
        name: 'RegistrationConfirmPage',
        component: RegistrationConfirmPage
}

#3 Обновим SignUp.vue — после успешной отправки данных с форм будем сообщать им, что для завершения регистрации необходимо перейти по ссылке в письме.

#4 Важно: увы, мы не можем дать фиксированную ссылку на отдельный компонент, который выполнял бы валидацию токена и сообщал бы об успехе или неуспехе. Ссылки с прописанными через слэш путями всё равно приведут нас на исходную страницу приложения. Но мы можем сообщить нашему приложению о необходимости подтвердить регистрацию с помощью передаваемого GET-параметра confirmRegistration:


methods: {
    confirmRegistration() {
      if (this.$route.query.confirmRegistration === 'true' && this.$route.query.token != null) {
        this.$router.push({name: 'RegistrationConfirmPage', params: { token: this.$route.query.token}});
      }
    },

...

mounted() {
    this.confirmRegistration();
  }

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

RegistrationConfirmPage.vue

<template>
    <div id="registration-confirm">
        <div class="confirm-form">
            <b-card
              title="Confirmation"
              tag="article"
              style="max-width: 20rem;"
              class="mb-2"
            >
            <div v-if="isSuccess">
                <p class="success">Account is successfully verified!</p>
                <router-link to="/login">
                    <b-button variant="primary">Login</b-button>
                </router-link>
            </div>
            <div v-if="isError">
                <p class="fail">Verification failed:</p>
                <p>{{ errorMessage }}</p>
            </div>
            </b-card>
            </div>
    </div>
</template>

<script>
import {AXIOS} from './http-common'
export default {
    name: 'RegistrationConfirmPage',
    data() {
        return {
            isSuccess: false,
            isError: false,
            errorMessage: ''
        }
    },
    methods: {
        executeVerification() {
            AXIOS.post(`/auth/registrationConfirm`, null, {params: { 'token': this.$route.params.token}})
            .then(response => {
              this.isSuccess = true;
              console.log(response);
            }, error => {
                this.isError = true;
                this.errorMessage = error.response.data.message;
            })
            .catch(e => {
              console.log(e);
              this.errorMessage = 'Server error. Please, report this error website owners';
            })
        }
    },
    mounted() {
        this.executeVerification();
    }
}
</script>

<style scoped>
.confirm-form {
    margin-left: 38%;
    margin-top: 50px;
}
.success {
    color: green;
}
.fail {
    color: red;
}
</style>


Результат


Вместо заключения


В завершение этого материала хотелось бы сделать небольшое лирическое отступление и сказать, что сама концепция приложения, рассмотренного в этой и предыдущей статье, не была нова уже на момент начала написания. Задачу быстрого создания full stack приложений на Spring Boot с использованием современных JavaScript-фреймворков Angular/React/Vue.js изящно решает Hipster.

Однако, идеи, описанные в данной статье вполне можно реализовать даже используя JHipster, так что, надеюсь, читатели, дошедшие до этого места, найдут этот материал полезным хотя бы в качестве пищи для размышлений.


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