Как и следует из названия, данная статья является дополнением к написанной ранее Веб-приложение на Kotlin + Spring Boot + Vue.js, позволяющим усовершенствовать скелет будущего приложения и сделать удобнее работу с ним.
Прежде чем приступить к повествованию, позвольте поблагодарить всех, кто оставлял комментарии в предыдущей статье.
Содержание
- Настройка CI/CD (Heroku)
- Защита от ботов (reCAPTCHA)
- Отправка электронной почты
- Миграция на Gradle
- Хранение токена JWT в Cookies
- Подтверждение регистрации по электронной почте
- Полезные ссылки
Настройка CI/CD (Heroku)
Рассмотрим реализацию непрерывной интеграции и доставки на примере облачной PaaS-платформы Heroku.
Первое, что нам необходимо сделать — разместить код приложения в репозитории на GitHub. Для того, чтобы в репозитории не оказалось ничего лишнего, рекомендую следующее содержание файла .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:
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:
#4 Выберите план:
#5 Теперь вы можете увидеть подключённый ресурс:
#6 Посмотрите учётные данные, они понадобятся для настройки переменных окружения: Settings -> 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, соотвественно, должен иметь примерно такой вид:
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:
#12 Deployment method — GitHub (нажмите Connect to GitHub и следуйте инструкциям в новом окне).
#13 Активируйте автоматическое развёртывание — Enable Automatic Deploys:
#14 Manual Deploy — нажмите Deploy Branch для первого развёртывания. Прямо в браузере вы увидите вывод командной строки.
#15 Нажмите View после успешной сборки, чтобы открыть развёрнутое приложение:
Защита от ботов (reCAPTCHA)
Первый шаг для подключения проверки reCAPTCHA в нашем приложении — создание новой reCAPTCH’и в администраторской панели Google. Там создаём новый сайт (Add new site / Create) и устанавливаем следующие настройки:
В разделе Domains стоит указать помимо адреса, по которому будет жить приложение, следует указать localhost
, чтобы при отладке избежать неприятностей в виде невозможности авторизоваться в своём же приложении.
Бэкенд
Сохраним 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-токена:
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 } }
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 и сообщать в ответ, прошёл ли токен верификацию:
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", ~_~quot{quot~_~) .asJson() return (jsonResponse.getStatus() == 200) } }
Этот сервис необходимо задействовать в контроллере регистрации и авторизации пользователей:
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:
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-письма
- Для отправки письма с использованием шаблона
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) }
Теперь создадим реализацию этого интерфейса — сервис отправки электронных писем:
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/:
<!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>
Изменяющиеся элементы шаблона (в нашем случае — имя адресата и путь картинки для подписи) объявлены с помощью плейсхолдеров.
Теперь создадим или обновим контроллер, который будет посылать письма:
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) }
Примечание: Чтобы убедиться, что всё работает, для начала будем посылать письма самим себе.
Также, чтобы убедиться, что всё работает, можем создать скромный веб-интерфейс, который будет просто дёргать методы веб-сервиса:
<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 будет выглядеть следующим образом:
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:
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 script —vue-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 и определять, авторизован пользователь или нет, в зависимости от того, определена ли эта роль.
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 в контроллере авторизации (не в теле ответа):
@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
:
Раньше мы брали токен из заголовка запроса, теперь мы берём его из куки:
@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
:
@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 Актуализация заголовков на стороне фронтенда:
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, но стоит понимать, что он не является панацеей.
Подтверждение регистрации по электронной почте
Краткий алгоритм выполнения этой задачи таков:
- Для всех новых пользователей атрибуту
isEnabled
в базе данных присваивается значениеfalse
- Из произвольных символов генерируется строковый токен, который будет служить ключом для подтверждения регистрации
- Токен отправляется пользователю на почте как часть ссылки
- Атрибут
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;
И, соотвественно, новая сущность для объектно-реляционного отображения…:
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) }
… и репозиторий:
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
, добавив методы для создания и верификации токена:
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
:
@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
- После создания нового аккаунта будем отправлять электронное письмо для подтверждения регистрации
- Создадим отдельный контроллер для валидация токена
- Важно: при авторизации будем проверять, подтверждена ли учётная запись:
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 Создадим компонент, выполняющий валидацию токена и сообщающий о результате валидации:
<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, так что, надеюсь, читатели, дошедшие до этого места, найдут этот материал полезным хотя бы в качестве пищи для размышлений.
Полезные ссылки
- GitHub репозиторий (до шага «Миграция на Gradle»)
- GitHub репозиторий (после шага «Миграция на Gradle»)
- Приложение (до шага «Миграция на Gradle»)
- Приложение (после шага «Миграция на Gradle»)
- Тот же материал, написанный мной же, только на английском языке
- How to use Google reCaptcha with Vuejs
- Google ReCAPTCHA component for Vue.js
- Guide to Spring Email
- Send Email using Spring Boot and Thymeleaf
- Migrating Builds From Apache Maven
- Migrating build logic from Groovy to Kotlin
- Integrate Angular in Spring Boot Using Gradle
- Getting Started with Gradle on Heroku
- Deploying Gradle Apps on Heroku
- Deploying multi-project builds
- Please Stop Using Local Storage
- Authentication in SPA (ReactJS and VueJS) the right way
- Stateless Authentication using JWT to secure a Spring Boot REST API
- Registration – Activate a New Account by Email
ссылка на оригинал статьи https://habr.com/ru/post/482222/