Selamat siang, penghuni Habr yang terkasih!
Sesuai namanya, artikel ini adalah tambahan untuk
aplikasi Web yang ditulis sebelumnya
di Kotlin + Spring Boot + Vue.js , yang memungkinkan kita untuk meningkatkan kerangka aplikasi masa depan dan membuatnya lebih mudah untuk bekerja dengannya.
Sebelum memulai cerita, izinkan saya mengucapkan terima kasih kepada semua orang yang berkomentar di artikel sebelumnya.
Isi
Pengaturan CI / CD (Heroku)
Pertimbangkan implementasi integrasi dan pengiriman berkelanjutan menggunakan platform
Heroku cloud PaaS sebagai contoh.
Hal pertama yang perlu kita lakukan adalah meletakkan kode aplikasi di repositori di
GitHub . Agar tidak ada yang berlebihan dalam repositori, saya merekomendasikan konten berikut dari file
.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
Penting: sebelum Anda mulai bekerja dengan Heroku, tambahkan file bernama
Procfile (tanpa ekstensi apa pun) ke direktori root dengan baris:
web: java -Dserver.port=$PORT -jar backend/target/backend-0.0.1-SNAPSHOT.jar
, di mana
backend-0.0.1-SNAPSHOT.jar adalah nama file JAR
rakitan . Dan pastikan untuk
melakukan komit dan dorong .
Catatan: Anda juga dapat menambahkan file travis.yaml ke direktori root untuk mengurangi waktu pembuatan dan penerapan aplikasi di Heroku:
travis.yaml language: java jdk: - oraclejdk8 script: mvn clean install jacoco:report coveralls:report cache: directories: - node_modules
Lalu:
# 1 Mendaftar di
Heroku .
# 2 Buat aplikasi baru:
# 3 Heroku memungkinkan Anda untuk menghubungkan sumber daya tambahan ke aplikasi, misalnya, database PostreSQL. Untuk melakukan ini, lakukan:
Aplikasi -> Sumber Daya -> Pengaya -> Heroku Postgres :
# 4 Pilih paket:
# 5 Sekarang Anda dapat melihat sumber daya yang terhubung:
# 6 Lihatlah kredensial, mereka akan diperlukan untuk mengatur variabel lingkungan:
Pengaturan -> Lihat Kredensial :
# 7 Atur variabel lingkungan:
Aplikasi -> Pengaturan -> Reveal Config Vars :
# 8 Atur variabel lingkungan untuk koneksi dalam format berikut:
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 Buat semua tabel yang diperlukan di database baru.
# 10 File application.properties, masing-masing, akan terlihat seperti ini:
properti aplikasi 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 Buat
pipa baru -
Buat pipa baru :
# 12 Metode penyebaran -
GitHub (klik
Sambungkan ke GitHub dan ikuti instruksi di jendela baru).
# 13 Aktifkan Penyebaran Otomatis :
Aktifkan Penyebaran Otomatis # 14 Deploy Manual - Klik
Deploy Branch untuk penerapan pertama. Tepat di browser Anda akan melihat output dari baris perintah.
# 15 Klik
View setelah bangunan berhasil untuk membuka aplikasi yang digunakan:
Perlindungan Bot (reCAPTCHA)
Langkah pertama untuk mengaktifkan verifikasi reCAPTCHA di aplikasi kami adalah membuat reCAPTCH baru di
panel admin Google . Di sana kami membuat situs baru (Tambah situs baru / Buat) dan atur pengaturan berikut:
Di bagian
Domain , Anda harus menentukan selain alamat tempat aplikasi akan hidup, Anda harus menentukan
localhost
, sehingga selama debugging Anda menghindari masalah dalam bentuk ketidakmampuan untuk masuk ke aplikasi Anda.
BackendSimpan
kunci situs dan
kunci rahasia ...
kunci situs / kunci rahasia ... lalu untuk menetapkan mereka ke variabel lingkungan, dan nama variabel, pada gilirannya, untuk menetapkan ke properti
application.properties baru:
google.recaptcha.key.site=${GOOGLE_RECAPTCHA_KEY_SITE} google.recaptcha.key.secret=${GOOGLE_RECAPTCHA_KEY_SECRET}
Tambahkan dependensi baru di
pom.xml untuk verifikasi di sisi Google token reCAPTCHA yang akan dikirimkan klien kepada kami:
<dependency> <groupId>com.mashape.unirest</groupId> <artifactId>unirest-java</artifactId> <version>1.4.9</version> </dependency>
Sekarang adalah waktu untuk memperbarui entitas yang kami gunakan untuk mengotorisasi dan mendaftarkan pengguna dengan menambahkan bidang string ke mereka untuk token reCAPTCHA yang sama:
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 } }
Tambahkan layanan kecil yang akan menyiarkan token reCAPTCHA ke layanan Google khusus dan informasikan sebagai tanggapan apakah token tersebut lolos verifikasi:
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) } }
Layanan ini harus digunakan dalam pengontrol registrasi dan otorisasi pengguna:
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...
FrontendLangkah pertama adalah menginstal dan menyimpan paket
reCAPTHA
:
$ npm install --save vue-recaptcha
Kemudian sambungkan ke skrip di
index.html :
<script src="https://www.google.com/recaptcha/api.js onload=vueRecaptchaApiLoaded&render=explicit" async defer></script>
Tambahkan captcha ke ruang kosong di halaman:
<vue-recaptcha ref="recaptcha" size="invisible" :sitekey="sitekey" @verify="onCapthcaVerified" @expired="onCaptchaExpired" />
Dan tombol untuk tindakan target (otorisasi atau registrasi) sekarang akan memanggil metode validasi:
<b-button v-on:click="validateCaptcha" variant="primary">Login</b-button>
Tambahkan ketergantungan ke komponen:
import VueRecaptcha from 'vue-recaptcha'
Edit
ekspor standar :
components: { VueRecaptcha }, β¦ data() { β¦ siteKey: <i> </i> β¦ }
Dan tambahkan metode baru:
validateCaptcha()
- yang dipanggil dengan mengklik tombolonCapthcaVerified(recaptchaToken) onCaptchaExpired()
- yang captcha sendiri memanggil
Metode baru 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() }
Pengiriman email
Pertimbangkan kemungkinan pengiriman surat ke aplikasi kami melalui server surat publik, seperti Google atau Mail.ru.
Langkah pertama, masing-masing, adalah membuat akun di server surat yang dipilih, jika belum.
Langkah kedua kita perlu menambahkan dependensi berikut di
pom.xml :
Ketergantungan <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>
Anda juga perlu menambahkan properti baru ke
application.properties :
Properti 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
Anda dapat menentukan pengaturan SMTP di sini:
Google dan
Mail.ruBuat antarmuka tempat kami mendeklarasikan beberapa metode:
- Untuk mengirim pesan teks biasa
- Untuk mengirim email HTML
- Untuk mengirim email menggunakan templat
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) }
Sekarang mari kita buat implementasi dari antarmuka ini - layanan pengiriman email:
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() } } }
- Kami menggunakan JavaMailSender yang dikonfigurasi secara otomatis oleh Spring untuk mengirim email
- Mengirim surat biasa sangat sederhana - Anda hanya perlu menambahkan teks ke badan surat dan mengirimkannya
- Email HTML didefinisikan sebagai pesan Mime Type, dan isinya sebagai
text/html
- Untuk memproses template pesan HTML, kami menggunakan Spring Template Engine
Mari kita membuat template sederhana untuk menulis menggunakan kerangka
Thymeleaf dengan menempatkannya di
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>
Elemen perubahan templat (dalam kasus kami, nama penerima dan jalur gambar untuk tanda tangan) dinyatakan menggunakan placeholder.
Sekarang buat atau perbarui pengontrol yang akan mengirim surat:
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) }
Catatan: Untuk memastikan semuanya bekerja, pertama kami akan mengirim surat kepada diri kami sendiri.
Selain itu, untuk memastikan semuanya bekerja, kita dapat membuat antarmuka web sederhana yang hanya akan menarik metode layanan 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>
Catatan: jangan lupa untuk memperbarui
router.js
dan menambahkan tautan ke
App.vue
navigasi
App.vue
jika Anda membuat komponen baru.
Migrasi Gradle
Saya akan mengklarifikasi segera: jika item ini dianggap sebagai peningkatan, biarkan semua orang memutuskan untuk proyeknya sendiri. Kami hanya melihat bagaimana melakukan ini.
Secara umum, Anda dapat menggunakan
Pindah dari Maven ke Gradle dalam waktu kurang dari 5 menit , tetapi hasilnya tidak sesuai dengan harapan. Saya masih merekomendasikan melakukan migrasi secara manual, tidak akan memakan waktu lebih lama.
Hal pertama yang perlu kita lakukan adalah
menginstal Gradle .
Maka kita perlu melakukan prosedur berikut untuk kedua subproyek -
backend
dan
fronted
:
# 1 Hapus file Maven -
pom.xml ,
.mvn .
# 2 Dalam direktori sub proyek, jalankan gradle init dan jawab pertanyaan:
- Pilih jenis proyek untuk menghasilkan: dasar
- Pilih bahasa implementasi: Kotlin
- Pilih skrip bangun DSL: Kotlin (karena kami sedang menulis proyek di Kotlin)
# 3 Hapus
settings.gradle.kts - file ini hanya diperlukan untuk proyek root.
# 4 Jalankan
gradle wrapper
.
Sekarang mari kita beralih ke proyek root kami. Untuk itu, Anda harus mengikuti langkah 1, 2 dan 4 yang dijelaskan di atas untuk sub-proyek - semuanya sama
kecuali menghapus settings.gradle.kts .
Konfigurasi build untuk proyek
backend akan terlihat seperti ini:
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" } }
- Semua plugin Kotlin dan Spring yang diperlukan harus ditentukan.
- Jangan lupa tentang plugin org.jetbrains.kotlin.plugin.jpa - perlu terhubung ke database
- Dalam dependensi, Anda harus menentukan
runtimeOnly(project(":frontend"))
- kita perlu membangun proyek frontend terlebih dahulu
Membangun konfigurasi untuk proyek
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") }
- Dalam contoh saya, plugin
org.siouan.frontend
digunakan untuk membangun proyek - Di bagian
frontend {...}
harus menunjukkan versi Node.js, serta perintah yang menjalankan skrip pembersihan, instalasi dan perakitan yang ditentukan dalam package.json
- Sekarang kita kemas sub proyek frontend kita menjadi file JAR dan menggunakannya sebagai dependensi (
runtimeOnly(project(":frontend"))
di backend ), jadi kita perlu menggambarkan tugas yang menyalin file dari direktori assembly ke / publik dan membuat file jar
Catatan:- Edit
vue.config.js
, ubah direktori build menjadi build / dist . - Dalam file skrip build
package.json
, tentukan build vue-cli-service build
atau pastikan itu ditentukan
File settings.gradle.kts dalam proyek root harus berisi kode berikut: ...
rootProject.name = "demo" include(":frontend", ":backend")
... adalah nama proyek dan sub-proyek.
Dan sekarang kita dapat membangun proyek dengan menjalankan perintah:
./gradlew build
Catatan: jika untuk placeholder ditentukan dalam
application.properties (misalnya,
${SPRING_DATASOURCE_URL}
) tidak ada variabel lingkungan yang sesuai, perakitan akan gagal. Untuk menghindarinya, gunakan
/gradlew build -x
Anda dapat memeriksa struktur proyek menggunakan perintah
gradle -q projects
, hasilnya akan terlihat mirip dengan ini:
Root project 'demo' +--- Project ':backend' \--- Project ':frontend'
Dan akhirnya, untuk menjalankan aplikasi, Anda harus menjalankan
./gradlew bootRun
.
.gitignore
File dan folder berikut harus ditambahkan ke file
.gitignore :
- backend / build /
- frontend / bangun /
- membangun
- .gradle
Penting: Anda tidak harus menambahkan file
gradlew
ke
.gitignore - tidak ada yang berbahaya di dalamnya, tetapi mereka diperlukan untuk perakitan yang sukses di server jauh.
Penempatan di Heroku
Mari kita lihat perubahan yang perlu kita lakukan agar aplikasi dapat digunakan dengan aman ke Heroku.
Procfile # 1Kita perlu bertanya pada Heroku instruksi baru untuk meluncurkan aplikasi:
web: java -Dserver.port=$PORT -jar backend/build/libs/backend-0.0.1-SNAPSHOT.jar
Variabel lingkungan # 2Heroku dapat membuat kembali jenis aplikasi (misalnya, aplikasi Spring Boot) dan mengikuti instruksi perakitan yang sesuai. Tetapi aplikasi kami (proyek root) tidak terlihat seperti aplikasi Spring Boot untuk Heroku. Jika kita meninggalkan semuanya apa adanya, Heroku akan meminta kita untuk menentukan
stage
. Jujur, saya tidak tahu di mana jalan ini berakhir, karena saya tidak mengikutinya. Lebih mudah untuk mendefinisikan variabel
GRADLE_TASK
dengan nilai
build
:
# 3 reCAPTCHASaat menempatkan aplikasi di domain baru, jangan lupa untuk memperbarui captcha, variabel lingkungan
GOOGLE_RECAPTCHA_KEY_SITE
dan
GOOGLE_RECAPTCHA_KEY_SECRET
, serta memperbarui Kunci Situs di subproyek frontend.
Menyimpan Token JWT di Cookie
Pertama-tama, saya sangat menyarankan Anda membaca artikel
Harap Berhenti Menggunakan Penyimpanan Lokal , terutama
Mengapa Penyimpanan Lokal Tidak Aman dan Anda Tidak Harus Menggunakannya untuk Menyimpan bagian
Data Sensitif .
Mari kita lihat bagaimana Anda dapat menyimpan token JWT di tempat yang lebih aman - cookie di bendera
httpOnly
, yang tidak tersedia untuk dibaca / diubah menggunakan JavaScript.
# 1 Menghapus semua logika terkait JWT dari frontend:
Karena token masih tidak dapat diakses dengan JavaScript dengan metode penyimpanan baru, Anda dapat dengan aman menghapus semua referensi dari sub-proyek kami.
Tetapi peran pengguna tanpa referensi ke data lain adalah informasi yang tidak begitu penting, masih dapat disimpan di Penyimpanan Lokal dan ditentukan apakah pengguna berwenang atau tidak, tergantung pada apakah peran ini didefinisikan.
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 });
Berhati-hatilah saat refactoring store/index.js
: jika otorisasi dan deauthorisasi tidak berfungsi dengan benar, pesan kesalahan akan terus-menerus masuk ke konsol.# 2 Kembalikan JWT sebagai cookie di pengontrol otorisasi ( bukan di badan respons ):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) } }
Penting : Silakan dicatat bahwa saya menempatkan pilihan authCookieName
dan isCookieSecure
di application.properties - mengirimkan cookies untuk bendera secure
hanya dapat https, yang membuatnya sangat sulit untuk debug dengan localhost. TETAPI dalam produksi, tentu saja, lebih baik menggunakan cookie dengan bendera ini.Selain itu, disarankan untuk tanggapan controller untuk menggunakan entitas tanpa bidang khusus untuk JWT. Pembaruan# 3JwtAuthTokenFilter
:Kami dulu mengambil token dari header permintaan, sekarang kami mengambilnya dari cookie: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 Mengaktifkan CORSJika dalam artikel saya sebelumnya Anda masih bisa dengan tenang melewati pertanyaan ini, sekarang akan aneh untuk melindungi token JWT tanpa harus mengaktifkan CORS di sisi backend.Anda dapat memperbaikinya dengan mengedit 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() }
Dan sekarang Anda dapat menghapus semua anotasi @CrossOrigin
dari pengontrol.Penting: parameter AllowCredentials diperlukan untuk mengirim permintaan dari frontend. Baca lebih lanjut tentang ini di sini .# 5 Memperbarui tajuk di sisi front-end: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 } })
Periksa
Mari kita coba masuk ke aplikasi dengan masuk dari host yang tidak ada dalam daftar yang diizinkan WebSecurityConfig.kt
. Untuk melakukan ini, jalankan backend di port 8080
, dan frontend, misalnya, aktif 8082
dan coba masuk:Permintaan otorisasi ditolak oleh kebijakan CORS.Sekarang mari kita lihat bagaimana flag cookies bekerja secara umum httpOnly
. Untuk melakukan ini, mari kita pergi, misalnya, ke situs https://kotlinlang.org dan jalankan di konsol browser: document.cookie
Non- httpOnly
cookie yang terkait dengan situs ini akan muncul di konsol , yang, seperti yang kita lihat, dapat diakses melalui JavaScript.Sekarang mari kita masuk ke aplikasi kita, login (sehingga browser menyimpan cookie dengan JWT) dan ulangi hal yang sama:Catatan: metode penyimpanan token JWT ini lebih andal daripada menggunakan Penyimpanan Lokal, tetapi Anda harus memahami bahwa ini bukan obat mujarab.Konfirmasi Pendaftaran Email
Algoritma singkat untuk melakukan tugas ini adalah sebagai berikut:- Untuk semua pengguna baru, atribut
isEnabled
dalam database diatur kefalse
- Token string dihasilkan dari karakter arbitrer, yang akan berfungsi sebagai kunci untuk mengonfirmasi pendaftaran
- Token dikirimkan kepada pengguna melalui pos sebagai bagian dari tautan
- Atribut
isEnabled
disetel ke true jika pengguna mengikuti tautan untuk periode waktu yang ditentukan.
Sekarang pertimbangkan proses ini secara lebih rinci.Kami membutuhkan meja untuk menyimpan token untuk mengonfirmasi pendaftaran: 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;
Dan, karenanya, entitas baru untuk pemetaan objek-relasional ...: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) }
... dan repositori: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> }
Sekarang kita perlu mengimplementasikan alat untuk mengelola token - membuat, memverifikasi dan mengirim melalui email. Untuk melakukan ini, kami memodifikasi UserDetailsServiceImpl
dengan menambahkan metode untuk membuat dan memverifikasi 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 } }
Sekarang tambahkan metode untuk mengirim email dengan tautan konfirmasi ke 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)} }
Catatan:- Saya akan merekomendasikan menyimpan URL host di application.properties
- Di tautan kami, kami meneruskan dua parameter GET (
token
dan confirmRegistration
) ke alamat tempat aplikasi tersebut digunakan. Nanti saya akan jelaskan kenapa.
Kami memodifikasi pengontrol pendaftaran sebagai berikut:- Semua pengguna baru akan menetapkan nilai
false
untuk bidang tersebutisEnabled
- Setelah membuat akun baru, kami akan mengirim email untuk mengonfirmasi pendaftaran
- Buat pengontrol validasi token yang terpisah
- Penting: selama otorisasi kami akan memeriksa apakah akun diverifikasi:
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 {
Sekarang mari kita bekerja di frontend:# 1 Buat komponen RegistrationConfirmPage.vue
# 2 Tambahkan jalur baru router.js
dengan parameter :token
: { path: '/registration-confirm/:token', name: 'RegistrationConfirmPage', component: RegistrationConfirmPage }
Pembaruan # 3SignUp.vue
- setelah berhasil mengirim data dari formulir, kami akan memberi tahu mereka bahwa untuk menyelesaikan pendaftaran, Anda harus mengikuti tautan dalam surat itu.# 4 Penting: sayangnya, kami tidak dapat memberikan tautan tetap ke komponen terpisah yang akan memvalidasi token dan melaporkan keberhasilan atau kegagalan. Tautan dengan garis miring semuanya akan membawa kita ke halaman asli aplikasi. Tetapi kami dapat memberi tahu aplikasi kami tentang perlunya mengonfirmasi pendaftaran menggunakan parameter GET yang dikirimkan 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 Mari kita buat komponen yang melakukan validasi token dan melaporkan hasil validasi:RegistrasiConfirmPage.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>
Alih-alih sebuah kesimpulan
Sebagai kesimpulan dari materi ini, saya ingin melakukan penyimpangan dan mengatakan bahwa konsep aplikasi yang dibahas dalam artikel ini dan sebelumnya tidak baru pada saat penulisan. Tugas dengan cepat membuat tumpukan penuh aplikasi pada musim semi Boot menggunakan yang modern JavaScript-kerangka sudut / Bereaksi / Vue.js elegan memecahkan Hipster .Namun, ide-ide yang dijelaskan dalam artikel ini dapat diimplementasikan bahkan menggunakan JHipster, jadi saya berharap para pembaca yang datang ke tempat ini akan menemukan bahan ini berguna bahkan sebagai bahan pertimbangan.Tautan yang bermanfaat