diff --git a/.gitignore b/.gitignore index 549e00a..d65ea6e 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,7 @@ build/ ### VS Code ### .vscode/ + +### Custom ### +/application-config.yml +data diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..957b824 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +FROM eclipse-temurin:17-jdk-alpine +LABEL authors="FatttSnake" + +VOLUME /data + +ARG EXTRACTED=target/extracted +COPY ${EXTRACTED}/dependencies/ / +COPY ${EXTRACTED}/spring-boot-loader/ / +COPY ${EXTRACTED}/snapshot-dependencies/ / +RUN true +COPY ${EXTRACTED}/application/ / + +ENTRYPOINT ["java", "org.springframework.boot.loader.JarLauncher", "${JAVA_OPTS}"] \ No newline at end of file diff --git a/build-docker.sh b/build-docker.sh new file mode 100644 index 0000000..6b42feb --- /dev/null +++ b/build-docker.sh @@ -0,0 +1,9 @@ +#!/bin/bash + +JAR_NAME=`ls target | grep api-|grep -v original` +JAR_VERSION=${JAR_NAME%.*} +JAR_VERSION=${JAR_VERSION#*-} + +mkdir target/extracted +java -Djarmode=layertools -jar target/*.jar extract --destination target/extracted +docker build -t hub.fatweb.top/fatweb-api:latest -t hub.fatweb.top/fatweb-api:$JAR_VERSION -t hub.fatweb.top/fatweb-api:$JAR_VERSION-$(date "+%Y%m%d%H%M%S") . \ No newline at end of file diff --git a/db/schema.sql b/db/schema.sql new file mode 100644 index 0000000..a1140fd --- /dev/null +++ b/db/schema.sql @@ -0,0 +1,12 @@ +drop table if exists t_user; + +create table if not exists t_user +( + id bigint not null primary key, + username varchar(20) not null comment '用户名', + password char(70) not null comment '密码', + enable int not null comment '启用', + deleted bigint not null default 0, + version int not null default 0, + constraint t_user_unique unique (username, deleted) +) comment '用户'; diff --git a/pom.xml b/pom.xml index ee5348b..abe9e27 100644 --- a/pom.xml +++ b/pom.xml @@ -13,9 +13,57 @@ 0.0.1-SNAPSHOT fatweb-api fatweb-api + + + + dev + + true + + env + dev + + + + + com.github.xiaoymin + knife4j-openapi3-jakarta-spring-boot-starter + 4.3.0 + + + + + prod + + + env + prod + + + + + com.github.xiaoymin + knife4j-openapi3-jakarta-spring-boot-starter + + + com.github.xiaoymin + knife4j-openapi3-ui + + + org.webjars + swagger-ui + + + + + + + 17 1.8.22 + ${maven.build.timestamp} + yyyy-MM-dd'T'HH:mm:ss @@ -26,6 +74,10 @@ org.springframework.boot spring-boot-starter-web + + org.springframework.boot + spring-boot-starter-validation + com.fasterxml.jackson.module jackson-module-kotlin @@ -46,9 +98,9 @@ true - org.projectlombok - lombok - true + com.mysql + mysql-connector-j + runtime org.springframework.boot @@ -60,31 +112,80 @@ spring-security-test test + + + com.baomidou + mybatis-plus-boot-starter + 3.5.3.2 + + + com.baomidou + mybatis-plus-boot-starter-test + 3.5.3.2 + test + + + com.alibaba + druid-spring-boot-starter + 1.2.19 + + + + org.apache.velocity + velocity-engine-core + 2.3 + + + org.springframework.boot + spring-boot-starter-data-redis + + + com.auth0 + java-jwt + 4.4.0 + + + org.springframework.boot + spring-boot-starter-actuator + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + ${project.basedir}/src/main/kotlin ${project.basedir}/src/test/kotlin - - org.graalvm.buildtools - native-maven-plugin - org.springframework.boot spring-boot-maven-plugin - - - - org.projectlombok - lombok - - - org.jetbrains.kotlin kotlin-maven-plugin + + + compile + process-sources + + compile + + + + src/main/kotlin + target/generated-sources/annotations + + + + -Xjsr305=strict @@ -103,5 +204,4 @@ - diff --git a/src/main/kotlin/top/fatweb/api/FatWebApiApplication.kt b/src/main/kotlin/top/fatweb/api/FatWebApiApplication.kt index f77dbd7..3c635e2 100644 --- a/src/main/kotlin/top/fatweb/api/FatWebApiApplication.kt +++ b/src/main/kotlin/top/fatweb/api/FatWebApiApplication.kt @@ -1,11 +1,36 @@ package top.fatweb.api +import org.slf4j.LoggerFactory import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.runApplication +import org.springframework.transaction.annotation.EnableTransactionManagement +import java.io.File +import java.util.* @SpringBootApplication +@EnableTransactionManagement class FatWebApiApplication fun main(args: Array) { - runApplication(*args) + val logger = LoggerFactory.getLogger("main") + + if (!File("data").isDirectory) { + if (!File("data").mkdir()) { + logger.error("Can not create directory 'data', please try again later.") + return + } + } + + if (File("application-config.yml").exists()) { + runApplication(*args) + } else { + logger.warn("File ‘application.yml’ cannot be found in the running path. The configuration file template 'application.example.yml' has been created in directory 'data'. Please change the configuration file content, rename it to 'application.yml' and put it in the running path, and then restart the server.") + FatWebApiApplication::class.java.getResource("/application-config-template.yml")?.readText()?.let { + File("data/application-config.example.yml").writeText( + it.replace( + "\$uuid\$", UUID.randomUUID().toString().replace("-", "") + ) + ) + } + } } diff --git a/src/main/kotlin/top/fatweb/api/annotation/ApiVersion.kt b/src/main/kotlin/top/fatweb/api/annotation/ApiVersion.kt new file mode 100644 index 0000000..a0b064c --- /dev/null +++ b/src/main/kotlin/top/fatweb/api/annotation/ApiVersion.kt @@ -0,0 +1,11 @@ +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 +) diff --git a/src/main/kotlin/top/fatweb/api/config/FilterConfig.kt b/src/main/kotlin/top/fatweb/api/config/FilterConfig.kt new file mode 100644 index 0000000..71374f9 --- /dev/null +++ b/src/main/kotlin/top/fatweb/api/config/FilterConfig.kt @@ -0,0 +1,18 @@ +package top.fatweb.api.config + +import org.springframework.boot.web.servlet.FilterRegistrationBean +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import top.fatweb.api.filter.ExceptionFilter + +@Configuration +class FilterConfig { + @Bean + fun exceptionFilterRegistrationBean(exceptionFilter: ExceptionFilter): FilterRegistrationBean { + val registrationBean = FilterRegistrationBean(exceptionFilter) + registrationBean.setBeanName("exceptionFilter") + registrationBean.order = -100 + + return registrationBean + } +} \ No newline at end of file diff --git a/src/main/kotlin/top/fatweb/api/config/MybatisPlusConfig.kt b/src/main/kotlin/top/fatweb/api/config/MybatisPlusConfig.kt new file mode 100644 index 0000000..2ce739c --- /dev/null +++ b/src/main/kotlin/top/fatweb/api/config/MybatisPlusConfig.kt @@ -0,0 +1,20 @@ +package top.fatweb.api.config + +import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor +import com.baomidou.mybatisplus.extension.plugins.inner.OptimisticLockerInnerInterceptor +import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class MybatisPlusConfig { + @Bean + fun mybatisPlusInterceptor(): MybatisPlusInterceptor { + val mybatisPlusInterceptor = MybatisPlusInterceptor() + mybatisPlusInterceptor.addInnerInterceptor(OptimisticLockerInnerInterceptor()) + mybatisPlusInterceptor.addInnerInterceptor(PaginationInnerInterceptor()) + + return mybatisPlusInterceptor + } + +} \ No newline at end of file diff --git a/src/main/kotlin/top/fatweb/api/config/RedisConfig.kt b/src/main/kotlin/top/fatweb/api/config/RedisConfig.kt new file mode 100644 index 0000000..14d3ee0 --- /dev/null +++ b/src/main/kotlin/top/fatweb/api/config/RedisConfig.kt @@ -0,0 +1,42 @@ +package top.fatweb.api.config + +import com.fasterxml.jackson.annotation.JsonAutoDetect +import com.fasterxml.jackson.annotation.JsonTypeInfo +import com.fasterxml.jackson.annotation.PropertyAccessor +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.data.redis.connection.RedisConnectionFactory +import org.springframework.data.redis.core.RedisTemplate +import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer +import org.springframework.data.redis.serializer.StringRedisSerializer + +@Configuration +class RedisConfig { + @Bean + fun redisTemplate(redisConnectionFactory: RedisConnectionFactory): RedisTemplate<*, *> { + val redisTemplate = RedisTemplate() + redisTemplate.connectionFactory = redisConnectionFactory + val stringRedisSerializer = StringRedisSerializer() + val objectMapper = ObjectMapper().registerModules(JavaTimeModule()).apply { + setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY) + activateDefaultTyping( + this.polymorphicTypeValidator, ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY + ) + } + val anyJackson2JsonRedisSerializer = Jackson2JsonRedisSerializer(objectMapper, Any::class.java) + + // 使用StringRedisSerializer来序列化和反序列化redis的key值 + redisTemplate.keySerializer = stringRedisSerializer + redisTemplate.valueSerializer = anyJackson2JsonRedisSerializer + + // Hash的key也采用StringRedisSerializer的序列化方式 + redisTemplate.hashKeySerializer = stringRedisSerializer + redisTemplate.hashValueSerializer = anyJackson2JsonRedisSerializer + + redisTemplate.afterPropertiesSet() + + return redisTemplate + } +} \ No newline at end of file diff --git a/src/main/kotlin/top/fatweb/api/config/SecurityConfig.kt b/src/main/kotlin/top/fatweb/api/config/SecurityConfig.kt new file mode 100644 index 0000000..5836f3d --- /dev/null +++ b/src/main/kotlin/top/fatweb/api/config/SecurityConfig.kt @@ -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 -> csrfConfigurer.disable() } + // Do not get SecurityContent by Session + .sessionManagement { sessionManagementConfigurer: SessionManagementConfigurer -> + sessionManagementConfigurer.sessionCreationPolicy( + SessionCreationPolicy.STATELESS + ) + } + .authorizeHttpRequests { authorizeHttpRequests: AuthorizeHttpRequestsConfigurer.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 -> logoutConfigurer.disable() } + + .exceptionHandling { exceptionHandlingConfigurer: ExceptionHandlingConfigurer -> + exceptionHandlingConfigurer.authenticationEntryPoint( + authenticationEntryPointHandler + ) + exceptionHandlingConfigurer.accessDeniedHandler( + accessDeniedHandler + ) + } + + .cors { cors: CorsConfigurer -> + cors.configurationSource( + corsConfigurationSource() + ) + } + + .addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter::class.java).build() +} \ No newline at end of file diff --git a/src/main/kotlin/top/fatweb/api/config/SwaggerConfig.kt b/src/main/kotlin/top/fatweb/api/config/SwaggerConfig.kt new file mode 100644 index 0000000..fde4588 --- /dev/null +++ b/src/main/kotlin/top/fatweb/api/config/SwaggerConfig.kt @@ -0,0 +1,23 @@ +package top.fatweb.api.config + +import io.swagger.v3.oas.models.OpenAPI +import io.swagger.v3.oas.models.info.Contact +import io.swagger.v3.oas.models.info.Info +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import top.fatweb.api.constant.ServerConstants + +@Configuration +class SwaggerConfig { + + @Bean + fun customOpenAPI(): OpenAPI? { + val contact = Contact().name("FatttSnake").url("https://fatweb.top").email("fatttsnake@fatweb.top") + return OpenAPI().info( + Info().title("FatWeb API 文档").description("FatWeb 后端 API 文档,包含各个 Controller 调用信息") + .contact(contact).version( + ServerConstants.version + ) + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/top/fatweb/api/config/WebMvcRegistrationsConfig.kt b/src/main/kotlin/top/fatweb/api/config/WebMvcRegistrationsConfig.kt new file mode 100644 index 0000000..1db6a0f --- /dev/null +++ b/src/main/kotlin/top/fatweb/api/config/WebMvcRegistrationsConfig.kt @@ -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() +} \ No newline at end of file diff --git a/src/main/kotlin/top/fatweb/api/constant/SecurityConstants.kt b/src/main/kotlin/top/fatweb/api/constant/SecurityConstants.kt new file mode 100644 index 0000000..c2b1738 --- /dev/null +++ b/src/main/kotlin/top/fatweb/api/constant/SecurityConstants.kt @@ -0,0 +1,25 @@ +package top.fatweb.api.constant + +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.stereotype.Component +import java.util.concurrent.TimeUnit + +@Component +@ConfigurationProperties("app.security") +object SecurityConstants { + var headerString = "Authorization" + + var tokenPrefix = "Bearer " + + var jwtTtl = 2L + + var jwtTtlUnit = TimeUnit.HOURS + + var jwtKey = "FatWeb" + + var jwtIssuer = "FatWeb" + + var redisTtl = 20L + + var redisTtlUnit = TimeUnit.MINUTES +} \ No newline at end of file diff --git a/src/main/kotlin/top/fatweb/api/constant/ServerConstants.kt b/src/main/kotlin/top/fatweb/api/constant/ServerConstants.kt new file mode 100644 index 0000000..5f55621 --- /dev/null +++ b/src/main/kotlin/top/fatweb/api/constant/ServerConstants.kt @@ -0,0 +1,18 @@ +package top.fatweb.api.constant + +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.stereotype.Component +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.ZonedDateTime + +@Component +@ConfigurationProperties("app") +object ServerConstants { + lateinit var version: String + + lateinit var buildTime: String + + fun buildZoneDateTime(zoneId: ZoneId = ZoneId.systemDefault()): ZonedDateTime = + LocalDateTime.parse(buildTime).atZone(ZoneId.of("UTC")).withZoneSameInstant(zoneId) +} \ No newline at end of file diff --git a/src/main/kotlin/top/fatweb/api/controller/ExceptionController.kt b/src/main/kotlin/top/fatweb/api/controller/ExceptionController.kt new file mode 100644 index 0000000..e5dfad2 --- /dev/null +++ b/src/main/kotlin/top/fatweb/api/controller/ExceptionController.kt @@ -0,0 +1,16 @@ +package top.fatweb.api.controller + +import io.swagger.v3.oas.annotations.Hidden +import jakarta.servlet.http.HttpServletRequest +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@Hidden +@RestController +@RequestMapping("/error") +class ExceptionController { + @RequestMapping("/thrown") + fun thrown(request: HttpServletRequest) { + throw request.getAttribute("filter.error") as RuntimeException + } +} \ No newline at end of file diff --git a/src/main/kotlin/top/fatweb/api/controller/UserController.kt b/src/main/kotlin/top/fatweb/api/controller/UserController.kt new file mode 100644 index 0000000..188caf5 --- /dev/null +++ b/src/main/kotlin/top/fatweb/api/controller/UserController.kt @@ -0,0 +1,17 @@ +package top.fatweb.api.controller + +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +/** + *

+ * 用户 前端控制器 + *

+ * + * @author FatttSnake + * @since 2023-10-04 + */ +@RestController +@RequestMapping("/api/user") +class UserController + diff --git a/src/main/kotlin/top/fatweb/api/controller/permission/AuthenticationController.kt b/src/main/kotlin/top/fatweb/api/controller/permission/AuthenticationController.kt new file mode 100644 index 0000000..63f68b4 --- /dev/null +++ b/src/main/kotlin/top/fatweb/api/controller/permission/AuthenticationController.kt @@ -0,0 +1,47 @@ +package top.fatweb.api.controller.permission + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.servlet.http.HttpServletRequest +import jakarta.validation.Valid +import org.springframework.web.bind.annotation.* +import top.fatweb.api.annotation.ApiVersion +import top.fatweb.api.converter.UserConverter +import top.fatweb.api.entity.common.ResponseCode +import top.fatweb.api.entity.common.ResponseResult +import top.fatweb.api.param.LoginParam +import top.fatweb.api.service.permission.IAuthenticationService +import top.fatweb.api.util.WebUtil + +@Tag(name = "身份认证", description = "身份认证相关接口") +@Suppress("MVCPathVariableInspection") +@RequestMapping("/api/{apiVersion}") +@ApiVersion(2) +@RestController +class AuthenticationController(val authenticationService: IAuthenticationService, val userConverter: UserConverter) { + @Operation(summary = "登录") + @PostMapping("/login") + fun login(@Valid @RequestBody loginParam: LoginParam) = + ResponseResult.success( + ResponseCode.SYSTEM_LOGIN_SUCCESS, + "Login success", + authenticationService.login(userConverter.loginParamToUser(loginParam)) + ) + + @Operation(summary = "登出") + @PostMapping("/logout") + fun logout(request: HttpServletRequest) = + when (authenticationService.logout(WebUtil.getToken(request))) { + true -> ResponseResult.success(ResponseCode.SYSTEM_LOGOUT_SUCCESS, "Logout success", null) + false -> ResponseResult.fail(ResponseCode.SYSTEM_LOGOUT_FAILED, "Logout failed", null) + } + + @Operation(summary = "更新 Token") + @GetMapping("/token") + fun renewToken(request: HttpServletRequest) = + ResponseResult.success( + ResponseCode.SYSTEM_TOKEN_RENEW_SUCCESS, + "Token renew success", + authenticationService.renewToken(WebUtil.getToken(request)) + ) +} \ No newline at end of file diff --git a/src/main/kotlin/top/fatweb/api/converter/UserConverter.kt b/src/main/kotlin/top/fatweb/api/converter/UserConverter.kt new file mode 100644 index 0000000..1bfd381 --- /dev/null +++ b/src/main/kotlin/top/fatweb/api/converter/UserConverter.kt @@ -0,0 +1,17 @@ +package top.fatweb.api.converter + +import org.springframework.stereotype.Component +import top.fatweb.api.entity.permission.User +import top.fatweb.api.param.LoginParam + +@Component +object UserConverter { + fun loginParamToUser(loginParam: LoginParam): User { + val user = User().apply { + username = loginParam.username + password = loginParam.password + } + + return user + } +} \ No newline at end of file diff --git a/src/main/kotlin/top/fatweb/api/entity/common/BusinessCode.kt b/src/main/kotlin/top/fatweb/api/entity/common/BusinessCode.kt new file mode 100644 index 0000000..02178f5 --- /dev/null +++ b/src/main/kotlin/top/fatweb/api/entity/common/BusinessCode.kt @@ -0,0 +1,6 @@ +package top.fatweb.api.entity.common + +enum class BusinessCode(val code: Int) { + SYSTEM(100), + DATABASE(200) +} \ No newline at end of file diff --git a/src/main/kotlin/top/fatweb/api/entity/common/ResponseCode.kt b/src/main/kotlin/top/fatweb/api/entity/common/ResponseCode.kt new file mode 100644 index 0000000..f6095bb --- /dev/null +++ b/src/main/kotlin/top/fatweb/api/entity/common/ResponseCode.kt @@ -0,0 +1,24 @@ +package top.fatweb.api.entity.common + +enum class ResponseCode(val code: Int) { + SYSTEM_OK(BusinessCode.SYSTEM, 0), + SYSTEM_LOGIN_SUCCESS(BusinessCode.SYSTEM, 20), + SYSTEM_PASSWORD_CHANGE_SUCCESS(BusinessCode.SYSTEM, 21), + SYSTEM_LOGOUT_SUCCESS(BusinessCode.SYSTEM, 22), + SYSTEM_TOKEN_RENEW_SUCCESS(BusinessCode.SYSTEM, 23), + SYSTEM_UNAUTHORIZED(BusinessCode.SYSTEM, 30), + SYSTEM_USERNAME_NOT_FOUND(BusinessCode.SYSTEM, 31), + SYSTEM_ACCESS_DENIED(BusinessCode.SYSTEM, 32), + SYSTEM_USER_DISABLE(BusinessCode.SYSTEM, 33), + SYSTEM_LOGIN_USERNAME_PASSWORD_ERROR(BusinessCode.SYSTEM, 34), + SYSTEM_OLD_PASSWORD_NOT_MATCH(BusinessCode.SYSTEM, 35), + SYSTEM_LOGOUT_FAILED(BusinessCode.SYSTEM, 36), + SYSTEM_TOKEN_ILLEGAL(BusinessCode.SYSTEM, 37), + SYSTEM_TOKEN_HAS_EXPIRED(BusinessCode.SYSTEM, 38), + SYSTEM_REQUEST_ILLEGAL(BusinessCode.SYSTEM, 40), + SYSTEM_ARGUMENT_NOT_VALID(BusinessCode.SYSTEM, 41), + SYSTEM_ERROR(BusinessCode.SYSTEM, 50), + SYSTEM_TIMEOUT(BusinessCode.SYSTEM, 51); + + constructor(businessCode: BusinessCode, code: Int) : this(businessCode.code * 100 + code) +} \ No newline at end of file diff --git a/src/main/kotlin/top/fatweb/api/entity/common/ResponseResult.kt b/src/main/kotlin/top/fatweb/api/entity/common/ResponseResult.kt new file mode 100644 index 0000000..9559c1d --- /dev/null +++ b/src/main/kotlin/top/fatweb/api/entity/common/ResponseResult.kt @@ -0,0 +1,29 @@ +package top.fatweb.api.entity.common + +import io.swagger.v3.oas.annotations.media.Schema +import java.io.Serializable + +class ResponseResult private constructor( + @Schema(description = "响应码", defaultValue = "200") + val code: Int, + + @Schema(description = "是否调用成功") + val success: Boolean, + + @Schema(description = "信息") + val msg: String, + + @Schema(description = "数据") + val data: T? +) : Serializable { + companion object { + fun build(code: ResponseCode, success: Boolean, msg: String, data: T?) = + ResponseResult(code.code, success, msg, data) + + fun success(code: ResponseCode = ResponseCode.SYSTEM_OK, msg: String = "success", data: T? = null) = + build(code, true, msg, data) + + fun fail(code: ResponseCode = ResponseCode.SYSTEM_ERROR, msg: String = "fail", data: T? = null) = + build(code, false, msg, data) + } +} \ No newline at end of file diff --git a/src/main/kotlin/top/fatweb/api/entity/permission/LoginUser.kt b/src/main/kotlin/top/fatweb/api/entity/permission/LoginUser.kt new file mode 100644 index 0000000..951d1e2 --- /dev/null +++ b/src/main/kotlin/top/fatweb/api/entity/permission/LoginUser.kt @@ -0,0 +1,44 @@ +package top.fatweb.api.entity.permission + +import com.fasterxml.jackson.annotation.JsonIgnore +import com.fasterxml.jackson.annotation.JsonTypeInfo +import org.springframework.security.core.GrantedAuthority +import org.springframework.security.core.userdetails.UserDetails + +@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS) +class LoginUser() : UserDetails { + lateinit var user: User + + @JsonIgnore + private var authorities: List? = null + + constructor(user: User) : this() { + this.user = user + } + + @JsonIgnore + override fun getAuthorities(): List { + authorities?.let { return it } + authorities = emptyList() + + return authorities as List + } + + @JsonIgnore + override fun getPassword(): String? = user.password + + @JsonIgnore + override fun getUsername(): String? = user.username + + @JsonIgnore + override fun isAccountNonExpired(): Boolean = true + + @JsonIgnore + override fun isAccountNonLocked(): Boolean = true + + @JsonIgnore + override fun isCredentialsNonExpired(): Boolean = true + + @JsonIgnore + override fun isEnabled(): Boolean = user.enable == 1 +} \ No newline at end of file diff --git a/src/main/kotlin/top/fatweb/api/entity/permission/User.kt b/src/main/kotlin/top/fatweb/api/entity/permission/User.kt new file mode 100644 index 0000000..bff63ab --- /dev/null +++ b/src/main/kotlin/top/fatweb/api/entity/permission/User.kt @@ -0,0 +1,54 @@ +package top.fatweb.api.entity.permission + +import com.baomidou.mybatisplus.annotation.* +import java.io.Serializable + +/** + *

+ * 用户 + *

+ * + * @author FatttSnake + * @since 2023-10-04 + */ +@TableName("t_user") +class User() : Serializable { + constructor(username: String, password: String, enable: Boolean = true) : this() { + this.username = username + this.password = password + this.enable = if (enable) 1 else 0 + } + + @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}" + } +} diff --git a/src/main/kotlin/top/fatweb/api/exception/TokenHasExpiredException.kt b/src/main/kotlin/top/fatweb/api/exception/TokenHasExpiredException.kt new file mode 100644 index 0000000..2b0521f --- /dev/null +++ b/src/main/kotlin/top/fatweb/api/exception/TokenHasExpiredException.kt @@ -0,0 +1,3 @@ +package top.fatweb.api.exception + +class TokenHasExpiredException : RuntimeException("Token has expired") \ No newline at end of file diff --git a/src/main/kotlin/top/fatweb/api/filter/ExceptionFilter.kt b/src/main/kotlin/top/fatweb/api/filter/ExceptionFilter.kt new file mode 100644 index 0000000..3097036 --- /dev/null +++ b/src/main/kotlin/top/fatweb/api/filter/ExceptionFilter.kt @@ -0,0 +1,23 @@ +package top.fatweb.api.filter + +import jakarta.servlet.Filter +import jakarta.servlet.FilterChain +import jakarta.servlet.ServletRequest +import jakarta.servlet.ServletResponse +import org.springframework.stereotype.Component + +@Component +class ExceptionFilter : Filter { + override fun doFilter( + servletRequest: ServletRequest?, + servletResponse: ServletResponse?, + filterChain: FilterChain? + ) { + try { + filterChain!!.doFilter(servletRequest, servletResponse) + } catch (e: Exception) { + servletRequest?.setAttribute("filter.error", e) + servletRequest?.getRequestDispatcher("/error/thrown")?.forward(servletRequest, servletResponse) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/top/fatweb/api/filter/JwtAuthenticationTokenFilter.kt b/src/main/kotlin/top/fatweb/api/filter/JwtAuthenticationTokenFilter.kt new file mode 100644 index 0000000..048aa39 --- /dev/null +++ b/src/main/kotlin/top/fatweb/api/filter/JwtAuthenticationTokenFilter.kt @@ -0,0 +1,46 @@ +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.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 top.fatweb.api.util.WebUtil + +@Component +class JwtAuthenticationTokenFilter(private val redisUtil: RedisUtil) : OncePerRequestFilter() { + override fun doFilterInternal( + request: HttpServletRequest, + response: HttpServletResponse, + filterChain: FilterChain + ) { + val tokenWithPrefix = request.getHeader(SecurityConstants.headerString) + + if (!StringUtils.hasText(tokenWithPrefix) || "/error/thrown" == request.servletPath) { + filterChain.doFilter(request, response) + return + } + + val token = WebUtil.getToken(tokenWithPrefix) + JwtUtil.parseJwt(token) + + val redisKey = "${SecurityConstants.jwtIssuer}_login:" + token + val loginUser = redisUtil.getObject(redisKey) + loginUser ?: let { throw TokenHasExpiredException() } + + redisUtil.setExpire(redisKey, SecurityConstants.redisTtl, SecurityConstants.redisTtlUnit) + + val authenticationToken = UsernamePasswordAuthenticationToken(loginUser, null, loginUser.authorities) + SecurityContextHolder.getContext().authentication = authenticationToken + + filterChain.doFilter(request, response) + } +} \ No newline at end of file diff --git a/src/main/kotlin/top/fatweb/api/handler/ExceptionHandler.kt b/src/main/kotlin/top/fatweb/api/handler/ExceptionHandler.kt new file mode 100644 index 0000000..5e742e1 --- /dev/null +++ b/src/main/kotlin/top/fatweb/api/handler/ExceptionHandler.kt @@ -0,0 +1,67 @@ +package top.fatweb.api.handler + +import com.auth0.jwt.exceptions.JWTDecodeException +import com.auth0.jwt.exceptions.SignatureVerificationException +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import org.springframework.http.converter.HttpMessageNotReadableException +import org.springframework.security.authentication.BadCredentialsException +import org.springframework.security.authentication.InsufficientAuthenticationException +import org.springframework.security.authentication.InternalAuthenticationServiceException +import org.springframework.web.bind.MethodArgumentNotValidException +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.annotation.RestControllerAdvice +import top.fatweb.api.entity.common.ResponseCode +import top.fatweb.api.entity.common.ResponseResult +import top.fatweb.api.exception.TokenHasExpiredException + +@RestControllerAdvice +class ExceptionHandler { + private val log: Logger = LoggerFactory.getLogger(this::class.java) + + @ExceptionHandler(value = [Exception::class]) + fun exceptionHandler(e: Exception): ResponseResult<*> { + return when (e) { + is InsufficientAuthenticationException -> { + log.debug(e.localizedMessage, e) + ResponseResult.fail(ResponseCode.SYSTEM_UNAUTHORIZED, e.localizedMessage, null) + } + + is HttpMessageNotReadableException -> { + log.debug(e.localizedMessage, e) + ResponseResult.fail(ResponseCode.SYSTEM_REQUEST_ILLEGAL, e.localizedMessage.split(":")[0], null) + } + + is MethodArgumentNotValidException -> { + log.debug(e.localizedMessage, e) + val errorMessage = e.allErrors.map { error -> error.defaultMessage }.joinToString(". ") + ResponseResult.fail(ResponseCode.SYSTEM_ARGUMENT_NOT_VALID, errorMessage, null) + } + + is InternalAuthenticationServiceException -> { + log.debug(e.localizedMessage, e) + ResponseResult.fail(ResponseCode.SYSTEM_USERNAME_NOT_FOUND, e.localizedMessage, null) + } + + is BadCredentialsException -> { + log.debug(e.localizedMessage, e) + ResponseResult.fail(ResponseCode.SYSTEM_LOGIN_USERNAME_PASSWORD_ERROR, e.localizedMessage, null) + } + + is SignatureVerificationException, is JWTDecodeException -> { + log.debug(e.localizedMessage, e) + ResponseResult.fail(ResponseCode.SYSTEM_TOKEN_ILLEGAL, "Token illegal", null) + } + + is TokenHasExpiredException -> { + log.debug(e.localizedMessage, e) + ResponseResult.fail(ResponseCode.SYSTEM_TOKEN_HAS_EXPIRED, e.localizedMessage, null) + } + + else -> { + log.error(e.localizedMessage, e) + ResponseResult.fail(ResponseCode.SYSTEM_ERROR, data = null) + } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/top/fatweb/api/handler/JwtAccessDeniedHandler.kt b/src/main/kotlin/top/fatweb/api/handler/JwtAccessDeniedHandler.kt new file mode 100644 index 0000000..22e03c6 --- /dev/null +++ b/src/main/kotlin/top/fatweb/api/handler/JwtAccessDeniedHandler.kt @@ -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) + } +} \ No newline at end of file diff --git a/src/main/kotlin/top/fatweb/api/handler/JwtAuthenticationEntryPointHandler.kt b/src/main/kotlin/top/fatweb/api/handler/JwtAuthenticationEntryPointHandler.kt new file mode 100644 index 0000000..cf5b808 --- /dev/null +++ b/src/main/kotlin/top/fatweb/api/handler/JwtAuthenticationEntryPointHandler.kt @@ -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) + } +} \ No newline at end of file diff --git a/src/main/kotlin/top/fatweb/api/mapper/UserMapper.kt b/src/main/kotlin/top/fatweb/api/mapper/UserMapper.kt new file mode 100644 index 0000000..8b37df3 --- /dev/null +++ b/src/main/kotlin/top/fatweb/api/mapper/UserMapper.kt @@ -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 + +/** + *

+ * 用户 Mapper 接口 + *

+ * + * @author FatttSnake + * @since 2023-10-04 + */ +@Mapper +interface UserMapper : BaseMapper diff --git a/src/main/kotlin/top/fatweb/api/param/LoginParam.kt b/src/main/kotlin/top/fatweb/api/param/LoginParam.kt new file mode 100644 index 0000000..701ab12 --- /dev/null +++ b/src/main/kotlin/top/fatweb/api/param/LoginParam.kt @@ -0,0 +1,17 @@ +package top.fatweb.api.param + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.NotBlank +import java.io.Serializable + +@Schema(description = "登录请求参数") +class LoginParam : Serializable { + + @Schema(description = "用户名", example = "test", required = true) + @NotBlank(message = "Username can not be blank") + val username: String? = null + + @Schema(description = "密码", example = "test123456", required = true) + @NotBlank(message = "Password can not be blank") + val password: String? = null +} \ No newline at end of file diff --git a/src/main/kotlin/top/fatweb/api/service/IUserService.kt b/src/main/kotlin/top/fatweb/api/service/IUserService.kt new file mode 100644 index 0000000..7bc58e9 --- /dev/null +++ b/src/main/kotlin/top/fatweb/api/service/IUserService.kt @@ -0,0 +1,14 @@ +package top.fatweb.api.service + +import com.baomidou.mybatisplus.extension.service.IService +import top.fatweb.api.entity.permission.User + +/** + *

+ * 用户 服务类 + *

+ * + * @author FatttSnake + * @since 2023-10-04 + */ +interface IUserService : IService diff --git a/src/main/kotlin/top/fatweb/api/service/impl/UserServiceImpl.kt b/src/main/kotlin/top/fatweb/api/service/impl/UserServiceImpl.kt new file mode 100644 index 0000000..e522f6b --- /dev/null +++ b/src/main/kotlin/top/fatweb/api/service/impl/UserServiceImpl.kt @@ -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 + +/** + *

+ * 用户 服务实现类 + *

+ * + * @author FatttSnake + * @since 2023-10-04 + */ +@Service +class UserServiceImpl : ServiceImpl(), IUserService diff --git a/src/main/kotlin/top/fatweb/api/service/permission/IAuthenticationService.kt b/src/main/kotlin/top/fatweb/api/service/permission/IAuthenticationService.kt new file mode 100644 index 0000000..28becfc --- /dev/null +++ b/src/main/kotlin/top/fatweb/api/service/permission/IAuthenticationService.kt @@ -0,0 +1,13 @@ +package top.fatweb.api.service.permission + +import top.fatweb.api.entity.permission.User +import top.fatweb.api.vo.LoginVo +import top.fatweb.api.vo.TokenVo + +interface IAuthenticationService { + fun login(user: User): LoginVo + + fun logout(token: String): Boolean + + fun renewToken(token: String): TokenVo +} \ No newline at end of file diff --git a/src/main/kotlin/top/fatweb/api/service/permission/impl/AuthenticationServiceImpl.kt b/src/main/kotlin/top/fatweb/api/service/permission/impl/AuthenticationServiceImpl.kt new file mode 100644 index 0000000..d08bc10 --- /dev/null +++ b/src/main/kotlin/top/fatweb/api/service/permission/impl/AuthenticationServiceImpl.kt @@ -0,0 +1,65 @@ +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 top.fatweb.api.vo.LoginVo +import top.fatweb.api.vo.TokenVo + +@Service +class AuthenticationServiceImpl( + private val authenticationManager: AuthenticationManager, + private val redisUtil: RedisUtil +) : IAuthenticationService { + override fun login(user: User): LoginVo { + 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 redisKey = "${SecurityConstants.jwtIssuer}_login:" + jwt + redisUtil.setObject(redisKey, loginUser, SecurityConstants.redisTtl, SecurityConstants.redisTtlUnit) + + return LoginVo(jwt) + } + + override fun logout(token: String): Boolean = + redisUtil.delObject("${SecurityConstants.jwtIssuer}_login:" + token) + + override fun renewToken(token: String): TokenVo { + val oldRedisKey = "${SecurityConstants.jwtIssuer}_login:" + token + redisUtil.delObject(oldRedisKey) + val jwt = JwtUtil.createJwt(WebUtil.getLoginUserId().toString()) + + jwt ?: let { + throw RuntimeException("Login failed") + } + + val redisKey = "${SecurityConstants.jwtIssuer}_login:" + jwt + redisUtil.setObject( + redisKey, + WebUtil.getLoginUser(), + SecurityConstants.redisTtl, + SecurityConstants.redisTtlUnit + ) + + return TokenVo(jwt) + } +} \ No newline at end of file diff --git a/src/main/kotlin/top/fatweb/api/service/permission/impl/UserDetailsServiceImpl.kt b/src/main/kotlin/top/fatweb/api/service/permission/impl/UserDetailsServiceImpl.kt new file mode 100644 index 0000000..6b78e76 --- /dev/null +++ b/src/main/kotlin/top/fatweb/api/service/permission/impl/UserDetailsServiceImpl.kt @@ -0,0 +1,19 @@ +package top.fatweb.api.service.permission.impl + +import com.baomidou.mybatisplus.extension.kotlin.KtQueryWrapper +import org.springframework.security.core.userdetails.UserDetails +import org.springframework.security.core.userdetails.UserDetailsService +import org.springframework.stereotype.Service +import top.fatweb.api.entity.permission.LoginUser +import top.fatweb.api.entity.permission.User +import top.fatweb.api.service.IUserService + +@Service +class UserDetailsServiceImpl(val userService: IUserService) : UserDetailsService { + override fun loadUserByUsername(username: String?): UserDetails { + val user = userService.getOne(KtQueryWrapper(User()).eq(User::username, username)) + user ?: let { throw Exception("Username not found") } + + return LoginUser(user) + } +} \ No newline at end of file diff --git a/src/main/kotlin/top/fatweb/api/util/ApiResponseMappingHandlerMapping.kt b/src/main/kotlin/top/fatweb/api/util/ApiResponseMappingHandlerMapping.kt new file mode 100644 index 0000000..8dafc0e --- /dev/null +++ b/src/main/kotlin/top/fatweb/api/util/ApiResponseMappingHandlerMapping.kt @@ -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? { + 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) +} \ No newline at end of file diff --git a/src/main/kotlin/top/fatweb/api/util/ApiVersionCondition.kt b/src/main/kotlin/top/fatweb/api/util/ApiVersionCondition.kt new file mode 100644 index 0000000..381edaa --- /dev/null +++ b/src/main/kotlin/top/fatweb/api/util/ApiVersionCondition.kt @@ -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 { + 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 +} \ No newline at end of file diff --git a/src/main/kotlin/top/fatweb/api/util/JwtUtil.kt b/src/main/kotlin/top/fatweb/api/util/JwtUtil.kt new file mode 100644 index 0000000..ea7c5aa --- /dev/null +++ b/src/main/kotlin/top/fatweb/api/util/JwtUtil.kt @@ -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) +} \ No newline at end of file diff --git a/src/main/kotlin/top/fatweb/api/util/RedisUtil.kt b/src/main/kotlin/top/fatweb/api/util/RedisUtil.kt new file mode 100644 index 0000000..35991a8 --- /dev/null +++ b/src/main/kotlin/top/fatweb/api/util/RedisUtil.kt @@ -0,0 +1,183 @@ +package top.fatweb.api.util + +import org.springframework.data.redis.core.BoundSetOperations +import org.springframework.data.redis.core.RedisTemplate +import org.springframework.stereotype.Component +import java.util.concurrent.TimeUnit + +@Suppress("UNCHECKED_CAST") +@Component +class RedisUtil(private val redisTemplate: RedisTemplate) { + /** + * 设置有效时间 + * + * @param key 缓存的键 + * @param timeout 超时时间 + * @param timeUnit 时间颗粒度 + * @return true=设置成功;false=设置失败 + */ + fun setExpire(key: String, timeout: Long, timeUnit: TimeUnit = TimeUnit.SECONDS) = + redisTemplate.expire(key, timeout, timeUnit) + + /** + * 获取有效时间 + * + * @param key 缓存的键 + * @return 有效时间 + */ + fun getExpire(key: String, timeUnit: TimeUnit = TimeUnit.SECONDS) = redisTemplate.getExpire(key, timeUnit) + + /** + * 判断 key 是否存在 + * + * @param key 缓存的键 + * @return true=存在; false=不存在 + */ + fun hasKey(key: String) = redisTemplate.hasKey(key) + + /** + * 获得缓存的基本对象列表 + * + * @param pattern 字符串前缀 + * @return 对象列表 + */ + fun keys(pattern: String): Set = redisTemplate.keys(pattern) + + /** + * 缓存基本的对象,Integer、String、实体类等 + * + * @param key 缓存的键 + * @param value 缓存的值 + */ + fun setObject(key: String, value: Any) = redisTemplate.opsForValue().set(key, value) + + /** + * 缓存基本的对象,Integer、String、实体类等 + * + * @param key 缓存的键 + * @param value 缓存的值 + * @param timeout 超时时间 + * @param timeUnit 时间颗粒度 + */ + fun setObject(key: String, value: Any, timeout: Long, timeUnit: TimeUnit = TimeUnit.SECONDS) = + redisTemplate.opsForValue().set(key, value, timeout, timeUnit) + + + /** + * 获得缓存的基本对象 + * + * @param key 缓存的键 + * @return 缓存的值 + */ + fun getObject(key: String) = redisTemplate.opsForValue().get(key) as? T + + /** + * 删除单个对象 + * + * @param key 缓存的键 + * @return true=删除成功;false=删除失败 + */ + fun delObject(key: String) = redisTemplate.delete(key) + + /** + * 删除对象集合 + * + * @param collection 键集合 + * @return 删除个数 + */ + fun delObject(collection: Collection) = redisTemplate.delete(collection) + + /** + * 缓存 List 数据 + * + * @param key 缓存的键 + * @param dataList 缓存的 List 数据 + * @return 缓存的个数 + */ + fun setList(key: String, dataList: List) = redisTemplate.opsForList().rightPushAll(key, dataList) + + /** + * 获得缓存的 List 数据 + * + * @param key 缓存的键 + * @return 缓存的键对应的 List 数据 + */ + fun getList(key: String): List? = redisTemplate.opsForList().range(key, 0, -1) as? List + + /** + * 缓存 Set 数据 + * + * @param key 缓存的键 + * @param dataSet 缓存的 Set 数据 + * @return 缓存数据的对象 + */ + fun setSet(key: String, dataSet: Set): BoundSetOperations { + val boundSetOps: BoundSetOperations = redisTemplate.boundSetOps(key) + for (data in dataSet) { + boundSetOps.add(data) + } + return boundSetOps + } + + /** + * 获得缓存的 Set 数据 + * + * @param key 缓存的键 + * @return 缓存的键对应的 Set 数据 + */ + fun getSet(key: String): Set? = redisTemplate.opsForSet().members(key) as? Set + + /** + * 缓存 Map 数据 + * + * @param key 缓存的键 + * @param dataMap 缓存的 Map 数据 + */ + fun setMap(key: String, dataMap: Map) = redisTemplate.opsForHash().putAll(key, dataMap) + + /** + * 获得缓存的 Map 数据 + * + * @param key 缓存的键 + * @return 缓存的键对应的 Map 数据 + */ + fun getMap(key: String): Map? = + redisTemplate.opsForHash().entries(key) as? Map + + /** + * 往 Hash 中存入数据 + * + * @param key Redis 键 + * @param hKey Hash 键 + * @param value 值 + */ + fun setMapValue(key: String, hKey: String, value: Any) = + redisTemplate.opsForHash().put(key, hKey, value) + + /** + * 获取 Hash 中的数据 + * + * @param key Redis 键 + * @param hKey Hash 键 + * @return Hash 中的对象 + */ + fun getMapValue(key: String, hKey: String) = redisTemplate.opsForHash().get(key, hKey) + + /** + * 删除 Hash 中的数据 + * + * @param key Redis 键 + * @param hKey Hash 键 + */ + fun delMapValue(key: String, hKey: String) = redisTemplate.opsForHash().delete(key, hKey) + + /** + * 获取多个 Hash 中的数据 + * + * @param key Redis 键 + * @param hKeys Hash 键集合 + * @return Hash 对象集合 + */ + fun getMultiMapValue(key: String, hKeys: Collection): List = + redisTemplate.opsForHash().multiGet(key, hKeys) +} \ No newline at end of file diff --git a/src/main/kotlin/top/fatweb/api/util/WebUtil.kt b/src/main/kotlin/top/fatweb/api/util/WebUtil.kt new file mode 100644 index 0000000..da37681 --- /dev/null +++ b/src/main/kotlin/top/fatweb/api/util/WebUtil.kt @@ -0,0 +1,16 @@ +package top.fatweb.api.util + +import jakarta.servlet.http.HttpServletRequest +import org.springframework.security.core.context.SecurityContextHolder +import top.fatweb.api.constant.SecurityConstants +import top.fatweb.api.entity.permission.LoginUser + +object WebUtil { + fun getLoginUser() = SecurityContextHolder.getContext().authentication.principal as LoginUser + + fun getLoginUserId() = getLoginUser().user.id + + fun getToken(tokenWithPrefix: String) = tokenWithPrefix.removePrefix(SecurityConstants.tokenPrefix) + + fun getToken(request: HttpServletRequest) = getToken(request.getHeader(SecurityConstants.headerString)) +} \ No newline at end of file diff --git a/src/main/kotlin/top/fatweb/api/vo/LoginVo.kt b/src/main/kotlin/top/fatweb/api/vo/LoginVo.kt new file mode 100644 index 0000000..8727d54 --- /dev/null +++ b/src/main/kotlin/top/fatweb/api/vo/LoginVo.kt @@ -0,0 +1,11 @@ +package top.fatweb.api.vo + +import io.swagger.v3.oas.annotations.media.Schema + +@Schema(description = "登录返回参数") +data class LoginVo( + @Schema( + description = "Token", + example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiJkYTllYjFkYmVmZDQ0OWRkOThlOGNjNzZlNzZkMDgyNSIsInN1YiI6IjE3MDk5ODYwNTg2Nzk5NzU5MzgiLCJpc3MiOiJGYXRXZWIiLCJpYXQiOjE2OTY1MjgxMTcsImV4cCI6MTY5NjUzNTMxN30.U2ZsyrGk7NbsP-DJfdz9xgWSfect5r2iKQnlEsscAA8" + ) val token: String +) \ No newline at end of file diff --git a/src/main/kotlin/top/fatweb/api/vo/TokenVo.kt b/src/main/kotlin/top/fatweb/api/vo/TokenVo.kt new file mode 100644 index 0000000..c17ea01 --- /dev/null +++ b/src/main/kotlin/top/fatweb/api/vo/TokenVo.kt @@ -0,0 +1,11 @@ +package top.fatweb.api.vo + +import io.swagger.v3.oas.annotations.media.Schema + +@Schema(description = "Token") +data class TokenVo( + @Schema( + description = "Token", + example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiJkYTllYjFkYmVmZDQ0OWRkOThlOGNjNzZlNzZkMDgyNSIsInN1YiI6IjE3MDk5ODYwNTg2Nzk5NzU5MzgiLCJpc3MiOiJGYXRXZWIiLCJpYXQiOjE2OTY1MjgxMTcsImV4cCI6MTY5NjUzNTMxN30.U2ZsyrGk7NbsP-DJfdz9xgWSfect5r2iKQnlEsscAA8" + ) val token: String +) \ No newline at end of file diff --git a/src/main/resources/application-config-template.yml b/src/main/resources/application-config-template.yml new file mode 100644 index 0000000..487ce6e --- /dev/null +++ b/src/main/resources/application-config-template.yml @@ -0,0 +1,43 @@ +app: + security: +# header-string: "Authorization" # The key of head to get token +# token-prefix: "Bearer " # Token prefix +# jwt-ttl: 2 # The life of token +# jwt-ttl-unit: hours # Unit of life of token [nanoseconds, microseconds, milliseconds, seconds, minutes, hours, days] + jwt-key: $uuid$ # Key to generate token (Only numbers and letters allow) +# jwt-issuer: FatWeb # Token issuer +# redis-ttl: 20 # The life of token in redis +# redis-ttl-unit: minutes # Unit of life of token in redis [nanoseconds, microseconds, milliseconds, seconds, minutes, hours, days] + +server: +# port: 8080 # Server port + +spring: + datasource: + url: jdbc:mysql://localhost # MySQL url + username: root # MySQL username + password: root # MySQL password + + druid: +# initial-size: 20 # Initial number of connections +# min-idle: 20 # Minimum number of connection pools +# max-active: 20 # Maximum number of connection pools +# max-wait: 3000 # Maximum connection timeout +# min-evictable-idle-time-millis: 300000 # Minimum time for a connection to live in the pool +# max-evictable-idle-time-millis: 900000 # Maximum time for a connection to live in the pool +# break-after-acquire-failure: true # Terminate the retry when the server fails to reconnect for the specified number of times +# connection-error-retry-attempts: 5 # Number of failed reconnections + + data: + redis: +# database: 0 # Redis database (default: 0) +# host: localhost # Redis host (default: localhost) +# port: 6379 # Redis port (default: 6379) +# password: # Password of redis +# connect-timeout: 3000 # Redis connect timeout +# lettuce: +# pool: +# min-idle: 0 +# max-idle: 8 +# max-active: 8 +# max-wait: -1ms \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 8b13789..cec8de8 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1 +1,18 @@ +app: + version: @project.version@ + build-time: @build.timestamp@ +spring: + profiles: + active: config + datasource: + type: com.alibaba.druid.pool.DruidDataSource + driver-class-name: com.mysql.cj.jdbc.Driver +mybatis-plus: + global-config: + db-config: + logic-delete-field: deleted + logic-not-delete-value: 0 + logic-delete-value: id + id-type: assign_id + type-aliases-package: top.fatweb.api.entity diff --git a/src/main/resources/mapper/UserMapper.xml b/src/main/resources/mapper/UserMapper.xml new file mode 100644 index 0000000..cecc32c --- /dev/null +++ b/src/main/resources/mapper/UserMapper.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/src/test/kotlin/top/fatweb/api/FatWebApiApplicationTests.kt b/src/test/kotlin/top/fatweb/api/FatWebApiApplicationTests.kt new file mode 100644 index 0000000..6d0c220 --- /dev/null +++ b/src/test/kotlin/top/fatweb/api/FatWebApiApplicationTests.kt @@ -0,0 +1,34 @@ +package top.fatweb.api + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.test.context.junit.jupiter.SpringExtension +import top.fatweb.api.constant.SecurityConstants +import top.fatweb.api.util.JwtUtil + +@ExtendWith(SpringExtension::class) +class FatWebApiApplicationTests { + + @Test + fun removePrefixTest() { + assertEquals("12312", "Bearer 12312".removePrefix(SecurityConstants.tokenPrefix)) + } + + @Test + fun jwtTest() { + val jwt = JwtUtil.createJwt("User") + assertEquals("User", jwt?.let { JwtUtil.parseJwt(it).subject }) + } + + /* + @Test + fun addUser(@Autowired userService: IUserService, @Autowired passwordEncoder: PasswordEncoder) { + val username = "admin" + val rawPassword = "admin" + val encodedPassword = passwordEncoder.encode(rawPassword) + val user = User(username, encodedPassword) + userService.save(user) + } + */ +} diff --git a/src/test/kotlin/top/fatweb/api/FatwebApiApplicationTests.kt b/src/test/kotlin/top/fatweb/api/FatwebApiApplicationTests.kt deleted file mode 100644 index 3c32b03..0000000 --- a/src/test/kotlin/top/fatweb/api/FatwebApiApplicationTests.kt +++ /dev/null @@ -1,13 +0,0 @@ -package top.fatweb.api - -import org.junit.jupiter.api.Test -import org.springframework.boot.test.context.SpringBootTest - -@SpringBootTest -class FatwebApiApplicationTests { - - @Test - fun contextLoads() { - } - -}