Aplicativo da Web no Kotlin + Spring Boot + Vue.js (complemento)

Boa tarde, queridos habitantes de Habr!

Como o nome indica, este artigo é uma adição ao aplicativo Web escrito anteriormente no Kotlin + Spring Boot + Vue.js , que nos permite melhorar o esqueleto de um aplicativo futuro e facilitar o trabalho com ele.

Antes de começar a história, agradeço a todos que comentaram no artigo anterior.

Conteúdo




Configuração de CI / CD (Heroku)


Considere a implementação de integração e entrega contínuas usando a plataforma PaaS na nuvem Heroku como exemplo.

A primeira coisa que precisamos fazer é colocar o código do aplicativo no repositório no GitHub . Para que não haja nada de supérfluo no repositório, recomendo o seguinte conteúdo do arquivo .gitignore :

.gitignore
*.class # Help # backend/*.md # Package Files # *.jar *.war *.ear # Eclipse # .settings .project .classpath .studio target # NetBeans # backend/nbproject/private/ backend/nbbuild/ backend/dist/ backend/nbdist/ backend/.nb-gradle/ backend/build/ # Apple # .DS_Store # Intellij # .idea *.iml *.log # logback logback.out.xml backend/src/main/resources/public/ backend/target backend/.mvn backend/mvnw frontend/dist/ frontend/node/ frontend/node_modules/ frontend/npm-debug.log frontend/target !.mvn/wrapper/maven-wrapper.jar 


Importante: antes de começar a trabalhar com o Heroku, adicione um arquivo chamado Procfile (sem nenhuma extensão) ao diretório raiz com a linha:
web: java -Dserver.port=$PORT -jar backend/target/backend-0.0.1-SNAPSHOT.jar , em que backend-0.0.1-SNAPSHOT.jar é o nome do arquivo JAR montado. E não se esqueça de confirmar e pressionar .

Nota: você também pode adicionar o arquivo travis.yaml ao diretório raiz para reduzir o tempo de construção e implementação do aplicativo no Heroku:

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


Então:

# 1 Registre-se no Heroku .

# 2 Crie um novo aplicativo:

Crie uma nova aplicação


# 3 O Heroku permite conectar recursos adicionais ao aplicativo, por exemplo, o banco de dados PostreSQL. Para fazer isso, faça: Aplicativo -> Recursos -> Complementos -> Heroku Postgres :

Heroku postgres


# 4 Escolha um plano:

Seleção de plano


# 5 Agora você pode ver o recurso conectado:

Recurso conectado


# 6 Olhe para as credenciais, elas serão necessárias para configurar variáveis ​​de ambiente: Configurações -> Exibir credenciais :

Exibir credenciais


# 7 Defina variáveis ​​de ambiente: Aplicativo -> Configurações -> Reveal Config Vars :

Variáveis ​​de ambiente


# 8 Defina as variáveis ​​de ambiente para a conexão no seguinte formato:

 SPRING_DATASOURCE_URL = jdbc:postgresql://<i>hostname:port</i>/<i>db_name</i> SPRING_DATASOURCE_USERNAME = <i>username</i> SPRING_DATASOURCE_PASSWORD = <i>password</i> 

Como é isso?


# 9 Crie todas as tabelas necessárias no novo banco de dados.

# 10 O arquivo application.properties, respectivamente, deve ser algo como isto:

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 Crie um novo pipeline - Crie um novo pipeline :

Criar novo pipeline


# 12 Método de implantação - GitHub (clique em Conectar ao GitHub e siga as instruções em uma nova janela).

# 13 Ativar implantações automáticas :

Ativar implantações automáticas


# 14 Implantação manual - clique em Implantar ramificação para a primeira implantação. Bem no navegador, você verá a saída da linha de comando.

Implantação manual


# 15 Clique em Exibir após a compilação bem-sucedida para abrir o aplicativo implantado:

Ver



Proteção contra bot (reCAPTCHA)


O primeiro passo para ativar a verificação do reCAPTCHA em nosso aplicativo é criar um novo reCAPTCH no painel de administração do Google . Lá, criamos um novo site (Adicionar novo site / Criar) e definimos as seguintes configurações:

Configurações do ReCAPTCHA


Na seção Domínios , você deve especificar, além do endereço em que o aplicativo irá residir, especificar localhost , para que durante a depuração, evite problemas na forma da incapacidade de efetuar login no seu aplicativo.

Backend

Salve a chave do site e a chave secreta ...

chave do site / chave secreta


... atribuí-los a variáveis ​​de ambiente e os nomes de variáveis, por sua vez, para atribuir a novas propriedades application.properties :

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

Adicione uma nova dependência no pom.xml para verificação no lado do Google dos tokens reCAPTCHA que o cliente enviará para nós:

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

Agora é a hora de atualizar as entidades que usamos para autorizar e registrar usuários adicionando um campo de sequência a eles para o mesmo token reCAPTCHA:

LoginUser.kt
 import com.fasterxml.jackson.annotation.JsonProperty import java.io.Serializable class LoginUser : Serializable { @JsonProperty("username") var username: String? = null @JsonProperty("password") var password: String? = null @JsonProperty("recapctha_token") var recaptchaToken: String? = null constructor() {} constructor(username: String, password: String, recaptchaToken: String) { this.username = username this.password = password this.recaptchaToken = recaptchaToken } companion object { private const val serialVersionUID = -1764970284520387975L } } 


NewUser.kt
 import com.fasterxml.jackson.annotation.JsonProperty import java.io.Serializable class NewUser : Serializable { @JsonProperty("username") var username: String? = null @JsonProperty("firstName") var firstName: String? = null @JsonProperty("lastName") var lastName: String? = null @JsonProperty("email") var email: String? = null @JsonProperty("password") var password: String? = null @JsonProperty("recapctha_token") var recaptchaToken: String? = null constructor() {} constructor(username: String, firstName: String, lastName: String, email: String, password: String, recaptchaToken: String) { this.username = username this.firstName = firstName this.lastName = lastName this.email = email this.password = password this.recaptchaToken = recaptchaToken } companion object { private const val serialVersionUID = -1764970284520387975L } } 


Adicione um pequeno serviço que transmita o token reCAPTCHA para um serviço especial do Google e informe em resposta se o token passou na verificação:

ReCaptchaService.kt
 import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Service import org.springframework.web.client.RestOperations import org.springframework.beans.factory.annotation.Autowired import com.mashape.unirest.http.HttpResponse import com.mashape.unirest.http.JsonNode import com.mashape.unirest.http.Unirest @Service("captchaService") class ReCaptchaService { val BASE_VERIFY_URL: String = "https://www.google.com/recaptcha/api/siteverify" @Autowired private val restTemplate: RestOperations? = null @Value("\${google.recaptcha.key.site}") lateinit var keySite: String @Value("\${google.recaptcha.key.secret}") lateinit var keySecret: String fun validateCaptcha(token: String): Boolean { val url: String = String.format(BASE_VERIFY_URL + "?secret=%s&response=%s", keySecret, token) val jsonResponse: HttpResponse<JsonNode> = Unirest.get(url) .header("accept", "application/json").queryString("apiKey", "123") .asJson() return (jsonResponse.getStatus() == 200) } } 


Este serviço deve ser usado no controlador de registro e autorização do usuário:

AuthController.kt
 import com.kotlinspringvue.backend.service.ReCaptchaService … @Autowired lateinit var captchaService: ReCaptchaService … if (!captchaService.validateCaptcha(loginRequest.recaptchaToken!!)) { return ResponseEntity(ResponseMessage("Validation failed (ReCaptcha v2)"), HttpStatus.BAD_REQUEST) } else [if]... … if (!captchaService.validateCaptcha(newUser.recaptchaToken!!)) { return ResponseEntity(ResponseMessage("Validation failed (ReCaptcha v2)"), HttpStatus.BAD_REQUEST) } else... 


Frontend

A primeira etapa é instalar e salvar o pacote reCAPTHA :

 $ npm install --save vue-recaptcha 

Em seguida, conecte-se ao script em index.html :

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

Adicione qualquer captcha a qualquer espaço livre na página:

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

E o botão da ação de destino (autorização ou registro) agora chamará primeiro o método de validação:

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

Adicione a dependência aos componentes:

 import VueRecaptcha from 'vue-recaptcha' 

Edite o padrão de exportação :

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

E adicione novos métodos:

  • validateCaptcha() - chamado clicando no botão
  • onCapthcaVerified(recaptchaToken) onCaptchaExpired() - que o próprio captcha chama

Novos métodos
 validateCaptcha() { this.$refs.recaptcha.execute() }, onCapthcaVerified(recaptchaToken) { AXIOS.post(`/auth/signin`, {'username': this.$data.username, 'password': this.$data.password, 'recapctha_token': recaptchaToken}) .then(response => { this.$store.dispatch('login', {'token': response.data.accessToken, 'roles': response.data.authorities, 'username': response.data.username}); this.$router.push('/home') }, error => { this.showAlert(error.response.data.message); }) .catch(e => { console.log(e); this.showAlert('Server error. Please, report this error website owners'); }) }, onCaptchaExpired() { this.$refs.recaptcha.reset() } 


Resultado



Envio de e-mail


Considere a possibilidade de enviar cartas para o nosso aplicativo por meio de um servidor de correio público, como Google ou Mail.ru.

A primeira etapa, respectivamente, será criar uma conta no servidor de email selecionado, se ainda não estiver.

A segunda etapa, precisamos adicionar as seguintes dependências no pom.xml :

Dependências
 <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> 


Você também precisa adicionar novas propriedades ao application.properties :

Propriedades 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 


Você pode especificar as configurações de SMTP aqui: Google e Mail.ru

Crie uma interface em que declaramos vários métodos:

  • Para enviar uma mensagem de texto comum
  • Para enviar um email em HTML
  • Para enviar um email usando um modelo

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


Agora vamos criar uma implementação dessa interface - o serviço de envio de 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() } } } 


  • Usamos o JavaMailSender configurado automaticamente pelo Spring para enviar e-mails
  • Enviar cartas regulares é extremamente simples - você só precisa adicionar texto ao corpo da carta e enviá-lo
  • Os emails em HTML são definidos como mensagens do tipo MIME e seu conteúdo como text/html
  • Para processar o modelo de mensagem HTML, usamos o Spring Template Engine

Vamos criar um modelo simples para escrever usando a estrutura Thymeleaf colocando-a em 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> 


Os elementos alterados do modelo (no nosso caso, o nome do destinatário e o caminho da imagem para assinatura) são declarados usando espaços reservados.

Agora crie ou atualize um controlador que enviará cartas:

BackendController.kt
 import com.kotlinspringvue.backend.email.EmailServiceImpl import com.kotlinspringvue.backend.web.response.ResponseMessage import org.springframework.beans.factory.annotation.Value import org.springframework.http.ResponseEntity import org.springframework.http.HttpStatus … @Autowired lateinit var emailService: EmailService @Value("\${spring.mail.username}") lateinit var addressee: String … @GetMapping("/sendSimpleEmail") @PreAuthorize("hasRole('USER')") fun sendSimpleEmail(): ResponseEntity<*> { try { emailService.sendSimpleMessage(addressee, "Simple Email", "Hello! This is simple email") } catch (e: Exception) { return ResponseEntity(ResponseMessage("Error while sending message"), HttpStatus.BAD_REQUEST) } return ResponseEntity(ResponseMessage("Email has been sent"), HttpStatus.OK) } @GetMapping("/sendTemplateEmail") @PreAuthorize("hasRole('USER')") fun sendTemplateEmail(): ResponseEntity<*> { try { var params:MutableMap<String, Any> = mutableMapOf() params["addresseeName"] = addressee params["signatureImage"] = "https://coderlook.com/wp-content/uploads/2019/07/spring-by-pivotal.png" emailService.sendSimpleMessageUsingTemplate(addressee, "Template Email", "emailTemplate", params) } catch (e: Exception) { return ResponseEntity(ResponseMessage("Error while sending message"), HttpStatus.BAD_REQUEST) } return ResponseEntity(ResponseMessage("Email has been sent"), HttpStatus.OK) } @GetMapping("/sendHtmlEmail") @PreAuthorize("hasRole('USER')") fun sendHtmlEmail(): ResponseEntity<*> { try { emailService.sendHtmlMessage(addressee, "HTML Email", "<h1>Hello!</h1><p>This is HTML email</p>") } catch (e: Exception) { return ResponseEntity(ResponseMessage("Error while sending message"), HttpStatus.BAD_REQUEST) } return ResponseEntity(ResponseMessage("Email has been sent"), HttpStatus.OK) } 


Nota: Para garantir que tudo funcione, primeiro enviaremos cartas para nós mesmos.

Além disso, para garantir que tudo funcione, podemos criar uma interface da Web modesta que simplesmente puxará os métodos de serviço da Web:

Email.vue
 <template> <div id="email"> <b-button v-on:click="sendSimpleMessage" variant="primary">Simple Email</b-button><br/> <b-button v-on:click="sendEmailUsingTemplate" variant="primary">Template Email</b-button><br/> <b-button v-on:click="sendHTMLEmail" variant="primary">HTML Email</b-button><br/> </div> </template> <script> import {AXIOS} from './http-common' export default { name: 'EmailPage', data() { return { counter: 0, username: '', header: {'Authorization': 'Bearer ' + this.$store.getters.getToken} } }, methods: { sendSimpleMessage() { AXIOS.get('/sendSimpleEmail', { headers: this.$data.header }) .then(response => { console.log(response); alert("OK"); }) .catch(error => { console.log('ERROR: ' + error.response.data); }) }, sendEmailUsingTemplate() { AXIOS.get('/sendTemplateEmail', { headers: this.$data.header }) .then(response => { console.log(response); alert("OK") }) .catch(error => { console.log('ERROR: ' + error.response.data); }) }, sendHTMLEmail() { AXIOS.get('/sendHtmlEmail', { headers: this.$data.header }) .then(response => { console.log(response); alert("OK") }) .catch(error => { console.log('ERROR: ' + error.response.data); }) } } } </script> <style> #email { margin-left: 38%; margin-top: 50px; } button { width: 150px; } </style> 


Nota: não esqueça de atualizar o router.js e adicione o link à App.vue navegação App.vue se você estiver criando um novo componente.


Migração Gradle


Vou esclarecer imediatamente: se esse item for considerado uma melhoria, deixe todos decidirem por seu próprio projeto. Nós apenas olhamos como fazer isso.

Em geral, você pode usar a instrução Mover para Maven para Gradle em menos de 5 minutos , mas é improvável que o resultado atenda às expectativas. Eu ainda recomendaria fazer a migração manualmente, não vai demorar muito mais.

A primeira coisa que precisamos fazer é instalar o Gradle .

Em seguida, precisamos executar o seguinte procedimento para os dois subprojetos - backend - backend e front- backend :

# 1 Excluir arquivos Maven - pom.xml , .mvn .

# 2 No diretório do subprojeto, execute gradle init e responda às perguntas:

  • Selecione o tipo de projeto a ser gerado: básico
  • Selecione o idioma de implementação: Kotlin
  • Selecione o script de construção DSL: Kotlin (já que estamos escrevendo um projeto no Kotlin)

# 3 Exclua settings.gradle.kts - esse arquivo é necessário apenas para o projeto raiz.

# 4 Execute o gradle wrapper .

Agora vamos ao nosso projeto raiz. Para isso, você precisa seguir as etapas 1, 2 e 4 descritas acima para subprojetos - tudo é o mesmo, exceto a exclusão de settings.gradle.kts .

A configuração de construção para o projeto de back - end será semelhante a esta:

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


  • Todos os plugins Kotlin e Spring necessários devem ser especificados.
  • Não esqueça do plugin org.jetbrains.kotlin.plugin.jpa - é necessário conectar-se ao banco de dados
  • Nas dependências, você deve especificar runtimeOnly(project(":frontend")) - precisamos criar o projeto frontend primeiro

Compile a configuração para o projeto 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") } 


  • No meu exemplo, o plug-in org.siouan.frontend é usado para criar o projeto
  • Na seção de frontend {...} deve indicar a versão do Node.js, bem como os comandos que invocam os scripts de limpeza, instalação e montagem especificados em package.json
  • Agora empacotamos nosso subprojeto de front-end em um arquivo JAR e o usamos como uma dependência ( runtimeOnly(project(":frontend")) no back - end ), por isso precisamos descrever uma tarefa que copia os arquivos do diretório de assembly para / public e cria um arquivo jar

Nota:

  • Edite vue.config.js , alterando o diretório build para build / dist .
  • No arquivo de script de construção package.json , especifique a construção vue-cli-service build ou verifique se ela está especificada

O arquivo settings.gradle.kts no projeto raiz deve conter o seguinte código: ...

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

... é o nome do projeto e subprojetos.

E agora podemos construir o projeto executando o comando: ./gradlew build

Nota: se para espaços reservados especificados em application.properties (por exemplo, ${SPRING_DATASOURCE_URL} ) não houver variáveis ​​de ambiente correspondentes, o assembly falhará. Para evitar isso, use /gradlew build -x

Você pode verificar a estrutura dos projetos usando o comando gradle -q projects , o resultado deve ficar assim:

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

E, finalmente, para executar o aplicativo, você deve executar ./gradlew bootRun .

.gitignore


Os seguintes arquivos e pastas devem ser adicionados ao arquivo .gitignore :

  • back-end / compilação /
  • frontend / construção /
  • construir
  • .gradle

Importante: você não deve adicionar arquivos gradlew ao .gitignore - não há nada perigoso neles, mas eles são necessários para uma montagem bem-sucedida em um servidor remoto.

Implantação no Heroku


Vejamos as alterações que precisamos fazer para que o aplicativo seja implantado com segurança no Heroku.

# 1 Procfile

Precisamos pedir novas instruções ao Heroku para iniciar o aplicativo:

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

# 2 variáveis ​​de ambiente

O Heroku pode refazer o tipo de aplicativo (por exemplo, o aplicativo Spring Boot) e seguir as instruções de montagem apropriadas. Mas nosso aplicativo (o projeto raiz) não se parece com um aplicativo Spring Boot para o Heroku. Se deixarmos tudo como está, Heroku nos pedirá para definir o stage . Honestamente, não sei onde esse caminho termina, porque não o segui. É mais fácil definir uma variável GRADLE_TASK com um valor de build :



# 3 reCAPTCHA

Ao colocar o aplicativo em um novo domínio, não esqueça de atualizar o captcha, as variáveis ​​de ambiente GOOGLE_RECAPTCHA_KEY_SITE e GOOGLE_RECAPTCHA_KEY_SECRET , além de atualizar a Chave do Site no subprojeto frontend.


Armazenando token JWT em cookies


Antes de tudo, recomendo fortemente que você leia o artigo Por favor, pare de usar o armazenamento local , especialmente a seção Por que o armazenamento local é inseguro e você não deve usá-lo para armazenar dados confidenciais.

Vejamos como você pode armazenar o token JWT em um local mais seguro - cookies no sinalizador httpOnly , onde ficará indisponível para leitura / alteração usando JavaScript.

# 1 Removendo toda a lógica relacionada ao JWT do front-end:
Como o token ainda não está acessível com JavaScript com o novo método de armazenamento, você pode remover com segurança todas as referências a ele do nosso subprojeto.

Mas o papel do usuário sem referência a outros dados não é uma informação tão importante, ele ainda pode ser armazenado no Armazenamento Local e determinado se o usuário está autorizado ou não, dependendo se essa função está definida.

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


store/index.js : , .

#2 JWT cookie ( ):

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


: , authCookieName isCookieSecure application.propertiessecure https, localhost. , , .

JWT.

#3 JwtAuthTokenFilter :
, :

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 CORS
, JWT-, CORS .

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


@CrossOrigin .

: AllowCredentials . .

#5 :

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



, , WebSecurityConfig.kt . 8080 , , , 8082 :



CORS.

, httpOnly . , , https://kotlinlang.org :

 document.cookie 



- httpOnly , , , , JavaScript.

, ( JWT) :



: JWT- , Local Storage, , .



:

  1. isEnabled false
  2. ,
  3. isEnabled true ,

.

:

 CREATE TABLE public.verification_token ( id serial NOT NULL, token character varying, expiry_date timestamp without time zone, user_id integer, PRIMARY KEY (id) ); ALTER TABLE public.verification_token ADD CONSTRAINT verification_token_users_fk FOREIGN KEY (user_id) REFERENCES public.users (id) MATCH SIMPLE ON UPDATE CASCADE ON DELETE CASCADE; 

, , - …:

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


… :

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


— , . UserDetailsServiceImpl , :

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


EmailServiceImpl :

EmailServiceImpl.kt
  @Value("\${host.url}") lateinit var hostUrl: String @Autowired lateinit var userDetailsService: UserDetailsServiceImpl ... override fun sendRegistrationConfirmationEmail(user: User) { val token = UUID.randomUUID().toString() userDetailsService.createVerificationTokenForUser(token, user) val link = "$hostUrl/?token=$token&confirmRegistration=true" val msg = "<p>Please, follow the link to complete your registration:</p><p><a href=\"$link\">$link</a></p>" user.email?.let{sendHtmlMessage(user.email!!, "KSVG APP: Registration Confirmation", msg)} } 


Nota:

  • URL application.properties
  • GET- ( token confirmRegistration ) , . , .

:

  • Todos os novos usuários definirão o valor falsepara o campoisEnabled
  • Depois de criar uma nova conta, enviaremos um email para confirmar o registro
  • Crie um controlador de validação de token separado
  • Importante: durante a autorização, verificaremos se a conta foi verificada:

AuthController.kt
 package com.kotlinspringvue.backend.controller import com.kotlinspringvue.backend.email.EmailService import javax.validation.Valid import java.util.* import java.util.stream.Collectors import org.springframework.beans.factory.annotation.Autowired import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity import org.springframework.security.authentication.AuthenticationManager import org.springframework.security.authentication.UsernamePasswordAuthenticationToken import org.springframework.security.core.context.SecurityContextHolder import org.springframework.security.crypto.password.PasswordEncoder import org.springframework.security.core.GrantedAuthority import org.springframework.security.core.authority.SimpleGrantedAuthority import org.springframework.ui.Model import com.kotlinspringvue.backend.model.LoginUser import com.kotlinspringvue.backend.model.NewUser import com.kotlinspringvue.backend.web.response.SuccessfulSigninResponse import com.kotlinspringvue.backend.web.response.ResponseMessage import com.kotlinspringvue.backend.jpa.User import com.kotlinspringvue.backend.repository.UserRepository import com.kotlinspringvue.backend.repository.RoleRepository import com.kotlinspringvue.backend.jwt.JwtProvider import com.kotlinspringvue.backend.service.ReCaptchaService import com.kotlinspringvue.backend.service.UserDetailsService import org.springframework.beans.factory.annotation.Value import org.springframework.context.ApplicationEventPublisher import org.springframework.web.bind.annotation.* import org.springframework.web.context.request.WebRequest import java.io.UnsupportedEncodingException import javax.servlet.http.Cookie import javax.servlet.http.HttpServletRequest import javax.servlet.http.HttpServletResponse import com.kotlinspringvue.backend.service.UserDetailsServiceImpl.Companion.TOKEN_VALID import com.kotlinspringvue.backend.service.UserDetailsServiceImpl.Companion.TOKEN_INVALID import com.kotlinspringvue.backend.service.UserDetailsServiceImpl.Companion.TOKEN_EXPIRED @RestController @RequestMapping("/api/auth") class AuthController() { @Value("\${ksvg.app.authCookieName}") lateinit var authCookieName: String @Value("\${ksvg.app.isCookieSecure}") var isCookieSecure: Boolean = true @Autowired lateinit var authenticationManager: AuthenticationManager @Autowired lateinit var userRepository: UserRepository @Autowired lateinit var roleRepository: RoleRepository @Autowired lateinit var encoder: PasswordEncoder @Autowired lateinit var jwtProvider: JwtProvider @Autowired lateinit var captchaService: ReCaptchaService @Autowired lateinit var userService: UserDetailsService @Autowired lateinit var emailService: EmailService @PostMapping("/signin") fun authenticateUser(@Valid @RequestBody loginRequest: LoginUser, response: HttpServletResponse): ResponseEntity<*> { val userCandidate: Optional <User> = userRepository.findByUsername(loginRequest.username!!) if (!captchaService.validateCaptcha(loginRequest.recaptchaToken!!)) { return ResponseEntity(ResponseMessage("Validation failed (ReCaptcha v2)"), HttpStatus.BAD_REQUEST) } else if (userCandidate.isPresent) { val user: User = userCandidate.get() if (!user.enabled) { return ResponseEntity(ResponseMessage("Account is not verified yet! Please, follow the link in the confirmation email."), HttpStatus.UNAUTHORIZED) } val authentication = authenticationManager.authenticate( UsernamePasswordAuthenticationToken(loginRequest.username, loginRequest.password)) SecurityContextHolder.getContext().setAuthentication(authentication) val jwt: String = jwtProvider.generateJwtToken(user.username!!) val cookie: Cookie = Cookie(authCookieName, jwt) cookie.maxAge = jwtProvider.jwtExpiration!! cookie.secure = isCookieSecure cookie.isHttpOnly = true cookie.path = "/" response.addCookie(cookie) val authorities: List<GrantedAuthority> = user.roles!!.stream().map({ role -> SimpleGrantedAuthority(role.name)}).collect(Collectors.toList<GrantedAuthority>()) return ResponseEntity.ok(SuccessfulSigninResponse(user.username, authorities)) } else { return ResponseEntity(ResponseMessage("User not found!"), HttpStatus.BAD_REQUEST) } } @PostMapping("/signup") fun registerUser(@Valid @RequestBody newUser: NewUser): ResponseEntity<*> { val userCandidate: Optional <User> = userRepository.findByUsername(newUser.username!!) if (!captchaService.validateCaptcha(newUser.recaptchaToken!!)) { return ResponseEntity(ResponseMessage("Validation failed (ReCaptcha v2)"), HttpStatus.BAD_REQUEST) } else if (!userCandidate.isPresent) { if (usernameExists(newUser.username!!)) { return ResponseEntity(ResponseMessage("Username is already taken!"), HttpStatus.BAD_REQUEST) } else if (emailExists(newUser.email!!)) { return ResponseEntity(ResponseMessage("Email is already in use!"), HttpStatus.BAD_REQUEST) } try { // Creating user's account val user = User( 0, newUser.username!!, newUser.firstName!!, newUser.lastName!!, newUser.email!!, encoder.encode(newUser.password), false ) user.roles = Arrays.asList(roleRepository.findByName("ROLE_USER")) val registeredUser = userRepository.save(user) emailService.sendRegistrationConfirmationEmail(registeredUser) } catch (e: Exception) { return ResponseEntity(ResponseMessage("Server error. Please, contact site owner"), HttpStatus.SERVICE_UNAVAILABLE) } return ResponseEntity(ResponseMessage("Please, follow the link in the confirmation email to complete the registration."), HttpStatus.OK) } else { return ResponseEntity(ResponseMessage("User already exists!"), HttpStatus.BAD_REQUEST) } } @PostMapping("/registrationConfirm") @CrossOrigin(origins = ["*"]) @Throws(UnsupportedEncodingException::class) fun confirmRegistration(request: HttpServletRequest, model: Model, @RequestParam("token") token: String): ResponseEntity<*> { when(userService.validateVerificationToken(token)) { TOKEN_VALID -> return ResponseEntity.ok(ResponseMessage("Registration confirmed")) TOKEN_INVALID -> return ResponseEntity(ResponseMessage("Token is invalid!"), HttpStatus.BAD_REQUEST) TOKEN_EXPIRED -> return ResponseEntity(ResponseMessage("Token is invalid!"), HttpStatus.UNAUTHORIZED) } return ResponseEntity(ResponseMessage("Server error. Please, contact site owner"), HttpStatus.SERVICE_UNAVAILABLE) } @PostMapping("/logout") fun logout(response: HttpServletResponse): ResponseEntity<*> { val cookie: Cookie = Cookie(authCookieName, null) cookie.maxAge = 0 cookie.secure = isCookieSecure cookie.isHttpOnly = true cookie.path = "/" response.addCookie(cookie) return ResponseEntity.ok(ResponseMessage("Successfully logged")) } private fun emailExists(email: String): Boolean { return userRepository.findByUsername(email).isPresent } private fun usernameExists(username: String): Boolean { return userRepository.findByUsername(username).isPresent } } 


Agora vamos trabalhar no frontend:

# 1 Criar componente RegistrationConfirmPage.vue

# 2 Adicione um novo caminho router.jscom o parâmetro :token:

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

#3 SignUp.vue — , .

#4 : , , . . GET- 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 , :

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> 




Em vez de uma conclusão


, , , . full stack Spring Boot JavaScript- Angular/React/Vue.js Hipster .

, , JHipster, , , , , .



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


All Articles