Добрый день, дорогие обитатели Хабра!
Как и следует из названия, данная статья является дополнением к написанной ранее Веб-приложение на Kotlin + Spring Boot + Vue.js, позволяющим усовершенствовать скелет будущего приложения и сделать удобнее работу с ним.
Прежде чем приступить к повествованию, позвольте поблагодарить всех, кто оставлял комментарии в предыдущей статье.
*.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
web: java -Dserver.port=$PORT -jar backend/target/backend-0.0.1-SNAPSHOT.jar
, где backend-0.0.1-SNAPSHOT.jar — имя собирающегося JAR-файла. И обязательно сделайте commit и push.language: java
jdk:
- oraclejdk8
script: mvn clean install jacoco:report coveralls:report
cache:
directories:
- node_modules
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>
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
localhost
, чтобы при отладке избежать неприятностей в виде невозможности авторизоваться в своём же приложении.google.recaptcha.key.site=${GOOGLE_RECAPTCHA_KEY_SITE}
google.recaptcha.key.secret=${GOOGLE_RECAPTCHA_KEY_SECRET}
<dependency>
<groupId>com.mashape.unirest</groupId>
<artifactId>unirest-java</artifactId>
<version>1.4.9</version>
</dependency>
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
}
}
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)
}
}
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
<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'
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()
}
<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>
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
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()
}
}
}
text/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>
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
, если создаёте новый компонент.backend
и fronted
:gradle wrapper
.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"
}
}
runtimeOnly(project(":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
runtimeOnly(project(":frontend"))
в backend'е), так что нам необходимо описать задание (task
), которое копирует файлы из сборочной директории в /public и создаёт JAR-файлvue.config.js
, изменив директорию сборки на build/dist.package.json
build script — vue-cli-service build
или убедитесь, что он указанrootProject.name = "demo"
include(":frontend", ":backend")
./gradlew build
${SPRING_DATASOURCE_URL}
) нет соответствующих переменных среды, сборка завершится неудачно. Чтобы этого избежать, следует использовать /gradlew build -x
gradle -q projects
, результат должен выглядеть подобным образом:Root project 'demo'
+--- Project ':backend'
\--- Project ':frontend'
./gradlew bootRun
.gradlew
в .gitignore — ничего опасного в них нет, однако они нужны для успешной сборки на удалённом сервере.web: java -Dserver.port=$PORT -jar backend/build/libs/backend-0.0.1-SNAPSHOT.jar
stage
. Честно говоря, не знаю, где заканчивается этот путь, потому что я по нему не шёл. Проще определить переменную GRADLE_TASK
со значением build
:GOOGLE_RECAPTCHA_KEY_SITE
и GOOGLE_RECAPTCHA_KEY_SECRET
, а также обновить Site Key в подпроекте фронтенда. httpOnly
, где он будет недоступен для чтения/изменения с помощью JavaScript'а.
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
: если авторизация и деавторизация не будут работать корректно, в консоль будут назойливо сыпаться сообщения об ошибках.@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. НО в продакшене, конечно, лучше использовать куки с этим флагом.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
}
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
из контроллеров.
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
и попробуем авторизоваться:httpOnly
. Для этого зайдём, например, на сайт https://kotlinlang.org и выполним в консоли браузера: document.cookie
httpOnly
куки, относящиеся к этому сайту, которые, как мы видим, доступны через JavaScript.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)}
}
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
}
}
RegistrationConfirmPage.vue
router.js
с параметром :token
:
{
path: '/registration-confirm/:token',
name: 'RegistrationConfirmPage',
component: RegistrationConfirmPage
}
SignUp.vue
— после успешной отправки данных с форм будем сообщать им, что для завершения регистрации необходимо перейти по ссылке в письме.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();
}
<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>
К сожалению, не доступен сервер mySQL