¡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 :
# 4 Elige un plan:
# 5 Ahora puede ver el recurso conectado:
# 6 Mire las credenciales, serán necesarias para configurar las variables de entorno:
Configuración -> Ver credenciales :
# 7 Establecer variables de entorno:
Aplicación -> Configuración -> Revelar valores de configuración :
# 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>
# 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 :
# 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.
# 15 Haga clic en
Ver después de una compilación exitosa para abrir la aplicación implementada:
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.
BackendGuarde 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...
FrontendEl 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ónonCapthcaVerified(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() }
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.ruCree 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 ProcfileNecesitamos 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 entornoHeroku 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 reCAPTCHAAl 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 authCookieName
y isCookieSecure
en application.properties - el envío de cookies para la bandera secure
só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 CORSSi 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 @CrossOrigin
de 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 8080
y la interfaz, por ejemplo, en 8082
e intente iniciar sesión: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
Las httpOnly
cookies 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: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:- Para todos los usuarios nuevos, el atributo
isEnabled
en la base de datos se establece enfalse
- Se genera un token de cadena a partir de caracteres arbitrarios, que servirá como clave para confirmar el registro.
- El token se envía al usuario por correo como parte del enlace.
- El atributo
isEnabled
se 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 UserDetailsServiceImpl
agregando 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 (
token
y 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
false
del 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 {
Ahora trabajemos en la interfaz:# 1 Crear componente RegistrationConfirmPage.vue
# 2 Agregue una nueva ruta router.js
con 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>
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