Aplicación web en Kotlin + Spring Boot + Vue.js (complemento)

¡Buenas tardes, queridos habitantes de Habr!

Como su nombre lo indica, este artículo es una adición a la aplicación web previamente escrita en Kotlin + Spring Boot + Vue.js , que nos permite mejorar el esqueleto de una aplicación futura y hacer que sea más fácil trabajar con ella.

Antes de comenzar la historia, permítanme agradecer a todos los que comentaron en el artículo anterior.

Contenido




Configuración de CI / CD (Heroku)


Considere la implementación de la integración continua y la entrega utilizando la plataforma Heroku cloud PaaS como ejemplo.

Lo primero que debemos hacer es poner el código de la aplicación en el repositorio en GitHub . Para que no haya nada superfluo en el repositorio, recomiendo los siguientes contenidos del archivo .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 


Importante: antes de comenzar a trabajar con Heroku, agregue un archivo llamado Procfile (sin ninguna extensión) al directorio raíz con la línea:
web: java -Dserver.port=$PORT -jar backend/target/backend-0.0.1-SNAPSHOT.jar , donde backend-0.0.1-SNAPSHOT.jar es el nombre del archivo JAR de ensamblaje . Y asegúrese de comprometerse y empujar .

Nota: también puede agregar el archivo travis.yaml al directorio raíz para reducir el tiempo de compilación y despliegue de la aplicación en Heroku:

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


Entonces:

# 1 Registrarse en Heroku .

# 2 Crea una nueva aplicación:

Crea una nueva aplicación


# 3 Heroku le permite conectar recursos adicionales a la aplicación, por ejemplo, la base de datos PostreSQL. Para hacer esto, haga: Aplicación -> Recursos -> Complementos -> Heroku Postgres :

Heroku postgres


# 4 Elige un plan:

Selección de plan


# 5 Ahora puede ver el recurso conectado:

Recurso conectado


# 6 Mire las credenciales, serán necesarias para configurar las variables de entorno: Configuración -> Ver credenciales :

Ver credenciales


# 7 Establecer variables de entorno: Aplicación -> Configuración -> Revelar valores de configuración :

Variables de entorno


# 8 Establezca las variables de entorno para la conexión en el siguiente formato:

 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> 

Como se ve


# 9 Cree todas las tablas necesarias en la nueva base de datos.

# 10 El archivo application.properties, respectivamente, debería verse así:

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 Crear una nueva tubería - Crear nueva tubería :

Crear nueva tubería


# 12 Método de implementación: GitHub (haga clic en Conectar a GitHub y siga las instrucciones en una nueva ventana).

# 13 Habilitar implementaciones automáticas :

Habilitar implementaciones automáticas


# 14 Implementación manual: haga clic en Implementar rama para la primera implementación. Directamente en el navegador verá la salida de la línea de comandos.

Despliegue manual


# 15 Haga clic en Ver después de una compilación exitosa para abrir la aplicación implementada:

Vista



Protección de bot (reCAPTCHA)


El primer paso para habilitar la verificación reCAPTCHA en nuestra aplicación es crear un nuevo reCAPTCH en el panel de administración de Google . Allí creamos un nuevo sitio (Agregar nuevo sitio / Crear) y establecemos la siguiente configuración:

Configuraciones de ReCAPTCHA


En la sección Dominios , debe especificar, además de la dirección donde vivirá la aplicación, debe especificar localhost , de modo que durante la depuración evite problemas en la forma de la imposibilidad de iniciar sesión en su aplicación.

Backend

Guarde la clave del sitio y la clave secreta ...

clave del sitio / clave secreta


... luego para asignarlos a las variables de entorno y los nombres de las variables, a su vez, para asignar a las nuevas propiedades de application.properties :

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

Agregue una nueva dependencia en pom.xml para la verificación en el lado de Google de los tokens reCAPTCHA que el cliente nos enviará:

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

Ahora es el momento de actualizar las entidades que utilizamos para autorizar y registrar usuarios agregando un campo de cadena para el mismo token 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 } } 


Agregue un pequeño servicio que transmitirá el token reCAPTCHA a un servicio especial de Google e informará en respuesta si el token pasó la verificación:

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


Este servicio se debe utilizar en el controlador de registro y autorización de usuarios:

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


Frontend

El primer paso es instalar y guardar el paquete reCAPTHA :

 $ npm install --save vue-recaptcha 

Luego, conéctese al script en index.html :

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

Agregue cualquier captcha a cualquier espacio libre en la página:

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

Y el botón para la acción objetivo (autorización o registro) ahora llamará primero al método de validación:

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

Agregue la dependencia a los componentes:

 import VueRecaptcha from 'vue-recaptcha' 

Editar exportación por defecto :

 components: { VueRecaptcha }, … data() { … siteKey: <i>  </i> … } 

Y agregue nuevos métodos:

  • validateCaptcha() - que se llama haciendo clic en el botón
  • onCapthcaVerified(recaptchaToken) onCaptchaExpired() , que el propio captcha llama

Nuevos métodos
 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() } 


Resultado



Envío de correo electrónico


Considere la posibilidad de enviar cartas a nuestra aplicación a través de un servidor de correo público, como Google o Mail.ru.

El primer paso, respectivamente, será crear una cuenta en el servidor de correo seleccionado, si aún no lo está.

El segundo paso es que debemos agregar las siguientes dependencias en pom.xml :

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


También debe agregar nuevas propiedades a application.properties :

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


Puede especificar la configuración de SMTP aquí: Google y Mail.ru

Cree una interfaz donde declaremos varios métodos:

  • Para enviar un mensaje de texto regular
  • Para enviar un correo electrónico HTML
  • Para enviar un correo electrónico usando una plantilla

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


Ahora creemos una implementación de esta interfaz: el servicio de envío de correos electrónicos:

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


  • Utilizamos JavaMailSender configurado automáticamente por Spring para enviar correos electrónicos
  • Enviar cartas regulares es extremadamente simple: solo necesita agregar texto al cuerpo de la carta y enviarla
  • Los correos electrónicos HTML se definen como mensajes de tipo Mime y su contenido como text/html
  • Para procesar la plantilla de mensaje HTML, usamos Spring Template Engine

Creemos una plantilla simple para escribir usando el marco Thymeleaf colocándola en 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> 


Los elementos cambiantes de la plantilla (en nuestro caso, el nombre del destinatario y la ruta de la imagen para la firma) se declaran utilizando marcadores de posición.

Ahora cree o actualice un controlador que enviará cartas:

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


Nota: Para asegurarnos de que todo funcione, primero nos enviaremos cartas a nosotros mismos.

Además, para asegurarnos de que todo funcione, podemos crear una modesta interfaz web que simplemente extraiga los métodos del servicio web:

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> 


Nota: no olvide actualizar router.js y agregue el enlace a la App.vue navegación de App.vue si está creando un nuevo componente.


Gradle Migration


Aclararé de inmediato: si este artículo se considera una mejora, deje que todos decidan por su propio proyecto. Solo miramos cómo hacer esto.

En general, puede usar la instrucción Mudarse de Maven a Gradle en menos de 5 minutos , pero es poco probable que el resultado cumpla con las expectativas. Todavía recomendaría hacer la migración manualmente, no tomará mucho más tiempo.

Lo primero que debemos hacer es instalar Gradle .

Luego, debemos realizar el siguiente procedimiento para ambos subproyectos: backend y fronted :

# 1 Eliminar archivos Maven - pom.xml , .mvn .

# 2 En el directorio de subproyectos, ejecute gradle init y responda las preguntas:

  • Seleccione el tipo de proyecto a generar: básico
  • Seleccionar idioma de implementación: Kotlin
  • Seleccione el script de compilación DSL: Kotlin (ya que estamos escribiendo un proyecto en Kotlin)

# 3 Eliminar settings.gradle.kts : este archivo solo es necesario para el proyecto raíz.

# 4 Ejecute el gradle wrapper .

Ahora pasemos a nuestro proyecto raíz. Para ello, debe seguir los pasos 1, 2 y 4 descritos anteriormente para subproyectos: todo es igual excepto eliminar settings.gradle.kts .

La configuración de compilación para el proyecto de fondo se verá así:

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


  • Se deben especificar todos los complementos requeridos de Kotlin y Spring.
  • No se olvide del complemento org.jetbrains.kotlin.plugin.jpa : es necesario conectarse a la base de datos
  • En las dependencias, debe especificar runtimeOnly(project(":frontend")) ; primero debemos crear el proyecto frontend

Configuración de compilación para proyecto 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") } 


  • En mi ejemplo, el complemento org.siouan.frontend se usa para construir el proyecto
  • En la sección frontend {...} debe indicar la versión de Node.js, así como los comandos que invocan los scripts de limpieza, instalación y ensamblaje especificados en package.json
  • Ahora empaquetamos nuestro subproyecto frontend en un archivo JAR y lo usamos como una dependencia ( runtimeOnly(project(":frontend")) en el back-end ), por lo que debemos describir una tarea que copie los archivos del directorio de ensamblado a / público y crea un archivo jar

Nota:

  • Edite vue.config.js , cambiando el directorio de compilación a build / dist .
  • En el archivo de script de compilación package.json , especifique vue-cli-service build o asegúrese de que esté especificado

El archivo settings.gradle.kts en el proyecto raíz debe contener el siguiente código: ...

 rootProject.name = "demo" include(":frontend", ":backend") 

... es el nombre del proyecto y los subproyectos.

Y ahora podemos construir el proyecto ejecutando el comando: ./gradlew build

Nota: si para los marcadores de posición especificados en application.properties (por ejemplo, ${SPRING_DATASOURCE_URL} ) no hay variables de entorno correspondientes, el ensamblado fallará. Para evitar esto, use /gradlew build -x

Puede verificar la estructura de los proyectos utilizando el gradle -q projects , el resultado debería ser similar a este:

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

Y finalmente, para ejecutar la aplicación, debe ejecutar ./gradlew bootRun .

.gitignore


Los siguientes archivos y carpetas deben agregarse al archivo .gitignore :

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

Importante: no debe agregar archivos gradlew a .gitignore ; no hay nada peligroso en ellos, pero son necesarios para un ensamblado exitoso en un servidor remoto.

Despliegue en Heroku


Veamos los cambios que necesitamos hacer para que la aplicación se implemente de manera segura en Heroku.

# 1 Procfile

Necesitamos pedirle a Heroku nuevas instrucciones para iniciar la aplicación:

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

# 2 variables de entorno

Heroku puede rehacer el tipo de aplicación (por ejemplo, la aplicación Spring Boot) y seguir las instrucciones de ensamblaje apropiadas. Pero nuestra aplicación (el proyecto raíz) no se parece a una aplicación Spring Boot para Heroku. Si dejamos todo como está, Heroku nos pedirá que definamos la stage . Honestamente, no sé dónde termina este camino, porque no lo seguí. Es más fácil definir una variable GRADLE_TASK con un valor de build :



# 3 reCAPTCHA

Al colocar la aplicación en un nuevo dominio, no olvide actualizar el captcha, las variables de entorno GOOGLE_RECAPTCHA_KEY_SITE y GOOGLE_RECAPTCHA_KEY_SECRET , y también actualizar la clave del sitio en el subproyecto de interfaz.


Almacenar token JWT en cookies


En primer lugar, le recomiendo que lea el artículo Por favor, deje de usar el almacenamiento local , especialmente la sección Por qué el almacenamiento local es inseguro y no debe usarlo para almacenar datos confidenciales .

Veamos cómo puede almacenar el token JWT en un lugar más seguro: cookies en el indicador httpOnly , donde no estará disponible para leer / cambiar usando JavaScript.

# 1 Eliminando toda la lógica relacionada con JWT de la interfaz:
Dado que el token aún no es accesible con JavaScript con el nuevo método de almacenamiento, puede eliminar de forma segura todas las referencias a él de nuestro subproyecto.

Pero el rol del usuario sin referencia a ningún otro dato no es una información tan importante, aún puede almacenarse en el Almacenamiento local y determinar si el usuario está autorizado o no, dependiendo de si este rol está definido.

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


Tenga cuidado al refactorizar store/index.js: si la autorización y la desautorización no funcionan correctamente, los mensajes de error molestarán persistentemente en la consola.

# 2 Devuelva el JWT como una cookie en el controlador de autorización ( no en el cuerpo de la respuesta ):

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


Importante : Por favor, nota que puse opciones authCookieNamey isCookieSecureen application.properties - el envío de cookies para la bandera securesólo puede https, lo que hace que sea extremadamente difícil de depurar con localhost. PERO en producción, por supuesto, es mejor usar cookies con esta bandera.

Además, ahora es aconsejable que las respuestas del controlador usen una entidad sin un campo especial para JWT. Actualización n .

° 3JwtAuthTokenFilter :
solíamos tomar un token del encabezado de la solicitud, ahora lo tomamos de las cookies:

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 Habilitación de CORS
Si en mi artículo anterior aún pudieras saltarte esta pregunta, ahora sería extraño proteger el token JWT sin tener que habilitar CORS en el lado del backend.

Puedes arreglar esto editando 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() } 


Y ahora puede eliminar todas las anotaciones @CrossOriginde los controladores.

Importante: el parámetro AllowCredentials es necesario para enviar solicitudes desde el frontend. Lea más sobre esto aquí .

# 5 Actualización de encabezados en el lado frontal:

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


Cheque


Intentemos iniciar sesión en la aplicación iniciando sesión desde un host que no está en la lista permitida WebSecurityConfig.kt. Para hacer esto, ejecute el back-end en el puerto 8080y la interfaz, por ejemplo, en 8082e intente iniciar sesión:

Resultado


Solicitud de autorización rechazada por la política de CORS.

Ahora veamos cómo funcionan las cookies de marca en general httpOnly. Para hacer esto, vamos, por ejemplo, al sitio https://kotlinlang.org y ejecútelo en la consola del navegador:

 document.cookie 

Resultado


Las httpOnlycookies no relacionadas con este sitio aparecerán en la consola , que, como vemos, son accesibles a través de JavaScript.

Ahora entremos a nuestra aplicación, inicie sesión (para que el navegador guarde la cookie con JWT) y repita lo mismo:

Resultado


Nota: este método de almacenar un token JWT es más confiable que usar el Almacenamiento local, pero debe comprender que no es una panacea.


Confirmación de registro por correo electrónico


Un breve algoritmo para realizar esta tarea es el siguiente:

  1. Para todos los usuarios nuevos, el atributo isEnableden la base de datos se establece enfalse
  2. Se genera un token de cadena a partir de caracteres arbitrarios, que servirá como clave para confirmar el registro.
  3. El token se envía al usuario por correo como parte del enlace.
  4. El atributo isEnabledse establece en verdadero si el usuario sigue el enlace durante un período de tiempo establecido.

Ahora considere este proceso con más detalle.

Necesitamos una tabla para almacenar el token para confirmar el registro:

 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; 

Y, en consecuencia, una nueva entidad para el mapeo relacional de objetos ...:

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


... y el repositorio:

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


Ahora necesitamos implementar herramientas para administrar tokens: crear, verificar y enviar por correo electrónico. Para hacer esto, modificamos UserDetailsServiceImplagregando métodos para crear y verificar el token:

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


Ahora agregue un método para enviar un correo electrónico con un enlace de confirmación a 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)} } 


Nota:

  • Recomendaría almacenar la URL del host en application.properties
  • En nuestro enlace, pasamos dos parámetros GET ( tokeny confirmRegistration) a la dirección donde se implementa la aplicación. Más adelante explicaré por qué.

Modificamos el controlador de registro de la siguiente manera:

  • Todos los nuevos usuarios establecerán el valor falsedel campo.isEnabled
  • Después de crear una nueva cuenta, le enviaremos un correo electrónico para confirmar el registro
  • Crear un controlador de validación de token separado
  • Importante: durante la autorización verificaremos si la cuenta está verificada:

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


Ahora trabajemos en la interfaz:

# 1 Crear componente RegistrationConfirmPage.vue

# 2 Agregue una nueva ruta router.jscon el parámetro :token:

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

Actualización n SignUp.vue. ° 3 : después de enviar correctamente los datos de los formularios, les informaremos que para completar el registro, debe seguir el enlace en la carta.

# 4 Importante: lamentablemente, no podemos proporcionar un enlace fijo a un componente separado que valide el token e informe el éxito o el fracaso. De todos modos, los enlaces con barras diagonales nos llevarán a la página original de la aplicación. Pero podemos decirle a nuestra aplicación sobre la necesidad de confirmar el registro utilizando el parámetro GET transmitido 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 Creemos un componente que realice la validación de token e informe sobre el resultado de la validación:

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> 


Resultado


En lugar de una conclusión


En conclusión de este material, me gustaría hacer una digresión y decir que el concepto mismo de la aplicación discutida en este y en el artículo anterior no era nuevo al momento de la redacción. Tarea crear rápidamente una pila completa de aplicaciones en la primavera de arranque utilizando modernas JavaScript marcos angular / React / Vue.js resuelve elegantemente inconformista .

Sin embargo, las ideas descritas en este artículo bien pueden implementarse incluso utilizando JHipster, por lo que espero que los lectores que vengan a este lugar encuentren este material útil incluso como alimento para el pensamiento.


Enlaces utiles


Source: https://habr.com/ru/post/482222/


All Articles