Добрый день, дорогие обитатели Хабра!
Как и следует из названия, данная статья является дополнением к написанной ранее Веб-приложение на Kotlin + Spring Boot + Vue.js, позволяющим усовершенствовать скелет будущего приложения и сделать удобнее работу с ним.
Прежде чем приступить к повествованию, позвольте поблагодарить всех, кто оставлял комментарии в предыдущей статье.
Рассмотрим реализацию непрерывной интеграции и доставки на примере облачной PaaS-платформы Heroku.
Первое, что нам необходимо сделать — разместить код приложения в репозитории на GitHub. Для того, чтобы в репозитории не оказалось ничего лишнего, рекомендую следующее содержание файла .gitignore:
Важно: перед тем, как начать работу с Heroku, добавьте в корневую директорию файл с названием Procfile (без какого-либо расширения) со строкой:
Примечание: также в корневую директорию можно добавить файл travis.yaml, чтобы сократить время сборки и развёртывания приложения на Heroku:
Затем:
#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 Задайте переменные окружения для подключения в следующем формате:
#9 Создайте все необходимые таблицы в новой базе данных.
#10 Файл application.properties, соотвественно, должен иметь примерно такой вид:
#11 Создайте новый пайплайн — Create new pipeline:
#12 Deployment method — GitHub (нажмите Connect to GitHub и следуйте инструкциям в новом окне).
#13 Активируйте автоматическое развёртывание — Enable Automatic Deploys:
#14 Manual Deploy — нажмите Deploy Branch для первого развёртывания. Прямо в браузере вы увидите вывод командной строки.
#15 Нажмите View после успешной сборки, чтобы открыть развёрнутое приложение:
Первый шаг для подключения проверки reCAPTCHA в нашем приложении — создание новой reCAPTCH'и в администраторской панели Google. Там создаём новый сайт (Add new site / Create) и устанавливаем следующие настройки:
В разделе Domains стоит указать помимо адреса, по которому будет жить приложение, следует указать
Бэкенд
Сохраним site key и secret key…
… чтобы потом присвоить их переменным окружения, а названия переменных, в свою очередь, присвоить новым свойствам application.properties:
Добавим новую зависимость в pom.xml для верификации на стороне Google reCAPTCHA-токенов, которые будет присылать нам клиент:
Теперь самое время обновить сущности, которые мы используем для авторизации и регистрации пользователей, добавив в них строковое поле для того самого reCAPTCHA-токена:
Добавим небольшой сервис, который будет транслировать reCAPTCHA-токен специальному сервису Google и сообщать в ответ, прошёл ли токен верификацию:
Этот сервис необходимо задействовать в контроллере регистрации и авторизации пользователей:
Фронтенд
Первым шагом установим и сохраним пакет
Затем подключим в скрипт в index.html:
В любое свободное место на странице добавим саму капчу:
А кнопка целевого действия (авторзации или регистрации) теперь будет сначала вызывать метод валидации:
Добавим зависимость в компоненты:
Отредактируем export default:
И добавим новые методы:
Рассмотрим возможность отправки писем нашим приложением через публичный почтовый сервер, например Google или Mail.ru.
Первым шагом, соотвественно, станет создание аккаунта на выбранном почтовом сервере, если его ещё нет.
Вторым шагом нам необходимо добавить следующие зависимости в pom.xml:
Также необходимо добавить новые свойства в application.properties:
Настройки SMTP можно уточнить тут: Google и Mail.ru
Создадим интерфейс, где объявим несколько методов:
Теперь создадим реализацию этого интерфейса — сервис отправки электронных писем:
Давайте же создадим простенький шаблон для письма с использованием фреймворка Thymeleaf, поместив его в src/main/resources/templates/:
Изменяющиеся элементы шаблона (в нашем случае — имя адресата и путь картинки для подписи) объявлены с помощью плейсхолдеров.
Теперь создадим или обновим контроллер, который будет посылать письма:
Примечание: Чтобы убедиться, что всё работает, для начала будем посылать письма самим себе.
Также, чтобы убедиться, что всё работает, можем создать скромный веб-интерфейс, который будет просто дёргать методы веб-сервиса:
Примечание: не забудьте обновить
Сразу уточню: считать ли этот пункт усовершенствованием, пусть каждый решает сам для своего проекта. Мы же просто рассмотрим, как это сделать.
Вообще, можно воспользоваться инструкцией Moving from Maven to Gradle in under 5 minutes, но вряд ли результат оправдает ожидания. Я бы порекомендовал всё-таки осуществлять миграцию вручную, это займёт не намного больше времени.
Первое, что нам нужно сделать — установить Gradle.
Затем нам необходимо выполнить следующий порядок действий для обоих подпроектов —
#1 Удалить файлы Maven — pom.xml, .mvn.
#2 В каталоге подпроект выполнить gradle init и ответить на вопросы:
#3 Удалить settings.gradle.kts — этот файл нужен только для корневого проекта.
#4 Выполнить
Теперь обратимся к нашему корневому проекту. Для него нужно выполнить шаги 1, 2 и 4 описанные выше для под проектов — всё то же самое, кроме удаления settings.gradle.kts.
Конфигурация сборки для проекта backend будет выглядеть следующим образом:
Конфигурация сборки для проекта frontend:
Примечание:
Файл settings.gradle.kts в корневом проекте должен содержать следующий код:…
… — название проекта и подпроектов.
И теперь мы можем собрать проект, выполнив команду:
Примечание: если для плейсхолдеров, указанных в application.properties (например,
Проверить структуру проектов можно с помощью команды
И, наконец, чтобы запустить приложение, необходимо выполнить
В файл .gitignore следует добавить следующие файлы и папки:
Важно: не следует добавлять файлы
Давай рассмотрим изменения, которые нам необходимо внести, чтобы приложение благополучно разворачивалось на Heroku.
#1 Procfile
Нам необходимо задать Heroku новые инструкции для запуска приложения:
#2 Переменные среды
Heroku способна переделать тип приложения (например, приложение Spring Boot) и выполнять соотвествующие инструкции для сборки. Но наше приложение (корневой проект) не выглядит для Heroku как приложение Spring Boot. Если мы оставим всё, как есть, Heroku попросит нас определить таску
#3 reCAPTCHA
При размещении приложения в новом домене не забудьте обновить капчу, переменные среды
Прежде всего, настоятельно рекомендую ознакомиться со статьей Please Stop Using Local Storage, особенно с разделом Why Local Storage is Insecure and You Shouldn’t Use it to Store Sensitive Data.
Давайте рассмотрим, как можно хранить токен JWT в более безопасном месте — куках в флагом
#1 Удаление всей логики, относящейся к JWT из фронтенда:
Поскольку с новым способом хранения токен всё равно не доступен для JavaScript'а, можно смело удалить все упоминания о нём из нашего подпроекта.
А вот роль пользователя без привязки к каким-либо другим данных — не столь не столь важная информация, её можно по-прежнему хранить в Local Storage и определять, авторизован пользователь или нет, в зависимости от того, определена ли эта роль.
Будьте осторожны при рефакторинге
#2 Возвращение JWT как cookie в контроллере авторизации (не в теле ответа):
Важно: обратите внимание, что я поместил параметры
Также ответов контроллера теперь целесообразно использовать сущность без специального поля для JWT.
#3 Обновление
Раньше мы брали токен из заголовка запроса, теперь мы берём его из куки:
#4 Включение CORS
Если в предыдущей моей статье ещё можно было незаметно пропустить этот вопрос, то сейчас было бы странно защищать JWT-токен, так и не включив CORS на стороне бэкенда.
Исправить это можно отредактировав
И теперь можно удалить все аннотации
Важно: параметр AllowCredentials необходим для отправки запросов со стороны фронтенда. Подробнее об этом можно почитать здесь.
#5 Актуализация заголовков на стороне фронтенда:
Давайте попробуем авторизоваться в приложении, зайдя с хоста, не входящего в список разрешённых в
Запрос авторизации отклонён политикой CORS.
Теперь давайте посмотрим, как вообще работаю куки с флагом
В консоли появятся не-
Теперь зайдём в наше приложение, авторизуемся (чтобы браузер сохранил куку с JWT) и повторим то же самое:
Примечание: такой способ хранения JWT-токена является более надёжным, чем с использованием Local Storage, но стоит понимать, что он не является панацеей.
Краткий алгоритм выполнения этой задачи таков:
Теперь рассмотрим этот процесс более детально.
Нам понадобится таблица для хранения токен для подтверждения регистрации:
И, соотвественно, новая сущность для объектно-реляционного отображения…:
… и репозиторий:
Теперь нам нужно реализовать средства для управления токенами — создания, верификации и отправки по электронной почте. Для этого модифицируем
Теперь добавим метод для отправки письма с ссылкой для подтверждения в
Примечание:
Модифицируем контроллер регистрации следующим образом:
Теперь поработаем на фронтендом:
#1 Создадим компонент
#2 Добавим новый путь в
#3 Обновим
#4 Важно: увы, мы не можем дать фиксированную ссылку на отдельный компонент, который выполнял бы валидацию токена и сообщал бы об успехе или неуспехе. Ссылки с прописанными через слэш путями всё равно приведут нас на исходную страницу приложения. Но мы можем сообщить нашему приложению о необходимости подтвердить регистрацию с помощью передаваемого GET-параметра
#5 Создадим компонент, выполняющий валидацию токена и сообщающий о результате валидации:
В завершение этого материала хотелось бы сделать небольшое лирическое отступление и сказать, что сама концепция приложения, рассмотренного в этой и предыдущей статье, не была нова уже на момент начала написания. Задачу быстрого создания full stack приложений на Spring Boot с использованием современных JavaScript-фреймворков Angular/React/Vue.js изящно решает Hipster.
Однако, идеи, описанные в данной статье вполне можно реализовать даже используя JHipster, так что, надеюсь, читатели, дошедшие до этого места, найдут этот материал полезным хотя бы в качестве пищи для размышлений.
Как и следует из названия, данная статья является дополнением к написанной ранее Веб-приложение на Kotlin + Spring Boot + Vue.js, позволяющим усовершенствовать скелет будущего приложения и сделать удобнее работу с ним.
Прежде чем приступить к повествованию, позвольте поблагодарить всех, кто оставлял комментарии в предыдущей статье.
Содержание
- Настройка CI/CD (Heroku)
- Защита от ботов (reCAPTCHA)
- Отправка электронной почты
- Миграция на Gradle
- Хранение токена JWT в Cookies
- Подтверждение регистрации по электронной почте
- Полезные ссылки
Настройка 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 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 и определять, авторизован пользователь или нет, в зависимости от того, определена ли эта роль.
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, но стоит понимать, что он не является панацеей.
Подтверждение регистрации по электронной почте
Краткий алгоритм выполнения этой задачи таков:
- Для всех новых пользователей атрибуту
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;
И, соотвественно, новая сущность для объектно-реляционного отображения…:
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, так что, надеюсь, читатели, дошедшие до этого места, найдут этот материал полезным хотя бы в качестве пищи для размышлений.
Полезные ссылки
- 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