added users, jwt AT and RT

This commit is contained in:
Andrey Kassaev 2023-12-18 22:33:37 +04:00
parent ed13d6b914
commit 134ae88bd3
23 changed files with 568 additions and 2 deletions

View File

@ -26,6 +26,13 @@ dependencies {
compileOnly("org.projectlombok:lombok:1.18.30")
annotationProcessor("org.projectlombok:lombok:1.18.30")
implementation ("org.springframework.boot:spring-boot-starter-security")
testImplementation("org.springframework.security:spring-security-test")
implementation("io.jsonwebtoken:jjwt-api:0.12.3")
implementation("io.jsonwebtoken:jjwt-impl:0.12.3")
implementation("io.jsonwebtoken:jjwt-jackson:0.12.3")
}
tasks.withType<KotlinCompile> {

View File

@ -0,0 +1,39 @@
package com.kassaev.notes.config
import com.kassaev.notes.repository.UserRepository
import com.kassaev.notes.service.CustomUserDetailsService
import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.security.authentication.AuthenticationManager
import org.springframework.security.authentication.AuthenticationProvider
import org.springframework.security.authentication.dao.DaoAuthenticationProvider
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration
import org.springframework.security.core.userdetails.UserDetailsService
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
import org.springframework.security.crypto.password.PasswordEncoder
@Configuration
@EnableConfigurationProperties(JwtProperties::class)
class Configuration {
@Bean
fun userDetailsService(userRepository: UserRepository): UserDetailsService =
CustomUserDetailsService(userRepository)
@Bean
fun encoder(): PasswordEncoder = BCryptPasswordEncoder()
@Bean
fun authenticationProvider(userRepository: UserRepository): AuthenticationProvider =
DaoAuthenticationProvider()
.also {
it.setUserDetailsService(userDetailsService(userRepository))
it.setPasswordEncoder(encoder())
}
@Bean
fun authenticationManager(config: AuthenticationConfiguration): AuthenticationManager =
config.authenticationManager
}

View File

@ -0,0 +1,58 @@
package com.kassaev.notes.config
import com.kassaev.notes.service.CustomUserDetailsService
import com.kassaev.notes.service.TokenService
import jakarta.servlet.FilterChain
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
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.stereotype.Component
import org.springframework.web.filter.OncePerRequestFilter
@Component
class JwtAuthenticationFilter(
private val userDetailsService: CustomUserDetailsService,
private val tokenService: TokenService
): OncePerRequestFilter() {
override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain
) {
val authHeader: String? = request.getHeader("Authorization")
if(authHeader.doNotContainBearerToken()){
filterChain.doFilter(request, response)
return
}
val jwtToken = authHeader!!.extractTokenValue()
val email = tokenService.extractEmail(jwtToken)
if (email != null && SecurityContextHolder.getContext().authentication == null){
val foundUser = userDetailsService.loadUserByUsername(email)
if (tokenService.isValid(jwtToken, foundUser)){
updateContext(foundUser, request)
}
filterChain.doFilter(request, response)
}
}
private fun updateContext(foundUser: UserDetails, request: HttpServletRequest) {
val authToken = UsernamePasswordAuthenticationToken(foundUser, null, foundUser.authorities)
authToken.details = WebAuthenticationDetailsSource().buildDetails(request)
SecurityContextHolder.getContext().authentication = authToken
}
private fun String?.doNotContainBearerToken(): Boolean =
this == null || !this.startsWith("Bearer ")
private fun String.extractTokenValue(): String =
this.substringAfter("Bearer ")
}

View File

@ -0,0 +1,10 @@
package com.kassaev.notes.config
import org.springframework.boot.context.properties.ConfigurationProperties
@ConfigurationProperties("jwt")
data class JwtProperties(
val key: String,
val accessTokenExpiration: Long,
val refreshTokenExpiration: Long
)

View File

@ -0,0 +1,43 @@
package com.kassaev.notes.config
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.http.HttpMethod
import org.springframework.security.authentication.AuthenticationProvider
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.security.config.http.SessionCreationPolicy
import org.springframework.security.web.DefaultSecurityFilterChain
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter
@Configuration
@EnableWebSecurity
class SecurityConfiguration(
private val authenticationProvider: AuthenticationProvider
) {
@Bean
fun securityFilterChain(
http: HttpSecurity,
jwtAuthenticationFilter: JwtAuthenticationFilter
): DefaultSecurityFilterChain =
http
.csrf { it.disable() }
.authorizeHttpRequests {
it
.requestMatchers("/api/v1.0.0/auth", "/api/v1.0.0/auth/refresh", "/error")
.permitAll()
.requestMatchers(HttpMethod.POST, "/api/v1.0.0/user")
.permitAll()
.requestMatchers("/api/v1.0.0/user**")
.hasRole("ADMIN")
.anyRequest()
.fullyAuthenticated()
}
.sessionManagement {
it.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
}
.authenticationProvider(authenticationProvider)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter::class.java)
.build()
}

View File

@ -0,0 +1,33 @@
package com.kassaev.notes.controller.auth
import com.kassaev.notes.service.AuthenticationService
import org.springframework.http.HttpStatus
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 org.springframework.web.server.ResponseStatusException
@RestController
@RequestMapping("/api/v1.0.0/auth")
class AuthController(
private val authenticationService: AuthenticationService
) {
@PostMapping
fun authenticate(@RequestBody authRequest: AuthenticationRequest): AuthenticationResponse =
authenticationService.authentication(authRequest)
@PostMapping("/refresh")
fun refreshAccessToken(
@RequestBody request: RefreshTokenRequest
): TokenResponse =
authenticationService.refreshAccessToken(request.token)
?.mapToTokenResponse()
?: throw ResponseStatusException(HttpStatus.FORBIDDEN, "Invalid refresh token!")
private fun String.mapToTokenResponse(): TokenResponse =
TokenResponse(
token = this
)
}

View File

@ -0,0 +1,6 @@
package com.kassaev.notes.controller.auth
data class AuthenticationRequest(
val email: String,
val password: String
)

View File

@ -0,0 +1,6 @@
package com.kassaev.notes.controller.auth
data class AuthenticationResponse(
val accessToken: String,
val refreshToken: String
)

View File

@ -0,0 +1,5 @@
package com.kassaev.notes.controller.auth
data class RefreshTokenRequest(
val token: String
)

View File

@ -0,0 +1,5 @@
package com.kassaev.notes.controller.auth
data class TokenResponse(
val token: String
)

View File

@ -1,4 +1,4 @@
package com.kassaev.notes.controller
package com.kassaev.notes.controller.note
import com.kassaev.notes.model.Note
import com.kassaev.notes.service.NoteService

View File

@ -0,0 +1,67 @@
package com.kassaev.notes.controller.user
import com.kassaev.notes.model.Role
import com.kassaev.notes.model.User
import com.kassaev.notes.service.UserService
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
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 org.springframework.web.server.ResponseStatusException
import java.util.UUID
@RestController
@RequestMapping("/api/v1.0.0/user")
class UserController(
private val userService: UserService
) {
@PostMapping
fun create(@RequestBody userRequest: UserRequest): UserResponse =
userService.createUser(
user = userRequest.toModel()
)
?.toResponse()
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Cannot create user.")
@GetMapping
fun listAll(): List<UserResponse> =
userService.findAll()
.map { it.toResponse() }
@GetMapping("/{uuid}")
fun findByUUID(@PathVariable uuid: UUID): UserResponse =
userService.findByUUID(uuid)
?.toResponse()
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Cannot find a user.")
@DeleteMapping("/{uuid}")
fun deleteByUUID(@PathVariable uuid: UUID): ResponseEntity<Boolean> {
val success = userService.deleteByUUID(uuid)
return if(success) {
ResponseEntity.noContent().build()
} else {
throw ResponseStatusException(HttpStatus.NOT_FOUND, "Cannot find a user.")
}
}
private fun UserRequest.toModel(): User =
User(
id = UUID.randomUUID(),
email = this.email,
password = this.password,
role = Role.USER
)
private fun User.toResponse(): UserResponse =
UserResponse(
uuid = this.id,
email = this.email
)
}

View File

@ -0,0 +1,6 @@
package com.kassaev.notes.controller.user
data class UserRequest(
val email: String,
val password: String
)

View File

@ -0,0 +1,8 @@
package com.kassaev.notes.controller.user
import java.util.UUID
data class UserResponse(
val uuid: UUID,
val email: String
)

View File

@ -0,0 +1,14 @@
package com.kassaev.notes.model
import java.util.UUID
data class User(
val id: UUID,
val email: String,
val password: String,
val role: Role
)
enum class Role{
USER, ADMIN
}

View File

@ -0,0 +1,17 @@
package com.kassaev.notes.repository
import org.springframework.security.core.userdetails.UserDetails
import org.springframework.stereotype.Repository
@Repository
class RefreshTokenRepository {
private val tokens = mutableMapOf<String, UserDetails>()
fun findUserDetailsByToken(token: String): UserDetails? =
tokens[token]
fun save(token: String, userDetails: UserDetails) {
tokens[token] = userDetails
}
}

View File

@ -0,0 +1,58 @@
package com.kassaev.notes.repository
import com.kassaev.notes.model.Role
import com.kassaev.notes.model.User
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.stereotype.Repository
import java.util.*
@Repository
class UserRepository(
private val encoder: PasswordEncoder
) {
private val users = mutableListOf(
User(
id = UUID.randomUUID(),
email = "user1@mail.com",
password = encoder.encode("pass1"),
role = Role.USER
),
User(
id = UUID.randomUUID(),
email = "user2@mail.com",
password = encoder.encode("pass2"),
role = Role.USER
),
User(
id = UUID.randomUUID(),
email = "user3@mail.com",
password = encoder.encode("pass3"),
role = Role.ADMIN
),
)
fun save(user: User): Boolean {
val updated = user.copy(password = encoder.encode(
user.password
))
return users.add(updated)
}
fun findByEmail(email: String): User? =
users.firstOrNull { it.email == email }
fun findByUUID(uuid: UUID): User? =
users.firstOrNull { it.id == uuid }
fun findAll(): List<User> =
users
fun deleteByUUID(uuid: UUID): Boolean {
val foundUser = findByUUID(uuid)
return foundUser?.let {
users.remove(it)
} ?: false
}
}

View File

@ -0,0 +1,67 @@
package com.kassaev.notes.service
import com.kassaev.notes.config.JwtProperties
import com.kassaev.notes.controller.auth.AuthenticationRequest
import com.kassaev.notes.controller.auth.AuthenticationResponse
import com.kassaev.notes.repository.RefreshTokenRepository
import org.springframework.security.authentication.AuthenticationManager
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.userdetails.UserDetails
import org.springframework.stereotype.Service
import java.util.Date
@Service
class AuthenticationService(
private val authManager: AuthenticationManager,
private val userDetailsService: CustomUserDetailsService,
private val tokenService: TokenService,
private val jwtProperties: JwtProperties,
private val refreshTokenRepository: RefreshTokenRepository
) {
fun authentication(authRequest: AuthenticationRequest): AuthenticationResponse {
authManager.authenticate(
UsernamePasswordAuthenticationToken(
authRequest.email,
authRequest.password
)
)
val user = userDetailsService.loadUserByUsername(authRequest.email)
val accessToken = generateAccessToken(user)
val refreshToken = generateRefreshToken(user)
refreshTokenRepository.save(refreshToken, user)
return AuthenticationResponse(
accessToken = accessToken,
refreshToken = refreshToken
)
}
private fun generateRefreshToken(user: UserDetails) = tokenService.generate(
userDetails = user,
expirationDate = Date(System.currentTimeMillis() + jwtProperties.refreshTokenExpiration)
)
private fun generateAccessToken(user: UserDetails) = tokenService.generate(
userDetails = user,
expirationDate = Date(System.currentTimeMillis() + jwtProperties.accessTokenExpiration)
)
fun refreshAccessToken(token: String): String? {
val extractedEmail = tokenService.extractEmail(token)
return extractedEmail?.let { email ->
val currentUserDetails = userDetailsService.loadUserByUsername(email)
val refreshTokenUserDetails = refreshTokenRepository.findUserDetailsByToken(token)
if (!tokenService.isExpired(token) && currentUserDetails.username == refreshTokenUserDetails?.username)
// TODO: check if this refresh token is the same token in db for this user <refresh token, user>. If so generate new access token and refresh token and update record in with newly created refresh token for this user.
generateAccessToken(currentUserDetails)
else
null
}
}
}

View File

@ -0,0 +1,26 @@
package com.kassaev.notes.service
import com.kassaev.notes.repository.UserRepository
import org.springframework.security.core.userdetails.User
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
typealias ApplicationUser = com.kassaev.notes.model.User
@Service
class CustomUserDetailsService(
private val userRepository: UserRepository
): UserDetailsService {
override fun loadUserByUsername(username: String): UserDetails =
userRepository.findByEmail(username)
?.mapToUserDetails()
?: throw UsernameNotFoundException("Not found!")
private fun ApplicationUser.mapToUserDetails(): UserDetails =
User.builder()
.username(this.email)
.password(this.password)
.roles(this.role.name)
.build()
}

View File

@ -0,0 +1,58 @@
package com.kassaev.notes.service
import com.kassaev.notes.config.JwtProperties
import io.jsonwebtoken.Claims
import io.jsonwebtoken.Jwts
import io.jsonwebtoken.security.Keys
import org.springframework.security.core.userdetails.UserDetails
import org.springframework.stereotype.Service
import java.util.Date
@Service
class TokenService(
jwtProperties: JwtProperties
) {
private val secretKey = Keys.hmacShaKeyFor(
jwtProperties.key.toByteArray()
)
fun generate(
userDetails: UserDetails,
expirationDate: Date,
additionalClaims: Map<String, Any> = emptyMap()
): String =
Jwts.builder()
.claims()
.subject(userDetails.username)
.issuedAt(Date(System.currentTimeMillis()))
.expiration(expirationDate)
.add(additionalClaims)
.and()
.signWith(secretKey)
.compact()
fun extractEmail(token: String): String? =
getAllClaims(token)
.subject
fun isExpired(token: String): Boolean =
getAllClaims(token)
.expiration
.before(Date(System.currentTimeMillis()))
fun isValid(token: String, userDetails: UserDetails): Boolean {
val email = extractEmail(token)
return userDetails.username == email && !isExpired(token)
}
private fun getAllClaims(token: String): Claims {
val parser = Jwts.parser()
.verifyWith(secretKey)
.build()
return parser
.parseSignedClaims(token)
.payload
}
}

View File

@ -0,0 +1,30 @@
package com.kassaev.notes.service
import com.kassaev.notes.model.User
import com.kassaev.notes.repository.UserRepository
import org.springframework.stereotype.Service
import java.util.UUID
@Service
class UserService(
private val userRepository: UserRepository
) {
fun createUser(user: User): User? {
val found = userRepository.findByEmail(user.email)
return if (found == null) {
userRepository.save(user)
user
} else null
}
fun findByUUID(uuid: UUID): User? =
userRepository.findByUUID(uuid)
fun findAll(): List<User> =
userRepository.findAll()
fun deleteByUUID(uuid: UUID): Boolean =
userRepository.deleteByUUID(uuid)
}

View File

@ -1 +0,0 @@

View File

@ -0,0 +1,4 @@
jwt:
key: ${JWT_KEY}
access-token-expiration: 3600000
refresh-token-expiration: 86400000