Добрый день, дорогие обитатели Хабра!
Не так давно мне представилась возможность реализовать небольшой проект без особых требований по технической части. То есть, я был волен выбирать стек технологий на своё усмотрение. Потому не преминул возможностью как следует «пощупать» модные, молодёжные многообещающие, но малознакомые мне на практике Kotlin и Vue.js, добавив туда уже знакомый Spring Boot и примерив всё это на незамысловатое веб-приложение.
Приступив, я опрометчиво полагал, что в Интернете найдётся множество статей и руководств на эту тему. Материалов действительно достаточно, и все они хороши, но только до первого REST-контроллера. Затем начинаются трудности противоречия. А ведь даже в простом приложении хотелось бы иметь более сложную логику, чем отрисовка на странице текста, возвращаемого сервером.
Кое-как разобравшись, я решил написать собственное руководство, которое, надеюсь, будет кому-нибудь полезно.
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.kotlin-spring-vue</groupId>
<artifactId>demo</artifactId>
<version>1.0-SNAPSHOT</version>
</parent>
<groupId>com.kotlin-spring-vue</groupId>
<artifactId>backend</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>backend</name>
<description>Backend module for Kotlin + Spring Boot + Vue.js</description>
<properties>
<java.version>1.8</java.version>
<kotlin.version>1.2.71</kotlin.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<rest-assured.version>3.3.0</rest-assured.version>
<start-class>com.kotlinspringvue.backend.BackendApplicationKt</start-class>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.module</groupId>
<artifactId>jackson-module-kotlin</artifactId>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-reflect</artifactId>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-stdlib-jdk8</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<sourceDirectory>${project.basedir}/src/main/kotlin</sourceDirectory>
<testSourceDirectory>${project.basedir}/src/test/kotlin</testSourceDirectory>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<mainClass>com.kotlinspringvue.backend.BackendApplicationKt</mainClass>
</configuration>
</plugin>
<plugin>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-plugin</artifactId>
<configuration>
<args>
<arg>-Xjsr305=strict</arg>
</args>
<compilerPlugins>
<plugin>spring</plugin>
</compilerPlugins>
</configuration>
<dependencies>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-allopen</artifactId>
<version>${kotlin.version}</version>
</dependency>
</dependencies>
</plugin>
<plugin>
<artifactId>maven-resources-plugin</artifactId>
<executions>
<execution>
<id>copy Vue.js frontend content</id>
<phase>generate-resources</phase>
<goals>
<goal>copy-resources</goal>
</goals>
<configuration>
<outputDirectory>src/main/resources/public</outputDirectory>
<overwrite>true</overwrite>
<resources>
<resource>
<directory>${project.parent.basedir}/frontend/target/dist</directory>
<includes>
<include>static/</include>
<include>index.html</include>
<include>favicon.ico</include>
</includes>
</resource>
</resources>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
$ vue create frontend
module.exports = {
outputDir: 'target/dist',
assetsDir: 'static'
}
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<artifactId>frontend</artifactId>
<parent>
<groupId>com.kotlin-spring-vue</groupId>
<artifactId>demo</artifactId>
<version>0.0.1-SNAPSHOT</version>
</parent>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<java.version>1.8</java.version>
<frontend-maven-plugin.version>1.6</frontend-maven-plugin.version>
</properties>
<build>
<plugins>
<plugin>
<groupId>com.github.eirslett</groupId>
<artifactId>frontend-maven-plugin</artifactId>
<version>${frontend-maven-plugin.version}</version>
<executions>
<!-- Install our node and npm version to run npm/node scripts-->
<execution>
<id>install node and npm</id>
<goals>
<goal>install-node-and-npm</goal>
</goals>
<configuration>
<nodeVersion>v11.8.0</nodeVersion>
</configuration>
</execution>
<!-- Install all project dependencies -->
<execution>
<id>npm install</id>
<goals>
<goal>npm</goal>
</goals>
<!-- optional: default phase is "generate-resources" -->
<phase>generate-resources</phase>
<!-- Optional configuration which provides for running any npm command -->
<configuration>
<arguments>install</arguments>
</configuration>
</execution>
<!-- Build and minify static files -->
<execution>
<id>npm run build</id>
<goals>
<goal>npm</goal>
</goals>
<configuration>
<arguments>run build</arguments>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.kotlin-spring-vue</groupId>
<artifactId>demo</artifactId>
<packaging>pom</packaging>
<version>1.0-SNAPSHOT</version>
<modules>
<module>backend</module>
<module>frontend</module>
</modules>
<name>kotlin-spring-vue</name>
<description>Kotlin + Spring Boot + Vue.js</description>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.1.3.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<properties>
<main.basedir>${project.basedir}</main.basedir>
<!-- Analysis Tools for CI -->
<build-plugin.jacoco.version>0.8.2</build-plugin.jacoco.version>
<build-plugin.coveralls.version>4.3.0</build-plugin.coveralls.version>
<kotlin.version>1.2.71</kotlin.version>
</properties>
<dependencies>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-stdlib-jdk8</artifactId>
<version>${kotlin.version}</version>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-test</artifactId>
<version>${kotlin.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>${build-plugin.jacoco.version}</version>
<executions>
<!-- Prepares the property pointing to the JaCoCo
runtime agent which is passed as VM argument when Maven the Surefire plugin
is executed. -->
<execution>
<id>pre-unit-test</id>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<!-- Ensures that the code coverage report for
unit tests is created after unit tests have been run. -->
<execution>
<id>post-unit-test</id>
<phase>test</phase>
<goals>
<goal>report</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.eluder.coveralls</groupId>
<artifactId>coveralls-maven-plugin</artifactId>
<version>${build-plugin.coveralls.version}</version>
</plugin>
<plugin>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-plugin</artifactId>
<version>${kotlin.version}</version>
<executions>
<execution>
<id>compile</id>
<phase>compile</phase>
<goals>
<goal>compile</goal>
</goals>
</execution>
<execution>
<id>test-compile</id>
<phase>test-compile</phase>
<goals>
<goal>test-compile</goal>
</goals>
</execution>
</executions>
<configuration>
<jvmTarget>1.8</jvmTarget>
</configuration>
</plugin>
</plugins>
</build>
</project>
$ mvn install
$ mvn --project backend spring-boot:run
data class Greeting(val id: Long, val content: String)
import org.springframework.web.bind.annotation.*
import com.kotlinspringvue.backend.model.Greeting
import java.util.concurrent.atomic.AtomicLong
@RestController
@RequestMapping("/api")
class BackendController() {
val counter = AtomicLong()
@GetMapping("/greeting")
fun greeting(@RequestParam(value = "name", defaultValue = "World") name: String) =
Greeting(counter.incrementAndGet(), "Hello, $name")
}
{"id":1,"content":"Hello, Vadim"}
{"id":2,"content":"Hello, Vadim"}
$ npm install --save vue-router
import Vue from 'vue'
import Router from 'vue-router'
import HelloWorld from '@/components/HelloWorld'
import Greeting from '@/components/Greeting'
Vue.use(Router)
export default new Router({
mode: 'history',
routes: [
{
path: '/',
name: 'Greeting',
component: Greeting
},
{
path: '/hello-world',
name: 'HelloWorld',
component: HelloWorld
}
]
})
import Vue from 'vue'
import App from './App.vue'
import router from './router'
Vue.config.productionTip = false
new Vue({
router,
render: h => h(App),
}).$mount('#app')
<template>
<div id="app">
<router-view></router-view>
</div>
</template>
<script>
export default {
name: 'app'
}
</script>
<style>
</style>
$ npm install --save axios
import axios from 'axios'
export const AXIOS = axios.create({
baseURL: `/api`
})
"rules": {
"no-console": "off"
}
import {AXIOS} from './http-common'
<template>
<div id="greeting">
<h3>Greeting component</h3>
<p>Counter: {{ counter }}</p>
<p>Username: {{ username }}</p>
</div>
</template>
export default {
name: 'Greeting',
data() {
return {
counter: 0,
username: ''
}
},
methods: {
loadGreeting() {
AXIOS.get('/greeting', { params: { name: 'Vadim' } })
.then(response => {
this.$data.counter = response.data.id;
this.$data.username = response.data.content;
})
.catch(error => {
console.log('ERROR: ' + error.response.data);
})
}
},
mounted() {
this.loadGreeting();
}
}
loadGreeting()
) вызывается сразу после загрузки страницы (mounted())CREATE TABLE public."person"
(
id serial NOT NULL,
name character varying,
PRIMARY KEY (id)
);
INSERT INTO person (name) VALUES ('John'), ('Griselda'), ('Bobby');
<properties>
...
<postgresql.version>42.2.5</postgresql.version>
...
</properties>
...
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>${postgresql.version}</version>
</dependency>
...
<plugin>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-plugin</artifactId>
<configuration>
<args>
<arg>-Xjsr305=strict</arg>
</args>
<compilerPlugins>
<plugin>spring</plugin>
<plugin>jpa</plugin>
</compilerPlugins>
</configuration>
...
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-noarg</artifactId>
<version>${kotlin.version}</version>
</dependency>
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
import javax.persistence.Column
import javax.persistence.Entity
import javax.persistence.GeneratedValue
import javax.persistence.GenerationType
import javax.persistence.Id
import javax.persistence.Table
@Entity
@Table (name="person")
data class Person(
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
val id: Long,
@Column(nullable = false)
val name: String
)
import com.kotlinspringvue.backend.jpa.Person
import org.springframework.stereotype.Repository
import org.springframework.data.repository.CrudRepository
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.repository.query.Param
@Repository
interface PersonRepository: CrudRepository<Person, Long> {}
findAll()
, который нет необходимости переопределять, поэтому оставим тело пустым.
import com.kotlinspringvue.backend.repository.PersonRepository
import org.springframework.beans.factory.annotation.Autowired
…
@Autowired
lateinit var personRepository: PersonRepository
…
@GetMapping("/persons")
fun getPersons() = personRepository.findAll()
[{"id":1,"name":"John"},{"id":2,"name":"Griselda"},{"id":3,"name":"Bobby"}]
CREATE TABLE public.users
(
id serial NOT NULL,
username character varying,
first_name character varying,
last_name character varying,
email character varying,
password character varying,
enabled boolean,
PRIMARY KEY (id)
);
CREATE TABLE public.roles
(
id serial NOT NULL,
name character varying,
PRIMARY KEY (id)
);
CREATE TABLE public.users_roles
(
id serial NOT NULL,
user_id integer,
role_id integer,
PRIMARY KEY (id)
);
ALTER TABLE public.users_roles
ADD CONSTRAINT users_roles_users_fk FOREIGN KEY (user_id)
REFERENCES public.users (id) MATCH SIMPLE
ON UPDATE CASCADE
ON DELETE CASCADE;
ALTER TABLE public.users_roles
ADD CONSTRAINT users_roles_roles_fk FOREIGN KEY (role_id)
REFERENCES public.roles (id) MATCH SIMPLE
ON UPDATE CASCADE
ON DELETE CASCADE;
INSERT INTO roles (name) VALUES ('ROLE_USER'), ('ROLE_ADMIN');
import javax.persistence.*
@Entity
@Table(name = "users")
data class User (
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
val id: Long? = 0,
@Column(name="username")
var username: String?=null,
@Column(name="first_name")
var firstName: String?=null,
@Column(name="last_name")
var lastName: String?=null,
@Column(name="email")
var email: String?=null,
@Column(name="password")
var password: String?=null,
@Column(name="enabled")
var enabled: Boolean = false,
@ManyToMany(fetch = FetchType.EAGER)
@JoinTable(
name = "users_roles",
joinColumns = [JoinColumn(name = "user_id", referencedColumnName = "id")],
inverseJoinColumns = [JoinColumn(name = "role_id", referencedColumnName = "id")]
)
var roles: Collection<Role>? = null
)
import javax.persistence.*
@Entity
@Table(name = "roles")
data class Role (
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
val id: Long,
@Column(name="name")
val name: String
)
import java.util.Optional
import com.kotlinspringvue.backend.jpa.User
import org.springframework.data.repository.CrudRepository
import org.springframework.data.repository.query.Param
import org.springframework.data.jpa.repository.JpaRepository
import javax.transaction.Transactional
interface UserRepository: JpaRepository<User, Long> {
fun existsByUsername(@Param("username") username: String): Boolean
fun findByUsername(@Param("username") username: String): Optional<User>
fun findByEmail(@Param("email") email: String): Optional<User>
@Transactional
fun deleteByUsername(@Param("username") username: String)
}
import com.kotlinspringvue.backend.jpa.Role
import org.springframework.data.repository.CrudRepository
import org.springframework.data.repository.query.Param
import org.springframework.data.jpa.repository.JpaRepository
interface RoleRepository : JpaRepository<Role, Long> {
fun findByName(@Param("name") name: String): Role
}
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.module</groupId>
<artifactId>jackson-module-kotlin</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.0</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.10.6</version>
</dependency>
assm.app.jwtSecret=jwtAssmSecretKey
assm.app.jwtExpiration=86400
class LoginUser : Serializable {
@JsonProperty("username")
var username: String? = null
@JsonProperty("password")
var password: String? = null
constructor() {}
constructor(username: String, password: String) {
this.username = username
this.password = password
}
companion object {
private const val serialVersionUID = -1764970284520387975L
}
}
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
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
}
companion object {
private const val serialVersionUID = -1764970284520387975L
}
}
import org.springframework.security.core.GrantedAuthority
class JwtResponse(var accessToken: String?, var username: String?, val authorities:
Collection<GrantedAuthority>) {
var type = "Bearer"
}
class ResponseMessage(var message: String?)
class UserAlreadyExistException : RuntimeException {
constructor() : super() {}
constructor(message: String, cause: Throwable) : super(message, cause) {}
constructor(message: String) : super(message) {}
constructor(cause: Throwable) : super(cause) {}
companion object {
private val serialVersionUID = 5861310537366287163L
}
}
import com.kotlinspringvue.backend.repository.UserRepository
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.security.core.userdetails.UserDetails
import org.springframework.security.core.userdetails.UserDetailsService
import org.springframework.security.core.userdetails.UsernameNotFoundException
import org.springframework.stereotype.Service
import org.springframework.security.core.GrantedAuthority
import org.springframework.security.core.authority.SimpleGrantedAuthority
import java.util.stream.Collectors
@Service
class UserDetailsServiceImpl: UserDetailsService {
@Autowired
lateinit var userRepository: UserRepository
@Throws(UsernameNotFoundException::class)
override fun loadUserByUsername(username: String): UserDetails {
val user = userRepository.findByUsername(username).get()
?: throw UsernameNotFoundException("User '$username' not found")
val authorities: List<GrantedAuthority> = user.roles!!.stream().map({ role -> SimpleGrantedAuthority(role.name)}).collect(Collectors.toList<GrantedAuthority>())
return org.springframework.security.core.userdetails.User
.withUsername(username)
.password(user.password)
.authorities(authorities)
.accountExpired(false)
.accountLocked(false)
.credentialsExpired(false)
.disabled(false)
.build()
}
}
import javax.servlet.ServletException
import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import org.springframework.security.core.AuthenticationException
import org.springframework.security.web.AuthenticationEntryPoint
import org.springframework.stereotype.Component
@Component
class JwtAuthEntryPoint : AuthenticationEntryPoint {
@Throws(IOException::class, ServletException::class)
override fun commence(request: HttpServletRequest,
response: HttpServletResponse,
e: AuthenticationException) {
logger.error("Unauthorized error. Message - {}", e!!.message)
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid credentials")
}
companion object {
private val logger = LoggerFactory.getLogger(JwtAuthEntryPoint::class.java)
}
}
import io.jsonwebtoken.*
import org.springframework.beans.factory.annotation.Autowired
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.security.core.Authentication
import org.springframework.stereotype.Component
import org.springframework.security.core.GrantedAuthority
import org.springframework.security.core.authority.SimpleGrantedAuthority
import com.kotlinspringvue.backend.repository.UserRepository
import java.util.Date
@Component
public class JwtProvider {
private val logger: Logger = LoggerFactory.getLogger(JwtProvider::class.java)
@Autowired
lateinit var userRepository: UserRepository
@Value("\${assm.app.jwtSecret}")
lateinit var jwtSecret: String
@Value("\${assm.app.jwtExpiration}")
var jwtExpiration:Int?=0
fun generateJwtToken(username: String): String {
return Jwts.builder()
.setSubject(username)
.setIssuedAt(Date())
.setExpiration(Date((Date()).getTime() + jwtExpiration!! * 1000))
.signWith(SignatureAlgorithm.HS512, jwtSecret)
.compact()
}
fun validateJwtToken(authToken: String): Boolean {
try {
Jwts.parser().setSigningKey(jwtSecret).parseClaimsJws(authToken)
return true
} catch (e: SignatureException) {
logger.error("Invalid JWT signature -> Message: {} ", e)
} catch (e: MalformedJwtException) {
logger.error("Invalid JWT token -> Message: {}", e)
} catch (e: ExpiredJwtException) {
logger.error("Expired JWT token -> Message: {}", e)
} catch (e: UnsupportedJwtException) {
logger.error("Unsupported JWT token -> Message: {}", e)
} catch (e: IllegalArgumentException) {
logger.error("JWT claims string is empty -> Message: {}", e)
}
return false
}
fun getUserNameFromJwtToken(token: String): String {
return Jwts.parser()
.setSigningKey(jwtSecret)
.parseClaimsJws(token)
.getBody().getSubject()
}
}
import java.io.IOException
import javax.servlet.FilterChain
import javax.servlet.ServletException
import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.security.core.userdetails.UserDetails
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource
import org.springframework.web.filter.OncePerRequestFilter
import com.kotlinspringvue.backend.service.UserDetailsServiceImpl
class JwtAuthTokenFilter : OncePerRequestFilter() {
@Autowired
private val tokenProvider: JwtProvider? = null
@Autowired
private val userDetailsService: UserDetailsServiceImpl? = null
@Throws(ServletException::class, IOException::class)
override fun doFilterInternal(request: HttpServletRequest, response: HttpServletResponse, filterChain: FilterChain) {
try {
val jwt = getJwt(request)
if (jwt != null && tokenProvider!!.validateJwtToken(jwt)) {
val username = tokenProvider.getUserNameFromJwtToken(jwt)
val userDetails = userDetailsService!!.loadUserByUsername(username)
val authentication = UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities())
authentication.setDetails(WebAuthenticationDetailsSource().buildDetails(request))
SecurityContextHolder.getContext().setAuthentication(authentication)
}
} catch (e: Exception) {
logger.error("Can NOT set user authentication -> Message: {}", e)
}
filterChain.doFilter(request, response)
}
private fun getJwt(request: HttpServletRequest): String? {
val authHeader = request.getHeader("Authorization")
return if (authHeader != null && authHeader.startsWith("Bearer ")) {
authHeader.replace("Bearer ", "")
} else null
}
companion object {
private val logger = LoggerFactory.getLogger(JwtAuthTokenFilter::class.java)
}
}
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter
import org.springframework.security.config.http.SessionCreationPolicy
import org.springframework.security.authentication.AuthenticationManager
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
import com.kotlinspringvue.backend.jwt.JwtAuthEntryPoint
import com.kotlinspringvue.backend.jwt.JwtAuthTokenFilter
import com.kotlinspringvue.backend.service.UserDetailsServiceImpl
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
class WebSecurityConfig : WebSecurityConfigurerAdapter() {
@Autowired
internal var userDetailsService: UserDetailsServiceImpl? = null
@Autowired
private val unauthorizedHandler: JwtAuthEntryPoint? = null
@Bean
fun bCryptPasswordEncoder(): BCryptPasswordEncoder {
return BCryptPasswordEncoder()
}
@Bean
fun authenticationJwtTokenFilter(): JwtAuthTokenFilter {
return JwtAuthTokenFilter()
}
@Throws(Exception::class)
override fun configure(authenticationManagerBuilder: AuthenticationManagerBuilder) {
authenticationManagerBuilder
.userDetailsService(userDetailsService)
.passwordEncoder(bCryptPasswordEncoder())
}
@Bean
@Throws(Exception::class)
override fun authenticationManagerBean(): AuthenticationManager {
return super.authenticationManagerBean()
}
@Throws(Exception::class)
override protected fun configure(http: HttpSecurity) {
http.csrf().disable().authorizeRequests()
.antMatchers("/**").permitAll()
.anyRequest().authenticated()
.and()
.exceptionHandling().authenticationEntryPoint(unauthorizedHandler).and()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
http.addFilterBefore(authenticationJwtTokenFilter(), UsernamePasswordAuthenticationFilter::class.java)
}
}
import javax.validation.Valid
import java.util.*
import java.util.stream.Collectors
import org.springframework.security.core.Authentication
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.core.userdetails.UserDetails
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.security.core.GrantedAuthority
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.web.bind.annotation.CrossOrigin
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import com.kotlinspringvue.backend.model.LoginUser
import com.kotlinspringvue.backend.model.NewUser
import com.kotlinspringvue.backend.web.response.JwtResponse
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
@CrossOrigin(origins = ["*"], maxAge = 3600)
@RestController
@RequestMapping("/api/auth")
class AuthController() {
@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
@PostMapping("/signin")
fun authenticateUser(@Valid @RequestBody loginRequest: LoginUser): ResponseEntity<*> {
val userCandidate: Optional <User> = userRepository.findByUsername(loginRequest.username!!)
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 authorities: List<GrantedAuthority> = user.roles!!.stream().map({ role -> SimpleGrantedAuthority(role.name)}).collect(Collectors.toList<GrantedAuthority>())
return ResponseEntity.ok(JwtResponse(jwt, 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 (!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)
}
// Creating user's account
val user = User(
0,
newUser.username!!,
newUser.firstName!!,
newUser.lastName!!,
newUser.email!!,
encoder.encode(newUser.password),
true
)
user!!.roles = Arrays.asList(roleRepository.findByName("ROLE_USER"))
userRepository.save(user)
return ResponseEntity(ResponseMessage("User registered successfully!"), HttpStatus.OK)
} else {
return ResponseEntity(ResponseMessage("User already exists!"),
HttpStatus.BAD_REQUEST)
}
}
private fun emailExists(email: String): Boolean {
return userRepository.findByUsername(email).isPresent
}
private fun usernameExists(username: String): Boolean {
return userRepository.findByUsername(username).isPresent
}
}
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.security.core.Authentication
import com.kotlinspringvue.backend.repository.UserRepository
import com.kotlinspringvue.backend.jpa.User
…
@Autowired
lateinit var userRepository: UserRepository
…
@GetMapping("/usercontent")
@PreAuthorize("hasRole('USER') or hasRole('ADMIN')")
@ResponseBody
fun getUserContent(authentication: Authentication): String {
val user: User = userRepository.findByUsername(authentication.name).get()
return "Hello " + user.firstName + " " + user.lastName + "!"
}
@GetMapping("/admincontent")
@PreAuthorize("hasRole('ADMIN')")
@ResponseBody
fun getAdminContent(): String {
return "Admin's content"
}
<template>
<div>
</div>
</template>
<script>
</script>
<style>
</style>
import Vue from 'vue'
import Router from 'vue-router'
import Home from '@/components/Home'
import SignIn from '@/components/SignIn'
import SignUp from '@/components/SignUp'
import AdminPage from '@/components/AdminPage'
import UserPage from '@/components/UserPage'
Vue.use(Router)
export default new Router({
mode: 'history',
routes: [
{
path: '/',
name: 'Home',
component: Home
},
{
path: '/home',
name: 'Home',
component: Home
},
{
path: '/login',
name: 'SignIn',
component: SignIn
},
{
path: '/register',
name: 'SignUp',
component: SignUp
},
{
path: '/user',
name: 'UserPage',
component: UserPage
},
{
path: '/admin',
name: 'AdminPage',
component: AdminPage
}
]
})
$ npm install --save vuex
import Vue from 'vue';
import Vuex from 'vuex';
Vue.use(Vuex);
const state = {
token: localStorage.getItem('user-token') || '',
role: localStorage.getItem('user-role') || '',
username: localStorage.getItem('user-name') || '',
authorities: localStorage.getItem('authorities') || '',
};
const getters = {
isAuthenticated: state => {
if (state.token != null && state.token != '') {
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;
},
getToken: state => {
return state.token;
}
};
const mutations = {
auth_login: (state, user) => {
localStorage.setItem('user-token', user.token);
localStorage.setItem('user-name', user.name);
localStorage.setItem('user-authorities', user.roles);
state.token = user.token;
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-token');
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
});
import { store } from './store';
...
new Vue({
router,
store,
render: h => h(App)
}).$mount('#app')
$ npm install --save bootstrap bootstrap-vue
import BootstrapVue from 'bootstrap-vue'
import 'bootstrap/dist/css/bootstrap.css'
import 'bootstrap-vue/dist/bootstrap-vue.css'
…
Vue.use(BootstrapVue)
methods: {
logout() {
this.$store.dispatch('logout');
this.$router.push('/')
}
}
<template>
<div id="app">
<b-navbar style="width: 100%" type="dark" variant="dark">
<b-navbar-brand id="nav-brand" href="#">Kotlin+Spring+Vue</b-navbar-brand>
<router-link to="/"><img height="30px" src="./assets/img/kotlin-logo.png" alt="Kotlin+Spring+Vue"/></router-link>
<router-link to="/"><img height="30px" src="./assets/img/spring-boot-logo.png" alt="Kotlin+Spring+Vue"/></router-link>
<router-link to="/"><img height="30px" src="./assets/img/vuejs-logo.png" alt="Kotlin+Spring+Vue"/></router-link>
<router-link to="/user" class="nav-link text-light" v-if="this.$store.getters.isAuthenticated">User</router-link>
<router-link to="/admin" class="nav-link text-light" v-if="this.$store.getters.isAuthenticated && this.$store.getters.isAdmin">Admin</router-link>
<router-link to="/register" class="nav-link text-light" v-if="!this.$store.getters.isAuthenticated">Register</router-link>
<router-link to="/login" class="nav-link text-light" v-if="!this.$store.getters.isAuthenticated">Login</router-link>
<a href="#" class="nav-link text-light" v-if="this.$store.getters.isAuthenticated" v-on:click="logout">Logout </a>
</b-navbar>
<router-view></router-view>
</div>
</template>
<template>
<div div="home">
<b-jumbotron>
<template slot="header">Kotlin + Spring Boot + Vue.js</template>
<template slot="lead">
This is the demo web-application written in Kotlin using Spring Boot and Vue.js for frontend
</template>
<hr class="my-4" />
<p v-if="!this.$store.getters.isAuthenticated">
Login and start
</p>
<router-link to="/login" v-if="!this.$store.getters.isAuthenticated">
<b-button variant="primary">Login</b-button>
</router-link>
</b-jumbotron>
</div>
</template>
<script>
</script>
<style>
</style>
<template>
<div div="signin">
<div class="login-form">
<b-card
title="Login"
tag="article"
style="max-width: 20rem;"
class="mb-2"
>
<div>
<b-alert
:show="dismissCountDown"
dismissible
variant="danger"
@dismissed="dismissCountDown=0"
@dismiss-count-down="countDownChanged"
> {{ alertMessage }}
</b-alert>
</div>
<div>
<b-form-input type="text" placeholder="Username" v-model="username" />
<div class="mt-2"></div>
<b-form-input type="password" placeholder="Password" v-model="password" />
<div class="mt-2"></div>
</div>
<b-button v-on:click="login" variant="primary">Login</b-button>
<hr class="my-4" />
<b-button variant="link">Forget password?</b-button>
</b-card>
</div>
</div>
</template>
<script>
import {AXIOS} from './http-common'
export default {
name: 'SignIn',
data() {
return {
username: '',
password: '',
dismissSecs: 5,
dismissCountDown: 0,
alertMessage: 'Request error',
}
},
methods: {
login() {
AXIOS.post(`/auth/signin`, {'username': this.$data.username, 'password': this.$data.password})
.then(response => {
this.$store.dispatch('login', {'token': response.data.accessToken, 'roles': response.data.authorities, 'username': response.data.username});
this.$router.push('/home')
}, error => {
this.$data.alertMessage = (error.response.data.message.length < 150) ? error.response.data.message : 'Request error. Please, report this error website owners';
console.log(error)
})
.catch(e => {
console.log(e);
this.showAlert();
})
},
countDownChanged(dismissCountDown) {
this.dismissCountDown = dismissCountDown
},
showAlert() {
this.dismissCountDown = this.dismissSecs
},
}
}
</script>
<style>
.login-form {
margin-left: 38%;
margin-top: 50px;
}
</style>
<template>
<div div="signup">
<div class="login-form">
<b-card
title="Register"
tag="article"
style="max-width: 20rem;"
class="mb-2"
>
<div>
<b-alert
:show="dismissCountDown"
dismissible
variant="danger"
@dismissed="dismissCountDown=0"
@dismiss-count-down="countDownChanged"
> {{ alertMessage }}
</b-alert>
</div>
<div>
<b-alert variant="success" :show="successfullyRegistered">
You have been successfully registered! Now you can login with your credentials
<hr />
<router-link to="/login">
<b-button variant="primary">Login</b-button>
</router-link>
</b-alert>
</div>
<div>
<b-form-input type="text" placeholder="Username" v-model="username" />
<div class="mt-2"></div>
<b-form-input type="text" placeholder="First Name" v-model="firstname" />
<div class="mt-2"></div>
<b-form-input type="text" placeholder="Last name" v-model="lastname" />
<div class="mt-2"></div>
<b-form-input type="text" placeholder="Email" v-model="email" />
<div class="mt-2"></div>
<b-form-input type="password" placeholder="Password" v-model="password" />
<div class="mt-2"></div>
<b-form-input type="password" placeholder="Confirm Password" v-model="confirmpassword" />
<div class="mt-2"></div>
</div>
<b-button v-on:click="register" variant="primary">Register</b-button>
</b-card>
</div>
</div>
</template>
<script>
import {AXIOS} from './http-common'
export default {
name: 'SignUp',
data () {
return {
username: '',
firstname: '',
lastname: '',
email: '',
password: '',
confirmpassword: '',
dismissSecs: 5,
dismissCountDown: 0,
alertMessage: '',
successfullyRegistered: false
}
},
methods: {
register: function () {
if (this.$data.username === '' || this.$data.username == null) {
this.$data.alertMessage = 'Please, fill "Username" field';
this.showAlert();
} else if (this.$data.firstname === '' || this.$data.firstname == null) {
this.$data.alertMessage = 'Please, fill "First name" field';
this.showAlert();
} else if (this.$data.lastname === '' || this.$data.lastname == null) {
this.$data.alertMessage = 'Please, fill "Last name" field';
this.showAlert();
} else if (this.$data.email === '' || this.$data.email == null) {
this.$data.alertMessage = 'Please, fill "Email" field';
this.showAlert();
} else if (!this.$data.email.includes('@')) {
this.$data.alertMessage = 'Email is incorrect';
this.showAlert();
} else if (this.$data.password === '' || this.$data.password == null) {
this.$data.alertMessage = 'Please, fill "Password" field';
this.showAlert();
} else if (this.$data.confirmpassword === '' || this.$data.confirmpassword == null) {
this.$data.alertMessage = 'Please, confirm password';
this.showAlert();
} else if (this.$data.confirmpassword !== this.$data.password) {
this.$data.alertMessage = 'Passwords are not match';
this.showAlert();
} else {
var newUser = {
'username': this.$data.username,
'firstName': this.$data.firstname,
'lastName': this.$data.lastname,
'email': this.$data.email,
'password': this.$data.password
};
AXIOS.post('/auth/signup', newUser)
.then(response => {
console.log(response);
this.successAlert();
}, error => {
this.$data.alertMessage = (error.response.data.message.length < 150) ? error.response.data.message : 'Request error. Please, report this error website owners'
this.showAlert();
})
.catch(error => {
console.log(error);
this.$data.alertMessage = 'Request error. Please, report this error website owners';
this.showAlert();
});
}
},
countDownChanged(dismissCountDown) {
this.dismissCountDown = dismissCountDown
},
showAlert() {
this.dismissCountDown = this.dismissSecs
},
successAlert() {
this.username = '';
this.firstname = '';
this.lastname = '';
this.email = '';
this.password = '';
this.confirmpassword = '';
this.successfullyRegistered = true;
}
}
}
</script>
<style>
.login-form {
margin-left: 38%;
margin-top: 50px;
}
</style>
<template>
<div div="userpage">
<h2>{{ pageContent }}</h2>
</div>
</template>
<script>
import {AXIOS} from './http-common'
export default {
name: 'UserPage',
data() {
return {
pageContent: ''
}
},
methods: {
loadUserContent() {
const header = {'Authorization': 'Bearer ' + this.$store.getters.getToken};
AXIOS.get('/usercontent', { headers: header })
.then(response => {
this.$data.pageContent = response.data;
})
.catch(error => {
console.log('ERROR: ' + error.response.data);
})
}
},
mounted() {
this.loadUserContent();
}
}
</script>
<style>
</style>
<template>
<div div="adminpage">
<h2>{{ pageContent }}</h2>
</div>
</template>
<script>
import {AXIOS} from './http-common'
export default {
name: 'AdminPage',
data() {
return {
pageContent: ''
}
},
methods: {
loadUserContent() {
const header = {'Authorization': 'Bearer ' + this.$store.getters.getToken};
AXIOS.get('/admincontent', { headers: header })
.then(response => {
this.$data.pageContent = response.data;
})
.catch(error => {
console.log('ERROR: ' + error.response.data);
})
}
},
mounted() {
this.loadUserContent();
}
}
</script>
<style>
</style>
INSERT INTO users_roles (user_id, role_id) VALUES (1, 2);
ERROR 77100 --- [nio-8080-exec-2] c.k.backend.jwt.JwtAuthEntryPoint : Unauthorized error. Message - Full authentication is required to access this resource
К сожалению, не доступен сервер mySQL