Bonjour, chers habitants de Habr!
Comme son nom l'indique, cet article est un ajout à l'
application Web précédemment écrite
sur Kotlin + Spring Boot + Vue.js , qui nous permet d'améliorer le squelette d'une future application et de la rendre plus facile à travailler avec.
Avant de commencer l'histoire, permettez-moi de remercier tous ceux qui ont commenté dans l'article précédent.
Table des matières
Configuration CI / CD (Heroku)
Prenons l'exemple de la mise en œuvre d'une intégration et d'une livraison continues en utilisant la plate-forme PaaS cloud
Heroku .
La première chose que nous devons faire est de mettre le code d'application dans le référentiel sur
GitHub . Pour qu'il n'y ait rien de superflu dans le référentiel, je recommande le contenu suivant du fichier
.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
Important: avant de commencer à travailler avec Heroku, ajoutez un fichier appelé
Procfile (sans aucune extension) au répertoire racine avec la ligne:
web: java -Dserver.port=$PORT -jar backend/target/backend-0.0.1-SNAPSHOT.jar
, où
backend-0.0.1-SNAPSHOT.jar est le nom du fichier JAR d'
assembly . Et assurez-vous de
vous engager et de pousser .
Remarque: vous pouvez également ajouter le fichier travis.yaml au répertoire racine pour réduire le temps de génération et de déploiement de l'application sur Heroku:
travis.yaml language: java jdk: - oraclejdk8 script: mvn clean install jacoco:report coveralls:report cache: directories: - node_modules
Ensuite:
# 1 Inscrivez-vous sur
Heroku .
# 2 Créez une nouvelle application:
Créer une nouvelle application # 3 Heroku vous permet de connecter des ressources supplémentaires à l'application, par exemple, la base de données PostreSQL. Pour ce faire, faites:
Application -> Ressources -> Modules complémentaires -> Heroku Postgres :
# 4 Choisissez un plan:
# 5 Vous pouvez maintenant voir la ressource connectée:
# 6 Regardez les informations d'identification, elles seront nécessaires pour configurer les variables d'environnement:
Paramètres -> Afficher les informations d'identification :
Afficher les informations d'identification # 7 Définissez les variables d'environnement:
Application -> Paramètres -> Reveal Config Vars :
Variables d'environnement # 8 Définissez les variables d'environnement pour la connexion au format suivant:
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 Créez toutes les tables nécessaires dans la nouvelle base de données.
# 10 Le fichier application.properties, respectivement, devrait ressembler à ceci:
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 Créer un
nouveau pipeline -
Créer un nouveau pipeline :
Créer un nouveau pipeline # 12 Méthode de déploiement -
GitHub (cliquez sur
Se connecter à GitHub et suivez les instructions dans une nouvelle fenêtre).
# 13 Activer les déploiements automatiques :
Activer les déploiements automatiques # 14 Déploiement manuel - Cliquez sur
Déployer la branche pour le premier déploiement. Dans le navigateur, vous verrez la sortie de la ligne de commande.
# 15 Cliquez sur
Afficher après la génération réussie pour ouvrir l'application déployée:
Protection contre les bots (reCAPTCHA)
La première étape pour activer la vérification reCAPTCHA dans notre application consiste à créer un nouveau reCAPTCH dans
le panneau d'administration de Google . Là, nous créons un nouveau site (Ajouter un nouveau site / Créer) et définissons les paramètres suivants:
Dans la section
Domaines , vous devez spécifier en plus de l'adresse où l'application va vivre, vous devez spécifier
localhost
, de sorte que lors du débogage, vous évitiez les problèmes sous la forme de l'impossibilité de se connecter à votre application.
BackendEnregistrez la
clé du
site et
la clé secrète ...
clé de site / clé secrète ... puis de les affecter à des variables d'environnement, et les noms de variables, à leur tour, à affecter à de nouvelles propriétés
application.properties :
google.recaptcha.key.site=${GOOGLE_RECAPTCHA_KEY_SITE} google.recaptcha.key.secret=${GOOGLE_RECAPTCHA_KEY_SECRET}
Ajoutez une nouvelle dépendance dans
pom.xml pour vérification du côté Google des jetons reCAPTCHA que le client nous enverra:
<dependency> <groupId>com.mashape.unirest</groupId> <artifactId>unirest-java</artifactId> <version>1.4.9</version> </dependency>
Il est maintenant temps de mettre à jour les entités que nous utilisons pour autoriser et enregistrer les utilisateurs en leur ajoutant un champ de chaîne pour le même jeton 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 } }
Ajoutez un petit service qui diffusera le jeton reCAPTCHA à un service Google spécial et indiquera en réponse si le jeton a réussi la vérification:
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) } }
Ce service doit être utilisé dans le contrôleur d'enregistrement et d'autorisation des utilisateurs:
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...
FrontendLa première étape consiste à installer et enregistrer le package
reCAPTHA
:
$ npm install --save vue-recaptcha
Connectez-vous ensuite au script dans
index.html :
<script src="https://www.google.com/recaptcha/api.js onload=vueRecaptchaApiLoaded&render=explicit" async defer></script>
Ajoutez n'importe quel captcha à n'importe quel espace libre sur la page:
<vue-recaptcha ref="recaptcha" size="invisible" :sitekey="sitekey" @verify="onCapthcaVerified" @expired="onCaptchaExpired" />
Et le bouton de l'action cible (autorisation ou enregistrement) appellera maintenant d'abord la méthode de validation:
<b-button v-on:click="validateCaptcha" variant="primary">Login</b-button>
Ajoutez la dépendance aux composants:
import VueRecaptcha from 'vue-recaptcha'
Modifier l'
exportation par défaut :
components: { VueRecaptcha }, … data() { … siteKey: <i> </i> … }
Et ajoutez de nouvelles méthodes:
validateCaptcha()
- qui est appelé en cliquant sur le boutononCapthcaVerified(recaptchaToken) onCaptchaExpired()
- que le captcha appelle lui-même
De nouvelles méthodes 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() }
Envoi d'email
Envisagez la possibilité d'envoyer des lettres à notre application via un serveur de messagerie public, tel que Google ou Mail.ru.
La première étape, respectivement, sera de créer un compte sur le serveur de messagerie sélectionné, s'il ne l'est pas déjà.
La deuxième étape, nous devons ajouter les dépendances suivantes dans
pom.xml :
Dépendances <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>
Vous devez également ajouter de nouvelles propriétés à
application.properties :
Propriétés 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
Vous pouvez spécifier les paramètres SMTP ici:
Google et
Mail.ruCréez une interface où nous déclarons plusieurs méthodes:
- Pour envoyer un SMS normal
- Pour envoyer un e-mail HTML
- Pour envoyer un e-mail à l'aide d'un modèle
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) }
Créons maintenant une implémentation de cette interface - le service d'envoi d'emails:
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() } } }
- Nous utilisons JavaMailSender configuré automatiquement par Spring pour envoyer des e-mails
- L'envoi de lettres régulières est extrêmement simple - il vous suffit d'ajouter du texte au corps de la lettre et de l'envoyer
- Les e-mails HTML sont définis comme des messages de type MIME et leur contenu sous forme de
text/html
- Pour traiter le modèle de message HTML, nous utilisons le moteur de modèle Spring
Créons un modèle simple pour écrire en utilisant le framework
Thymeleaf en le plaçant dans
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>
Les éléments changeants du modèle (dans notre cas, le nom du destinataire et le chemin de l'image pour signature) sont déclarés à l'aide d'espaces réservés.
Maintenant, créez ou mettez à jour un contrôleur qui enverra des lettres:
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) }
Remarque: Pour nous assurer que tout fonctionne, nous nous enverrons d'abord des lettres.
De plus, pour nous assurer que tout fonctionne, nous pouvons créer une interface Web modeste qui tirera simplement les méthodes du service 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>
Remarque: n'oubliez pas de mettre à jour
router.js
et d'ajouter le lien vers la
App.vue
navigation
App.vue
si vous créez un nouveau composant.
Migration Gradle
Je vais clarifier tout de suite: si cet élément est considéré comme une amélioration, laissez chacun décider de son propre projet. Nous regardons simplement comment procéder.
En général, vous pouvez utiliser l'instruction
Moving from Maven to Gradle en moins de 5 minutes , mais il est peu probable que le résultat soit à la hauteur des attentes. Je recommanderais toujours de faire la migration manuellement, cela ne prendra pas beaucoup de temps.
La première chose que nous devons faire est d'
installer Gradle .
Ensuite, nous devons effectuer la procédure suivante pour les deux sous-projets -
backend
et
fronted
:
# 1 Supprimer les fichiers Maven -
pom.xml ,
.mvn .
# 2 Dans le répertoire du sous-projet, exécutez gradle init et répondez aux questions:
- Sélectionnez le type de projet à générer: basique
- Sélectionnez le langage d'implémentation: Kotlin
- Sélectionnez le script de construction DSL: Kotlin (puisque nous écrivons un projet dans Kotlin)
# 3 Supprimez
settings.gradle.kts - ce fichier n'est nécessaire que pour le projet racine.
# 4 Exécutez
gradle wrapper
.
Passons maintenant à notre projet racine. Pour cela, vous devez suivre les étapes 1, 2 et 4 décrites ci-dessus pour les sous-projets - tout est le même
sauf la suppression de settings.gradle.kts .
La configuration de construction du projet
backend ressemblera à ceci:
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" } }
- Tous les plugins Kotlin et Spring requis doivent être spécifiés.
- N'oubliez pas le plugin org.jetbrains.kotlin.plugin.jpa - il est nécessaire de se connecter à la base de données
- Dans les dépendances, vous devez spécifier
runtimeOnly(project(":frontend"))
- nous devons d'abord construire le projet frontend
Construire la configuration pour le projet
frontal :
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") }
- Dans mon exemple, le plugin
org.siouan.frontend
est utilisé pour construire le projet - Dans la section
frontend {...}
indiquer la version de Node.js, ainsi que les commandes qui appellent les scripts de nettoyage, d'installation et d'assemblage spécifiés dans package.json
- Maintenant, nous emballons notre sous-projet frontal dans un fichier JAR et l'utilisons comme dépendance (
runtimeOnly(project(":frontend"))
dans le backend ), nous devons donc décrire une tâche qui copie les fichiers du répertoire d'assembly vers / public et crée un fichier jar
Remarque:- Modifiez
vue.config.js
, en changeant le répertoire de construction en build / dist . - Dans le fichier de script de construction
package.json
, spécifiez la construction vue-cli-service build
ou assurez-vous qu'il est spécifié
Le fichier settings.gradle.kts du projet racine doit contenir le code suivant: ...
rootProject.name = "demo" include(":frontend", ":backend")
... est le nom du projet et des sous-projets.
Et maintenant, nous pouvons construire le projet en exécutant la commande:
./gradlew build
Remarque: si pour les espaces réservés spécifiés dans
application.properties (par exemple,
${SPRING_DATASOURCE_URL}
) il n'y a pas de variables d'environnement correspondantes, l'assembly échouera. Pour éviter cela, utilisez
/gradlew build -x
Vous pouvez vérifier la structure des projets en utilisant la commande
gradle -q projects
, le résultat devrait ressembler à ceci:
Root project 'demo' +--- Project ':backend' \--- Project ':frontend'
Et enfin, pour exécuter l'application, vous devez exécuter
./gradlew bootRun
.
.gitignore
Les fichiers et dossiers suivants doivent être ajoutés au fichier
.gitignore :
- backend / build /
- frontend / build /
- construire
- .gradle
Important: vous ne devez pas ajouter de fichiers
gradlew
à
.gitignore - ils ne
contiennent rien de dangereux, mais ils sont nécessaires pour un assemblage réussi sur un serveur distant.
Déploiement sur Heroku
Examinons les modifications que nous devons apporter pour que l'application se déploie en toute sécurité sur Heroku.
# 1 ProcfileNous devons demander à Heroku de nouvelles instructions pour lancer l'application:
web: java -Dserver.port=$PORT -jar backend/build/libs/backend-0.0.1-SNAPSHOT.jar
# 2 variables d'environnementHeroku est capable de refaire le type d'application (par exemple, l'application Spring Boot) et de suivre les instructions d'assemblage appropriées. Mais notre application (le projet racine) ne ressemble pas à une application Spring Boot pour Heroku. Si nous laissons tout tel quel, Heroku nous demandera de définir la
stage
. Honnêtement, je ne sais pas où se termine ce chemin, car je ne l'ai pas suivi. Il est plus facile de définir une variable
GRADLE_TASK
avec une valeur de
build
:
# 3 reCAPTCHALorsque vous placez l'application dans un nouveau domaine, n'oubliez pas de mettre à jour le captcha, les variables d'environnement
GOOGLE_RECAPTCHA_KEY_SITE
et
GOOGLE_RECAPTCHA_KEY_SECRET
, ainsi que de mettre à jour la clé de site dans le sous-projet frontal.
Stockage du jeton JWT dans les cookies
Tout d'abord, je vous recommande fortement de lire l'article
Veuillez cesser d'utiliser le stockage local , en particulier la
section Pourquoi le stockage local n'est pas sécurisé et vous ne devriez pas l'utiliser pour stocker des données sensibles .
Voyons comment vous pouvez stocker le jeton JWT dans un endroit plus sécurisé - les cookies dans le drapeau
httpOnly
, où il ne sera pas disponible pour la lecture / modification à l'aide de JavaScript.
# 1 Suppression de toute la logique liée à JWT du frontend:
Étant donné que le jeton n'est toujours pas accessible avec JavaScript avec la nouvelle méthode de stockage, vous pouvez supprimer en toute sécurité toutes les références à celui-ci de notre sous-projet.
Mais le rôle de l'utilisateur sans référence à d'autres données n'est pas une information si importante, elle peut toujours être stockée dans le stockage local et déterminer si l'utilisateur est autorisé ou non, selon que ce rôle est défini.
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 });
Soyez prudent lors de la refactorisation store/index.js
: si l'autorisation et la désautorisation ne fonctionnent pas correctement, les messages d'erreur seront persistants dans la console.# 2 Renvoyez le JWT sous forme de cookie dans le contrôleur d'autorisation ( pas dans le corps de la réponse ):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) } }
Important : Remarque S'il vous plaît que je mets les options authCookieName
et isCookieSecure
dans application.properties - envoyer des cookies au drapeau secure
ne peut https, ce qui le rend extrêmement difficile de débogage avec localhost. MAIS en production, bien sûr, il est préférable d'utiliser des cookies avec ce drapeau.De plus, il est désormais recommandé que les réponses du contrôleur utilisent une entité sans champ spécial pour le JWT.# 3 Mise à jour JwtAuthTokenFilter
:Nous avions l'habitude de prendre un jeton de l'en-tête de la demande, maintenant nous le prenons des 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 Activation de CORSSi, dans mon article précédent, vous pouviez toujours ignorer cette question, il serait maintenant étrange de protéger le jeton JWT sans avoir à activer CORS côté backend.Vous pouvez résoudre ce problème en modifiant 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() }
Et maintenant, vous pouvez supprimer toutes les annotations @CrossOrigin
des contrôleurs.Important: le paramètre AllowCredentials est requis pour envoyer des demandes depuis le frontend. En savoir plus à ce sujet ici .# 5 Mise à jour des en-têtes du côté 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 } })
Vérifier
Essayons de nous connecter à l'application en nous connectant à partir d'un hôte qui ne figure pas dans la liste autorisée WebSecurityConfig.kt
. Pour ce faire, exécutez le backend sur le port 8080
et le frontend, par exemple, sur 8082
et essayez de vous connecter:Demande d'autorisation rejetée par la politique CORS.Voyons maintenant comment les cookies de drapeau fonctionnent en général httpOnly
. Pour ce faire, allons par exemple sur le site https://kotlinlang.org et exécutons-le dans la console du navigateur: document.cookie
Les non- httpOnly
cookies liés à ce site apparaîtront dans la console , qui, comme nous le voyons, sont accessibles via JavaScript.Maintenant, allons dans notre application, connectez-vous (pour que le navigateur enregistre le cookie avec JWT) et répétez la même chose:Remarque: cette méthode de stockage d'un jeton JWT est plus fiable que l'utilisation du stockage local, mais vous devez comprendre qu'il ne s'agit pas d'une panacée.Confirmation d'inscription par e-mail
Un bref algorithme pour effectuer cette tâche est le suivant:- Pour tous les nouveaux utilisateurs, l'attribut
isEnabled
de la base de données est défini surfalse
- Un jeton de chaîne est généré à partir de caractères arbitraires, qui servira de clé pour confirmer l'enregistrement
- Le token est envoyé à l'utilisateur par mail dans le cadre du lien
- L'attribut
isEnabled
est défini sur true si l'utilisateur suit le lien pendant une période de temps définie.
Examinons maintenant ce processus plus en détail.Nous avons besoin d'une table pour stocker le jeton pour confirmer l'inscription: 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;
Et, en conséquence, une nouvelle entité pour le mappage relationnel-objet ...: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) }
... et le référentiel: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> }
Nous devons maintenant implémenter des outils de gestion des jetons - création, vérification et envoi par e-mail. Pour ce faire, nous modifions UserDetailsServiceImpl
en ajoutant des méthodes pour créer et vérifier le jeton: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 } }
Ajoutez maintenant une méthode pour envoyer un e-mail avec un lien de confirmation à 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)} }
Remarque:- Je recommanderais de stocker l'URL de l'hôte dans application.properties
- Dans notre lien, nous transmettons deux paramètres GET (
token
et confirmRegistration
) à l'adresse où l'application est déployée. Plus tard, je vais expliquer pourquoi.
Nous modifions le contrôleur d'inscription comme suit:- Tous les nouveaux utilisateurs définiront la valeur
false
du champisEnabled
- Après avoir créé un nouveau compte, nous vous enverrons un e-mail pour confirmer l'inscription
- Créer un contrôleur de validation de jeton distinct
- Important: lors de l'autorisation nous vérifierons si le compte est vérifié:
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 {
Maintenant, travaillons sur le frontend:# 1 Créez le composant RegistrationConfirmPage.vue
# 2 Ajoutez un nouveau chemin router.js
avec le paramètre :token
: { path: '/registration-confirm/:token', name: 'RegistrationConfirmPage', component: RegistrationConfirmPage }
# 3 Mise à jour SignUp.vue
- après avoir envoyé avec succès les données des formulaires, nous les informerons que pour terminer l'inscription, vous devez suivre le lien dans la lettre.# 4 Important: hélas, nous ne pouvons pas fournir de lien fixe vers un composant distinct qui validerait le jeton et signalerait un succès ou un échec. Des liens avec des barres obliques nous mèneront tout de même à la page d'origine de l'application. Mais nous pouvons indiquer à notre application la nécessité de confirmer l'enregistrement en utilisant le paramètre GET transmis 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 Créons un composant qui effectue la validation des jetons et rend compte du résultat de la validation: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>
Au lieu d'une conclusion
En conclusion de ce document, je voudrais faire une digression et dire que le concept même de l'application discuté dans cet article et l'article précédent n'était pas nouveau au moment de la rédaction. Tâche de créer rapidement une pile complète d'applications au démarrage Spring en utilisant JavaScript-cadres modernes angulaire / React / Vue.js résout élégamment Hipster .Cependant, les idées décrites dans cet article peuvent être mises en œuvre même en utilisant JHipster, donc j'espère que les lecteurs qui viendront à cet endroit trouveront ce matériel utile même comme aliment de réflexion.Liens utiles