Add authentication

This commit is contained in:
2023-10-05 21:11:22 +08:00
parent 78de04713f
commit 8e5375ab30
24 changed files with 580 additions and 15 deletions

View File

@@ -0,0 +1,14 @@
package top.fatweb.api.annotation
import org.springframework.core.annotation.AliasFor
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
annotation class ApiVersion(
@get:AliasFor("version")
val value: Int = 1,
@get:AliasFor("value")
val version: Int = 1
)

View File

@@ -0,0 +1,92 @@
package top.fatweb.api.config
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.security.authentication.AuthenticationManager
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configurers.*
import org.springframework.security.config.http.SessionCreationPolicy
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
import org.springframework.security.web.SecurityFilterChain
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter
import org.springframework.web.cors.CorsConfiguration
import org.springframework.web.cors.UrlBasedCorsConfigurationSource
import top.fatweb.api.filter.JwtAuthenticationTokenFilter
import top.fatweb.api.handler.JwtAccessDeniedHandler
import top.fatweb.api.handler.JwtAuthenticationEntryPointHandler
@Configuration
@EnableMethodSecurity
class SecurityConfig(
val jwtAuthenticationTokenFilter: JwtAuthenticationTokenFilter,
val authenticationEntryPointHandler: JwtAuthenticationEntryPointHandler,
val accessDeniedHandler: JwtAccessDeniedHandler
) {
@Bean
fun passwordEncoder() = BCryptPasswordEncoder()
@Bean
fun authenticationManager(authenticationConfiguration: AuthenticationConfiguration): AuthenticationManager =
authenticationConfiguration.authenticationManager
@Bean
fun corsConfigurationSource(): UrlBasedCorsConfigurationSource {
val corsConfiguration = CorsConfiguration()
corsConfiguration.allowedMethods = listOf("*")
corsConfiguration.allowedHeaders = listOf("*")
corsConfiguration.maxAge = 3600L
corsConfiguration.allowedOrigins = listOf("*")
val source = UrlBasedCorsConfigurationSource()
source.registerCorsConfiguration("/**", corsConfiguration)
return source
}
@Bean
fun securityFilterChain(httpSecurity: HttpSecurity): SecurityFilterChain = httpSecurity
// Disable CSRF
.csrf { csrfConfigurer: CsrfConfigurer<HttpSecurity> -> csrfConfigurer.disable() }
// Do not get SecurityContent by Session
.sessionManagement { sessionManagementConfigurer: SessionManagementConfigurer<HttpSecurity?> ->
sessionManagementConfigurer.sessionCreationPolicy(
SessionCreationPolicy.STATELESS
)
}
.authorizeHttpRequests { authorizeHttpRequests: AuthorizeHttpRequestsConfigurer<HttpSecurity>.AuthorizationManagerRequestMatcherRegistry ->
authorizeHttpRequests
// Allow anonymous access
.requestMatchers(
"/api/v*/login",
"/error/thrown",
"/doc.html",
"/swagger-ui/**",
"/webjars/**",
"/v3/**",
"/swagger-ui.html",
"/favicon.ico"
).anonymous()
// Authentication required
.anyRequest().authenticated()
}
.logout { logoutConfigurer: LogoutConfigurer<HttpSecurity> -> logoutConfigurer.disable() }
.exceptionHandling { exceptionHandlingConfigurer: ExceptionHandlingConfigurer<HttpSecurity?> ->
exceptionHandlingConfigurer.authenticationEntryPoint(
authenticationEntryPointHandler
)
exceptionHandlingConfigurer.accessDeniedHandler(
accessDeniedHandler
)
}
.cors { cors: CorsConfigurer<HttpSecurity?> ->
cors.configurationSource(
corsConfigurationSource()
)
}
.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter::class.java).build()
}

View File

@@ -0,0 +1,11 @@
package top.fatweb.api.config
import org.springframework.boot.autoconfigure.web.servlet.WebMvcRegistrations
import org.springframework.context.annotation.Configuration
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping
import top.fatweb.api.util.ApiResponseMappingHandlerMapping
@Configuration
class WebMvcRegistrationsConfig : WebMvcRegistrations {
override fun getRequestMappingHandlerMapping(): RequestMappingHandlerMapping = ApiResponseMappingHandlerMapping()
}

View File

@@ -0,0 +1,17 @@
package top.fatweb.api.controller
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
/**
* <p>
* 用户 前端控制器
* </p>
*
* @author FatttSnake
* @since 2023-10-04
*/
@RestController
@RequestMapping("/api/user")
class UserController

View File

@@ -0,0 +1,21 @@
package top.fatweb.api.controller.permission
import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.tags.Tag
import org.springframework.web.bind.annotation.*
import top.fatweb.api.annotation.ApiVersion
import top.fatweb.api.entity.common.ResponseCode
import top.fatweb.api.entity.common.ResponseResult
import top.fatweb.api.entity.permission.User
import top.fatweb.api.service.permission.IAuthenticationService
@Tag(name = "身份认证", description = "身份认证相关接口")
@RestController
@RequestMapping("/api/{apiVersion}")
@ApiVersion(2)
class AuthenticationController(val loginService: IAuthenticationService) {
@Operation(summary = "登录")
@PostMapping("/login")
fun login(@PathVariable apiVersion: String, @RequestBody user: User) =
ResponseResult.success(ResponseCode.SYSTEM_LOGIN_SUCCESS, "Login success", loginService.login(user))
}

View File

@@ -0,0 +1,36 @@
package top.fatweb.api.entity.permission
import com.fasterxml.jackson.annotation.JsonIgnore
import org.springframework.security.core.GrantedAuthority
import org.springframework.security.core.userdetails.UserDetails
class LoginUser() : UserDetails {
lateinit var user: User
@JsonIgnore
private var authorities: List<GrantedAuthority>? = null
constructor(user: User) : this() {
this.user = user
}
@JsonIgnore
override fun getAuthorities(): List<GrantedAuthority> {
authorities?.let { return it }
authorities = emptyList()
return authorities as List<GrantedAuthority>
}
override fun getPassword(): String? = user.password
override fun getUsername(): String? = user.username
override fun isAccountNonExpired(): Boolean = true
override fun isAccountNonLocked(): Boolean = true
override fun isCredentialsNonExpired(): Boolean = true
override fun isEnabled(): Boolean = user.enable == 1
}

View File

@@ -0,0 +1,56 @@
package top.fatweb.api.entity.permission
import com.baomidou.mybatisplus.annotation.*
import java.io.Serializable
/**
* <p>
* 用户
* </p>
*
* @author FatttSnake
* @since 2023-10-04
*/
@TableName("t_user")
class User : Serializable {
@TableId("id")
var id: Long? = null
/**
* 用户名
*/
@TableField("username")
var username: String? = null
/**
* 密码
*/
@TableField("password")
var password: String? = null
/**
* 启用
*/
@TableField("enable")
var enable: Int? = null
@TableField("deleted")
@TableLogic
var deleted: Long? = null
@TableField("version")
@Version
var version: Int? = null
override fun toString(): String {
return "User{" +
"id=" + id +
", username=" + username +
", password=" + password +
", enable=" + enable +
", deleted=" + deleted +
", version=" + version +
"}"
}
}

View File

@@ -0,0 +1,3 @@
package top.fatweb.api.exception
class TokenHasExpiredException : RuntimeException("Token has expired")

View File

@@ -3,24 +3,44 @@ package top.fatweb.api.filter
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.stereotype.Component
import org.springframework.util.StringUtils
import org.springframework.web.filter.OncePerRequestFilter
import top.fatweb.api.constants.SecurityConstants
import top.fatweb.api.utils.RedisUtil
import top.fatweb.api.constant.SecurityConstants
import top.fatweb.api.entity.permission.LoginUser
import top.fatweb.api.exception.TokenHasExpiredException
import top.fatweb.api.util.JwtUtil
import top.fatweb.api.util.RedisUtil
import java.util.concurrent.TimeUnit
@Component
class JwtAuthenticationTokenFilter(private val redisUtil: RedisUtil) : OncePerRequestFilter() {
override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain
) {
val token = request.getHeader(SecurityConstants.headerString)
val tokenWithPrefix = request.getHeader(SecurityConstants.headerString)
if (!StringUtils.hasText(token) || "/error/thrown" == request.servletPath) {
if (!StringUtils.hasText(tokenWithPrefix) || "/error/thrown" == request.servletPath) {
filterChain.doFilter(request, response)
return
}
val token = tokenWithPrefix.removePrefix(SecurityConstants.tokenPrefix)
JwtUtil.parseJwt(token)
val redisKey = "${SecurityConstants.jwtIssuer}_login:" + token.substring(0, 32)
val loginUser = redisUtil.getObject<LoginUser>(redisKey)
loginUser ?: let { throw TokenHasExpiredException() }
redisUtil.setExpire(redisKey, 20, TimeUnit.MINUTES)
val authenticationToken = UsernamePasswordAuthenticationToken(loginUser, null, loginUser.authorities)
SecurityContextHolder.getContext().authentication = authenticationToken
filterChain.doFilter(request, response)
}
}

View File

@@ -0,0 +1,19 @@
package top.fatweb.api.handler
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.security.access.AccessDeniedException
import org.springframework.security.web.access.AccessDeniedHandler
import org.springframework.stereotype.Component
@Component
class JwtAccessDeniedHandler : AccessDeniedHandler {
override fun handle(
request: HttpServletRequest?,
response: HttpServletResponse?,
accessDeniedException: AccessDeniedException?
) {
request?.setAttribute("filter.error", accessDeniedException)
request?.getRequestDispatcher("/error/thrown")?.forward(request, response)
}
}

View File

@@ -0,0 +1,19 @@
package top.fatweb.api.handler
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.security.core.AuthenticationException
import org.springframework.security.web.AuthenticationEntryPoint
import org.springframework.stereotype.Component
@Component
class JwtAuthenticationEntryPointHandler : AuthenticationEntryPoint {
override fun commence(
request: HttpServletRequest?,
response: HttpServletResponse?,
authException: AuthenticationException?
) {
request?.setAttribute("filter.error", authException)
request?.getRequestDispatcher("/error/thrown")?.forward(request, response)
}
}

View File

@@ -0,0 +1,16 @@
package top.fatweb.api.mapper
import com.baomidou.mybatisplus.core.mapper.BaseMapper
import org.apache.ibatis.annotations.Mapper
import top.fatweb.api.entity.permission.User
/**
* <p>
* 用户 Mapper 接口
* </p>
*
* @author FatttSnake
* @since 2023-10-04
*/
@Mapper
interface UserMapper : BaseMapper<User>

View File

@@ -0,0 +1,14 @@
package top.fatweb.api.service
import com.baomidou.mybatisplus.extension.service.IService
import top.fatweb.api.entity.permission.User
/**
* <p>
* 用户 服务类
* </p>
*
* @author FatttSnake
* @since 2023-10-04
*/
interface IUserService : IService<User>

View File

@@ -0,0 +1,18 @@
package top.fatweb.api.service.impl
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl
import org.springframework.stereotype.Service
import top.fatweb.api.entity.permission.User
import top.fatweb.api.mapper.UserMapper
import top.fatweb.api.service.IUserService
/**
* <p>
* 用户 服务实现类
* </p>
*
* @author FatttSnake
* @since 2023-10-04
*/
@Service
class UserServiceImpl : ServiceImpl<UserMapper, User>(), IUserService

View File

@@ -0,0 +1,11 @@
package top.fatweb.api.service.permission
import top.fatweb.api.entity.permission.User
interface IAuthenticationService {
fun login(user: User): HashMap<String, String>
fun logout(token: String): Boolean
fun renewToken(token: String): HashMap<String, String>
}

View File

@@ -0,0 +1,61 @@
package top.fatweb.api.service.permission.impl
import org.springframework.security.authentication.AuthenticationManager
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.stereotype.Service
import top.fatweb.api.constant.SecurityConstants
import top.fatweb.api.entity.permission.LoginUser
import top.fatweb.api.entity.permission.User
import top.fatweb.api.service.permission.IAuthenticationService
import top.fatweb.api.util.JwtUtil
import top.fatweb.api.util.RedisUtil
import top.fatweb.api.util.WebUtil
import java.util.concurrent.TimeUnit
@Service
class AuthenticationServiceImpl(
private val authenticationManager: AuthenticationManager,
private val redisUtil: RedisUtil
) : IAuthenticationService {
override fun login(user: User): HashMap<String, String> {
val usernamePasswordAuthenticationToken = UsernamePasswordAuthenticationToken(user.username, user.password)
val authentication = authenticationManager.authenticate(usernamePasswordAuthenticationToken)
authentication ?: let {
throw RuntimeException("Login failed")
}
val loginUser = authentication.principal as LoginUser
loginUser.user.password = ""
val userId = loginUser.user.id.toString()
val jwt = JwtUtil.createJwt(userId)
jwt ?: let {
throw RuntimeException("Login failed")
}
val hashMap = hashMapOf("token" to jwt)
val redisKey = "${SecurityConstants.jwtIssuer}_login:" + jwt.substring(0, 32)
redisUtil.setObject(redisKey, loginUser, 20, TimeUnit.MINUTES)
return hashMap
}
override fun logout(token: String): Boolean =
redisUtil.delObject("${SecurityConstants.jwtIssuer}_login:" + token.substring(0, 32))
override fun renewToken(token: String): HashMap<String, String> {
val oldRedisKey = "${SecurityConstants.jwtIssuer}_login:" + token.substring(0, 32)
redisUtil.delObject(oldRedisKey)
val jwt = JwtUtil.createJwt(WebUtil.getLoginUserId().toString())
jwt ?: let {
throw RuntimeException("Login failed")
}
val hashMap = hashMapOf("token" to jwt)
val redisKey = "${SecurityConstants.jwtIssuer}_login:" + jwt.substring(0, 32)
redisUtil.setObject(redisKey, WebUtil.getLoginUser(), 20, TimeUnit.MINUTES)
return hashMap
}
}

View File

@@ -0,0 +1,31 @@
package top.fatweb.api.util
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.servlet.mvc.condition.RequestCondition
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping
import top.fatweb.api.annotation.ApiVersion
import java.lang.reflect.Method
class ApiResponseMappingHandlerMapping : RequestMappingHandlerMapping() {
private val versionFlag = "{apiVersion}"
private fun createCondition(clazz: Class<*>): RequestCondition<ApiVersionCondition>? {
val classRequestMapping = clazz.getAnnotation(RequestMapping::class.java)
classRequestMapping ?: let { return null }
val mappingUrlBuilder = StringBuilder()
if (classRequestMapping.value.isNotEmpty()) {
mappingUrlBuilder.append(classRequestMapping.value[0])
}
val mappingUrl = mappingUrlBuilder.toString()
if (!mappingUrl.contains(versionFlag)) {
return null
}
val apiVersion = clazz.getAnnotation(ApiVersion::class.java)
return if (apiVersion == null) ApiVersionCondition(1) else ApiVersionCondition(apiVersion.version)
}
override fun getCustomMethodCondition(method: Method): RequestCondition<*>? = createCondition(method.javaClass)
override fun getCustomTypeCondition(handlerType: Class<*>): RequestCondition<*>? = createCondition(handlerType)
}

View File

@@ -0,0 +1,26 @@
package top.fatweb.api.util
import jakarta.servlet.http.HttpServletRequest
import org.springframework.web.servlet.mvc.condition.RequestCondition
import java.util.regex.Pattern
class ApiVersionCondition(private val apiVersion: Int) : RequestCondition<ApiVersionCondition> {
private val versionPrefixPattern: Pattern = Pattern.compile(".*v(\\d+).*")
override fun combine(other: ApiVersionCondition): ApiVersionCondition = ApiVersionCondition(other.apiVersion)
override fun getMatchingCondition(request: HttpServletRequest): ApiVersionCondition? {
val matcher = versionPrefixPattern.matcher(request.requestURI)
if (matcher.find()) {
val version = matcher.group(1).toInt()
if (version >= this.apiVersion) {
return this
}
}
return null
}
override fun compareTo(other: ApiVersionCondition, request: HttpServletRequest): Int =
other.apiVersion - this.apiVersion
}

View File

@@ -0,0 +1,67 @@
package top.fatweb.api.util
import com.auth0.jwt.JWT
import com.auth0.jwt.algorithms.Algorithm
import com.auth0.jwt.interfaces.DecodedJWT
import top.fatweb.api.constant.SecurityConstants
import java.util.*
import java.util.concurrent.TimeUnit
import javax.crypto.spec.SecretKeySpec
object JwtUtil {
private fun getUUID() = UUID.randomUUID().toString().replace("-", "")
/**
* 生成加密后的秘钥 secretKey
*
* @return 密钥
*/
private fun generalKey(): SecretKeySpec {
val encodeKey = Base64.getDecoder().decode(SecurityConstants.jwtKey)
return SecretKeySpec(encodeKey, 0, encodeKey.size, "AES")
}
private fun algorithm(): Algorithm = Algorithm.HMAC256(generalKey().toString())
/**
* 创建 token
*
* @param subject token 中存放的数据json格式)
* @param ttl token 生存时间
* @param timeUnit ttl 时间单位
* @param uuid 唯一 ID
* @return jwt 串
*/
fun createJwt(
subject: String,
ttl: Long = SecurityConstants.jwtTtl,
timeUnit: TimeUnit = SecurityConstants.jwtTtlUnit,
uuid: String = getUUID()
): String? {
val nowMillis = System.currentTimeMillis()
val nowDate = Date(nowMillis)
val unitTtl = (ttl * when (timeUnit) {
TimeUnit.DAYS -> 24 * 60 * 60 * 1000
TimeUnit.HOURS -> 60 * 60 * 1000
TimeUnit.MINUTES -> 60 * 1000
TimeUnit.SECONDS -> 1000
TimeUnit.MILLISECONDS -> 1
TimeUnit.NANOSECONDS -> 1 / 1000
TimeUnit.MICROSECONDS -> 1 / 1000 / 1000
})
val expMillis = nowMillis + unitTtl
val expDate = Date(expMillis)
return JWT.create().withJWTId(uuid).withSubject(subject).withIssuer(SecurityConstants.jwtIssuer)
.withIssuedAt(nowDate).withExpiresAt(expDate).sign(algorithm())
}
/**
* 解析 jwt
*
* @param jwt jwt 串
* @return 解析内容
*/
fun parseJwt(jwt: String): DecodedJWT =
JWT.require(algorithm()).build().verify(jwt)
}

View File

@@ -1,4 +1,4 @@
package top.fatweb.api.utils
package top.fatweb.api.util
import org.springframework.data.redis.core.BoundSetOperations
import org.springframework.data.redis.core.RedisTemplate
@@ -141,7 +141,8 @@ class RedisUtil(private val redisTemplate: RedisTemplate<String, Any>) {
* @param key 缓存的键
* @return 缓存的键对应的 Map 数据
*/
fun <T> getMap(key: String): Map<String, T>? = redisTemplate.opsForHash<String, Any>().entries(key) as? Map<String, T>
fun <T> getMap(key: String): Map<String, T>? =
redisTemplate.opsForHash<String, Any>().entries(key) as? Map<String, T>
/**
* Hash 中存入数据

View File

@@ -0,0 +1,10 @@
package top.fatweb.api.util
import org.springframework.security.core.context.SecurityContextHolder
import top.fatweb.api.entity.permission.LoginUser
object WebUtil {
fun getLoginUser() = SecurityContextHolder.getContext().authentication.principal as LoginUser
fun getLoginUserId() = getLoginUser().user.id
}

View File

@@ -1,5 +0,0 @@
package top.fatweb.api.utils
class JwtUtil {
}

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="top.fatweb.api.mapper.UserMapper">
</mapper>

View File

@@ -1,17 +1,19 @@
package top.fatweb.api
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import org.springframework.boot.test.context.SpringBootTest
import top.fatweb.api.constants.SecurityConstants
import java.security.MessageDigest
import java.util.*
import top.fatweb.api.constant.SecurityConstants
@SpringBootTest
class FatWebApiApplicationTests {
@Test
fun contextLoads() {
SecurityConstants.jwtKey
}
@Test
fun removePrefixTest() {
assertEquals("12312", "Bearer 12312".removePrefix(SecurityConstants.tokenPrefix))
}
}