From 9d2a5f52178b9c8d9c2259cf93f898491be62244 Mon Sep 17 00:00:00 2001 From: server_main Date: Thu, 25 Jun 2026 12:07:24 +0000 Subject: [PATCH] feat: basic backend --- .idea/gradle.xml | 1 + .idea/misc.xml | 2 +- .idea/vcs.xml | 1 + src/beckend/build.gradle.kts | 2 +- .../itstudio/itmanager/config/BeanConfig.kt | 15 ++ .../itstudio/itmanager/config/RabbitConfig.kt | 60 +++++ .../itmanager/config/SecurityConfig.kt | 153 +++++++++++++ .../itmanager/config/SerializationConfig.kt | 17 ++ .../controller/AuthorizeController.kt | 79 +++++++ .../itmanager/controller/TestController.kt | 14 ++ .../itstudio/itmanager/entity/ApiResponse.kt | 41 ++++ .../itstudio/itmanager/entity/DataCopy.kt | 56 +++++ .../itstudio/itmanager/entity/dto/Account.kt | 20 ++ .../entity/dto/EmailVerificationMsg.kt | 10 + .../entity/vo/request/EmailRegisterVO.kt | 19 ++ .../entity/vo/request/PasswordResetVO.kt | 15 ++ .../entity/vo/response/AuthorizeVO.kt | 13 ++ .../itmanager/entity/vo/response/ReloginVO.kt | 9 + .../exception/ValidationController.kt | 23 ++ .../itstudio/itmanager/filter/CorsFilter.kt | 38 ++++ .../itmanager/filter/FlowLimitFilter.kt | 81 +++++++ .../itmanager/filter/JwtAuthorizeFilter.kt | 37 ++++ .../itmanager/listener/MailQueueListener.kt | 59 +++++ .../itmanager/mapper/AccountMapper.kt | 8 + .../itmanager/service/AccountService.kt | 18 ++ .../service/impl/AccountServiceImpl.kt | 209 ++++++++++++++++++ .../club/itstudio/itmanager/utils/Const.kt | 12 + .../itstudio/itmanager/utils/DateUtils.kt | 17 ++ .../itstudio/itmanager/utils/FlowUtils.kt | 26 +++ .../club/itstudio/itmanager/utils/JwtUtils.kt | 107 +++++++++ .../src/main/resources/application.yaml | 41 ++++ 31 files changed, 1201 insertions(+), 2 deletions(-) create mode 100644 src/beckend/src/main/kotlin/club/itstudio/itmanager/config/BeanConfig.kt create mode 100644 src/beckend/src/main/kotlin/club/itstudio/itmanager/config/RabbitConfig.kt create mode 100644 src/beckend/src/main/kotlin/club/itstudio/itmanager/config/SecurityConfig.kt create mode 100644 src/beckend/src/main/kotlin/club/itstudio/itmanager/config/SerializationConfig.kt create mode 100644 src/beckend/src/main/kotlin/club/itstudio/itmanager/controller/AuthorizeController.kt create mode 100644 src/beckend/src/main/kotlin/club/itstudio/itmanager/controller/TestController.kt create mode 100644 src/beckend/src/main/kotlin/club/itstudio/itmanager/entity/ApiResponse.kt create mode 100644 src/beckend/src/main/kotlin/club/itstudio/itmanager/entity/DataCopy.kt create mode 100644 src/beckend/src/main/kotlin/club/itstudio/itmanager/entity/dto/Account.kt create mode 100644 src/beckend/src/main/kotlin/club/itstudio/itmanager/entity/dto/EmailVerificationMsg.kt create mode 100644 src/beckend/src/main/kotlin/club/itstudio/itmanager/entity/vo/request/EmailRegisterVO.kt create mode 100644 src/beckend/src/main/kotlin/club/itstudio/itmanager/entity/vo/request/PasswordResetVO.kt create mode 100644 src/beckend/src/main/kotlin/club/itstudio/itmanager/entity/vo/response/AuthorizeVO.kt create mode 100644 src/beckend/src/main/kotlin/club/itstudio/itmanager/entity/vo/response/ReloginVO.kt create mode 100644 src/beckend/src/main/kotlin/club/itstudio/itmanager/exception/ValidationController.kt create mode 100644 src/beckend/src/main/kotlin/club/itstudio/itmanager/filter/CorsFilter.kt create mode 100644 src/beckend/src/main/kotlin/club/itstudio/itmanager/filter/FlowLimitFilter.kt create mode 100644 src/beckend/src/main/kotlin/club/itstudio/itmanager/filter/JwtAuthorizeFilter.kt create mode 100644 src/beckend/src/main/kotlin/club/itstudio/itmanager/listener/MailQueueListener.kt create mode 100644 src/beckend/src/main/kotlin/club/itstudio/itmanager/mapper/AccountMapper.kt create mode 100644 src/beckend/src/main/kotlin/club/itstudio/itmanager/service/AccountService.kt create mode 100644 src/beckend/src/main/kotlin/club/itstudio/itmanager/service/impl/AccountServiceImpl.kt create mode 100644 src/beckend/src/main/kotlin/club/itstudio/itmanager/utils/Const.kt create mode 100644 src/beckend/src/main/kotlin/club/itstudio/itmanager/utils/DateUtils.kt create mode 100644 src/beckend/src/main/kotlin/club/itstudio/itmanager/utils/FlowUtils.kt create mode 100644 src/beckend/src/main/kotlin/club/itstudio/itmanager/utils/JwtUtils.kt diff --git a/.idea/gradle.xml b/.idea/gradle.xml index f2f163b..10c540d 100644 --- a/.idea/gradle.xml +++ b/.idea/gradle.xml @@ -1,5 +1,6 @@ + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml index 35eb1dd..ef2fda2 100644 --- a/.idea/vcs.xml +++ b/.idea/vcs.xml @@ -2,5 +2,6 @@ + \ No newline at end of file diff --git a/src/beckend/build.gradle.kts b/src/beckend/build.gradle.kts index 717ce02..6f713ae 100644 --- a/src/beckend/build.gradle.kts +++ b/src/beckend/build.gradle.kts @@ -11,7 +11,7 @@ description = "itmanager" java { toolchain { - languageVersion = JavaLanguageVersion.of(21) + languageVersion = JavaLanguageVersion.of(25) } } diff --git a/src/beckend/src/main/kotlin/club/itstudio/itmanager/config/BeanConfig.kt b/src/beckend/src/main/kotlin/club/itstudio/itmanager/config/BeanConfig.kt new file mode 100644 index 0000000..5fb3844 --- /dev/null +++ b/src/beckend/src/main/kotlin/club/itstudio/itmanager/config/BeanConfig.kt @@ -0,0 +1,15 @@ +package club.itstudio.itmanager.config + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder +import org.springframework.security.crypto.password.PasswordEncoder + +@Configuration +class BeanConfig { + + @Bean + fun passwordEncoder(): PasswordEncoder { + return BCryptPasswordEncoder() + } +} \ No newline at end of file diff --git a/src/beckend/src/main/kotlin/club/itstudio/itmanager/config/RabbitConfig.kt b/src/beckend/src/main/kotlin/club/itstudio/itmanager/config/RabbitConfig.kt new file mode 100644 index 0000000..9c898fa --- /dev/null +++ b/src/beckend/src/main/kotlin/club/itstudio/itmanager/config/RabbitConfig.kt @@ -0,0 +1,60 @@ +package club.itstudio.itmanager.config + +import kotlinx.serialization.json.Json +import kotlinx.serialization.serializer +import org.springframework.amqp.core.Message +import org.springframework.amqp.core.MessageProperties +import org.springframework.amqp.rabbit.config.SimpleRabbitListenerContainerFactory +import org.springframework.amqp.rabbit.connection.ConnectionFactory +import org.springframework.amqp.rabbit.core.RabbitTemplate +import org.springframework.amqp.support.converter.AbstractMessageConverter +import org.springframework.amqp.support.converter.MessageConverter +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.boot.amqp.autoconfigure.SimpleRabbitListenerContainerFactoryConfigurer +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class RabbitConfig { + + @Bean + fun messageConverter(@Qualifier("kotlinxSerializationJson") json: Json): MessageConverter = + object : AbstractMessageConverter() { + override fun createMessage(obj: Any, messageProperties: MessageProperties): Message { + val serializer = json.serializersModule.serializer(obj::class.java) + val jsonString = json.encodeToString(serializer, obj) + + val bytes = jsonString.toByteArray(Charsets.UTF_8) + messageProperties.contentType = MessageProperties.CONTENT_TYPE_JSON + messageProperties.contentLength = bytes.size.toLong() + return Message(bytes, messageProperties) + } + + override fun fromMessage(message: Message): Any { + val content = String(message.body, Charsets.UTF_8) + return content + } + } + + @Bean + fun rabbitTemplate( + connectionFactory: ConnectionFactory, + messageConverter: MessageConverter + ): RabbitTemplate { + val rabbitTemplate = RabbitTemplate(connectionFactory) + rabbitTemplate.messageConverter = messageConverter + return rabbitTemplate + } + + @Bean + fun rabbitListenerContainerFactory( + configurer: SimpleRabbitListenerContainerFactoryConfigurer, + connectionFactory: ConnectionFactory, + messageConverter: MessageConverter + ): SimpleRabbitListenerContainerFactory { + val factory = SimpleRabbitListenerContainerFactory() + configurer.configure(factory, connectionFactory) + factory.setMessageConverter(messageConverter) + return factory + } +} \ No newline at end of file diff --git a/src/beckend/src/main/kotlin/club/itstudio/itmanager/config/SecurityConfig.kt b/src/beckend/src/main/kotlin/club/itstudio/itmanager/config/SecurityConfig.kt new file mode 100644 index 0000000..2422954 --- /dev/null +++ b/src/beckend/src/main/kotlin/club/itstudio/itmanager/config/SecurityConfig.kt @@ -0,0 +1,153 @@ +package club.itstudio.itmanager.config + +import club.itstudio.itmanager.entity.ApiResponse +import club.itstudio.itmanager.entity.vo.response.AuthorizeVO +import club.itstudio.itmanager.filter.JwtAuthorizeFilter +import club.itstudio.itmanager.service.AccountService +import club.itstudio.itmanager.utils.DateUtils +import club.itstudio.itmanager.utils.JwtUtils +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.encodeToJsonElement +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.security.authentication.ReactiveAuthenticationManager +import org.springframework.security.authentication.UserDetailsRepositoryReactiveAuthenticationManager +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity +import org.springframework.security.config.web.server.SecurityWebFiltersOrder +import org.springframework.security.config.web.server.ServerHttpSecurity +import org.springframework.security.core.userdetails.User +import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.security.web.server.SecurityWebFilterChain +import org.springframework.security.web.server.authentication.AuthenticationWebFilter +import org.springframework.security.web.server.authentication.ServerAuthenticationConverter +import org.springframework.security.web.server.context.NoOpServerSecurityContextRepository +import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers +import org.springframework.web.server.ServerWebExchange +import reactor.core.publisher.Mono + +@Configuration +@EnableWebFluxSecurity +class SecurityConfig( + private val jwtUtils: JwtUtils, + private val service: AccountService, + private val jwtAuthorizeFilter: JwtAuthorizeFilter, + private val passwordEncoder: PasswordEncoder +) { + + @Bean + fun reactiveAuthenticationManager(): ReactiveAuthenticationManager { + val manager = UserDetailsRepositoryReactiveAuthenticationManager(service) + manager.setPasswordEncoder(passwordEncoder) + return manager + } + + @Bean + fun securityWebFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain { + return http + .authorizeExchange { + it + .pathMatchers( + "/api/v1/auth/relogin" + ).authenticated() + .pathMatchers( + "/api/v1/auth/**", + "/ws/**", + "/error" + ).permitAll() + .anyExchange() + .authenticated() + } + .addFilterAt(loginAuthenticationFilter(), SecurityWebFiltersOrder.AUTHENTICATION) + .addFilterAt(jwtAuthorizeFilter, SecurityWebFiltersOrder.AUTHENTICATION) + .exceptionHandling { + it.authenticationEntryPoint { exchange, ex -> + writeJsonResponse( + exchange, + HttpStatus.UNAUTHORIZED, + ApiResponse.unauthenticated(ex.message) + ) + } + .accessDeniedHandler { exchange, ex -> + writeJsonResponse( + exchange, + HttpStatus.FORBIDDEN, + ApiResponse.forbidden(ex.message) + ) + } + } + .csrf { it.disable() } + .securityContextRepository(NoOpServerSecurityContextRepository.getInstance()) + .build() + } + + @Bean + fun loginAuthenticationFilter(): AuthenticationWebFilter { + val filter = AuthenticationWebFilter(reactiveAuthenticationManager()) + filter.setRequiresAuthenticationMatcher( + ServerWebExchangeMatchers.pathMatchers("/api/v1/auth/login") + ) + filter.setServerAuthenticationConverter(loginAuthenticationConverter()) + filter.setAuthenticationSuccessHandler { webFilterExchange, authentication -> + val user = authentication.principal as User + service.findAccountByNameOrEmail(user.username) + .flatMap { account -> + val vo = account.toAnotherObject( + AuthorizeVO::class, + mapOf( + "token" to jwtUtils.createJwt(user, account.id!!, account.username), + "expire" to DateUtils.dateToLocalDateTime(jwtUtils.expiresTime()) + ) + ) + writeJsonResponse( + webFilterExchange.exchange, + HttpStatus.OK, + ApiResponse.success(vo) + ) + } + } + filter.setAuthenticationFailureHandler { webFilterExchange, exception -> + writeJsonResponse( + webFilterExchange.exchange, + HttpStatus.UNAUTHORIZED, + ApiResponse.unauthenticated(exception.message) + ) + } + return filter + } + + private fun loginAuthenticationConverter(): ServerAuthenticationConverter { + return ServerAuthenticationConverter { exchange -> + exchange.formData.flatMap { formData -> + val username = formData.getFirst("username") + val password = formData.getFirst("password") + if (username != null && password != null) { + Mono.just( + UsernamePasswordAuthenticationToken( + username, + password + ) + ) + } else { + Mono.empty() + } + } + } + } + + private inline fun writeJsonResponse( + exchange: ServerWebExchange, + status: HttpStatus, + body: ApiResponse + ): Mono { + val response = exchange.response + response.statusCode = status + response.headers.contentType = MediaType.APPLICATION_JSON + + val jsonString = Json.encodeToJsonElement(body).toString() + val buffer = response.bufferFactory().wrap(jsonString.toByteArray()) + return response.writeWith(Mono.just(buffer)) + } +} \ No newline at end of file diff --git a/src/beckend/src/main/kotlin/club/itstudio/itmanager/config/SerializationConfig.kt b/src/beckend/src/main/kotlin/club/itstudio/itmanager/config/SerializationConfig.kt new file mode 100644 index 0000000..a5852e4 --- /dev/null +++ b/src/beckend/src/main/kotlin/club/itstudio/itmanager/config/SerializationConfig.kt @@ -0,0 +1,17 @@ +package club.itstudio.itmanager.config + +import kotlinx.serialization.json.Json +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class SerializationConfig { + + @Bean + fun kotlinxSerializationJson(): Json = Json { + ignoreUnknownKeys = true + isLenient = true + encodeDefaults = false + prettyPrint = true + } +} \ No newline at end of file diff --git a/src/beckend/src/main/kotlin/club/itstudio/itmanager/controller/AuthorizeController.kt b/src/beckend/src/main/kotlin/club/itstudio/itmanager/controller/AuthorizeController.kt new file mode 100644 index 0000000..40b83d6 --- /dev/null +++ b/src/beckend/src/main/kotlin/club/itstudio/itmanager/controller/AuthorizeController.kt @@ -0,0 +1,79 @@ +package club.itstudio.itmanager.controller + +import club.itstudio.itmanager.entity.ApiResponse +import club.itstudio.itmanager.entity.vo.request.EmailRegisterVO +import club.itstudio.itmanager.entity.vo.request.PasswordResetVO +import club.itstudio.itmanager.service.AccountService +import jakarta.validation.Valid +import jakarta.validation.constraints.Email +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Pattern +import org.slf4j.LoggerFactory +import org.springframework.validation.annotation.Validated +import org.springframework.web.bind.annotation.* +import org.springframework.web.server.ServerWebExchange +import reactor.core.publisher.Mono + +@Validated +@RestController +@RequestMapping("/api/auth") +class AuthorizeController( + private val service: AccountService +) { + private val logger = LoggerFactory.getLogger(this::class.java) + + @GetMapping("/ask-code") + fun askVerifyCode( + @RequestParam @NotBlank @Email email: String, + @RequestParam @Pattern(regexp = "(register|reset)") type: String, + exchange: ServerWebExchange + ): Mono> { + logger.info("Trying to ask code") + val ip = exchange.request.remoteAddress?.address?.hostAddress ?: "unknown" + return service.askEmailVerifyCode(type, email, ip) + .map { messageHandler(it) } + } + + @PostMapping("/register") + fun emailRegister(@RequestBody @Valid vo: EmailRegisterVO): Mono> { + logger.info("Trying to register") + return service.registerEmailAccount(vo) + .map { messageHandler(it) } + } + + @PostMapping("/reset") + fun emailResetPassword(@RequestBody @Valid vo: PasswordResetVO): Mono> { + logger.info("Trying to reset password") + return service.resetEmailAccountPassword(vo) + .map { messageHandler(it) } + } + + @GetMapping("/logout") + fun logout(exchange: ServerWebExchange): Mono> { + logger.info("Trying to logout") + val authorization = exchange.request.headers.getFirst("Authorization") + return Mono.fromCallable { service.invalidateJwt(authorization) } + .map { success -> + if (success) ApiResponse.success() + else ApiResponse.logoutFailed() + } + } + + @GetMapping("/relogin") + fun relogin(exchange: ServerWebExchange): Mono> { + logger.info("Trying to relogin") + val authorization = exchange.request.headers.getFirst("Authorization") + return service + .jwtTokenRelogin(authorization) + ?.map { ApiResponse.success(it) } + ?: Mono.just(messageHandler("Please login with password.")) + } + + private fun messageHandler(wrongMessage: String): ApiResponse { + return if (wrongMessage.isBlank()) { + ApiResponse.success() + } else { + ApiResponse.failure(400, null, wrongMessage) + } + } +} \ No newline at end of file diff --git a/src/beckend/src/main/kotlin/club/itstudio/itmanager/controller/TestController.kt b/src/beckend/src/main/kotlin/club/itstudio/itmanager/controller/TestController.kt new file mode 100644 index 0000000..25a0902 --- /dev/null +++ b/src/beckend/src/main/kotlin/club/itstudio/itmanager/controller/TestController.kt @@ -0,0 +1,14 @@ +package club.itstudio.itmanager.controller + +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import reactor.core.publisher.Mono + +@RestController +@RequestMapping("/api/test") +class TestController { + + @GetMapping("/hello") + fun test(): Mono = Mono.just("Hello Kotlin WebFlux!") +} \ No newline at end of file diff --git a/src/beckend/src/main/kotlin/club/itstudio/itmanager/entity/ApiResponse.kt b/src/beckend/src/main/kotlin/club/itstudio/itmanager/entity/ApiResponse.kt new file mode 100644 index 0000000..5cf5f82 --- /dev/null +++ b/src/beckend/src/main/kotlin/club/itstudio/itmanager/entity/ApiResponse.kt @@ -0,0 +1,41 @@ +package club.itstudio.itmanager.entity + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +@Serializable +data class ApiResponse( + val code: Int, + val data: T, + val message: String +) { + companion object { + fun success(data: T, message: String = "Success"): ApiResponse { + return ApiResponse(200, data, message) + } + + // 针对无 data 返回的情况,统一使用 Any? + fun success(message: String = "Success"): ApiResponse { + return ApiResponse(200, null, message) + } + + fun failure(code: Int = 401, data: T, message: String?): ApiResponse { + return ApiResponse(code, data, message ?: "Failure") + } + + fun unauthenticated(message: String?) = + failure(401, null, message) + + fun forbidden(message: String?) = + failure(403, null, message) + + fun logoutFailed(message: String = ""): ApiResponse { + val formatMessage = if (message.isBlank()) "" else ": $message" + return failure(400, null, "Logout Failed$formatMessage") + } + } +} + +inline fun ApiResponse.toJsonString(): String { + return Json.encodeToString(this) +} \ No newline at end of file diff --git a/src/beckend/src/main/kotlin/club/itstudio/itmanager/entity/DataCopy.kt b/src/beckend/src/main/kotlin/club/itstudio/itmanager/entity/DataCopy.kt new file mode 100644 index 0000000..cd379d8 --- /dev/null +++ b/src/beckend/src/main/kotlin/club/itstudio/itmanager/entity/DataCopy.kt @@ -0,0 +1,56 @@ +package club.itstudio.itmanager.entity + +import kotlin.reflect.KClass +import kotlin.reflect.KProperty +import kotlin.reflect.full.createInstance +import kotlin.reflect.full.declaredMemberProperties +import kotlin.reflect.full.memberProperties +import kotlin.reflect.jvm.javaField + +interface DataCopy { + + fun toAnotherObject( + toClass: KClass, + otherProperties: Map + ): T { + try { + val voProperties = toClass.declaredMemberProperties + val vo = toClass.createInstance() + for (voProperty in voProperties) + copyFieldData(voProperty, otherProperties, vo) + return vo + } catch (e: ReflectiveOperationException) { + throw RuntimeException( + "${toClass.simpleName} has no fields.", + e + ) + } + } + + private fun copyFieldData( + toProperty: KProperty<*>, + otherProperties: Map, + another: Any + ) { + try { + val thisProperty = this::class.memberProperties + .firstOrNull { it.name == toProperty.name } + + val value = + if (thisProperty != null) { + thisProperty.getter.call(this) + } else if (otherProperties.containsKey(toProperty.name)) + otherProperties[toProperty.name] + else + throw RuntimeException( + "Property ${toProperty.name} not found." + ) + + val field = toProperty.javaField!! + field.isAccessible = true + field.set(another, value) + } catch (e: Exception) { + e.printStackTrace() + } + } +} \ No newline at end of file diff --git a/src/beckend/src/main/kotlin/club/itstudio/itmanager/entity/dto/Account.kt b/src/beckend/src/main/kotlin/club/itstudio/itmanager/entity/dto/Account.kt new file mode 100644 index 0000000..beb8ba9 --- /dev/null +++ b/src/beckend/src/main/kotlin/club/itstudio/itmanager/entity/dto/Account.kt @@ -0,0 +1,20 @@ +package club.itstudio.itmanager.entity.dto + +import com.baomidou.mybatisplus.annotation.IdType +import com.baomidou.mybatisplus.annotation.TableId +import com.baomidou.mybatisplus.annotation.TableName +import club.itstudio.itmanager.entity.DataCopy +import kotlinx.datetime.LocalDateTime +import kotlinx.serialization.Serializable + +@Serializable +@TableName("account") +data class Account( + @TableId(type = IdType.AUTO) + val id: Int? = null, + val username: String = "", + val password: String = "", + val email: String = "", + val role: String = "", + val registerTime: LocalDateTime? = null +) : DataCopy \ No newline at end of file diff --git a/src/beckend/src/main/kotlin/club/itstudio/itmanager/entity/dto/EmailVerificationMsg.kt b/src/beckend/src/main/kotlin/club/itstudio/itmanager/entity/dto/EmailVerificationMsg.kt new file mode 100644 index 0000000..791a98c --- /dev/null +++ b/src/beckend/src/main/kotlin/club/itstudio/itmanager/entity/dto/EmailVerificationMsg.kt @@ -0,0 +1,10 @@ +package club.itstudio.itmanager.entity.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class EmailVerificationMsg( + val email: String, + val type: String, + val code: String +) \ No newline at end of file diff --git a/src/beckend/src/main/kotlin/club/itstudio/itmanager/entity/vo/request/EmailRegisterVO.kt b/src/beckend/src/main/kotlin/club/itstudio/itmanager/entity/vo/request/EmailRegisterVO.kt new file mode 100644 index 0000000..0c5c4d2 --- /dev/null +++ b/src/beckend/src/main/kotlin/club/itstudio/itmanager/entity/vo/request/EmailRegisterVO.kt @@ -0,0 +1,19 @@ +package club.itstudio.itmanager.entity.vo.request + +import jakarta.validation.constraints.Email +import jakarta.validation.constraints.Pattern +import kotlinx.serialization.Serializable +import org.hibernate.validator.constraints.Length + +@Serializable +data class EmailRegisterVO( + @param:Email + val email: String = "", + @param:Length(min = 6, max = 6) + val code: String? = null, + @param:Pattern(regexp = "^[a-zA-Z0-9\\u4e00-\\u9fa5]+$") + @param:Length(min = 4, max = 16) + val username: String = "", + @param:Length(min = 6, max = 20) + val password: String = "", +) \ No newline at end of file diff --git a/src/beckend/src/main/kotlin/club/itstudio/itmanager/entity/vo/request/PasswordResetVO.kt b/src/beckend/src/main/kotlin/club/itstudio/itmanager/entity/vo/request/PasswordResetVO.kt new file mode 100644 index 0000000..9cdf4e7 --- /dev/null +++ b/src/beckend/src/main/kotlin/club/itstudio/itmanager/entity/vo/request/PasswordResetVO.kt @@ -0,0 +1,15 @@ +package club.itstudio.itmanager.entity.vo.request + +import jakarta.validation.constraints.Email +import kotlinx.serialization.Serializable +import org.hibernate.validator.constraints.Length + +@Serializable +data class PasswordResetVO( + @param:Email + val email: String = "", + @param:Length(min = 6, max = 6) + val code: String? = null, + @param:Length(min = 6, max = 20) + val password: String = "", +) \ No newline at end of file diff --git a/src/beckend/src/main/kotlin/club/itstudio/itmanager/entity/vo/response/AuthorizeVO.kt b/src/beckend/src/main/kotlin/club/itstudio/itmanager/entity/vo/response/AuthorizeVO.kt new file mode 100644 index 0000000..d0eb2b2 --- /dev/null +++ b/src/beckend/src/main/kotlin/club/itstudio/itmanager/entity/vo/response/AuthorizeVO.kt @@ -0,0 +1,13 @@ +package club.itstudio.itmanager.entity.vo.response + +import club.itstudio.itmanager.utils.DateUtils.getCurrentDateTime +import kotlinx.datetime.LocalDateTime +import kotlinx.serialization.Serializable + +@Serializable +data class AuthorizeVO( + val username: String = "", + val role: String = "", + val token: String = "", + val expire: LocalDateTime = getCurrentDateTime(), +) \ No newline at end of file diff --git a/src/beckend/src/main/kotlin/club/itstudio/itmanager/entity/vo/response/ReloginVO.kt b/src/beckend/src/main/kotlin/club/itstudio/itmanager/entity/vo/response/ReloginVO.kt new file mode 100644 index 0000000..2f31570 --- /dev/null +++ b/src/beckend/src/main/kotlin/club/itstudio/itmanager/entity/vo/response/ReloginVO.kt @@ -0,0 +1,9 @@ +package club.itstudio.itmanager.entity.vo.response + +import kotlinx.serialization.Serializable + +@Serializable +data class ReloginVO( + val token: String = "", + val expire: String = "", +) \ No newline at end of file diff --git a/src/beckend/src/main/kotlin/club/itstudio/itmanager/exception/ValidationController.kt b/src/beckend/src/main/kotlin/club/itstudio/itmanager/exception/ValidationController.kt new file mode 100644 index 0000000..eb70837 --- /dev/null +++ b/src/beckend/src/main/kotlin/club/itstudio/itmanager/exception/ValidationController.kt @@ -0,0 +1,23 @@ +package club.itstudio.itmanager.exception + +import club.itstudio.itmanager.entity.ApiResponse +import jakarta.validation.ValidationException +import org.slf4j.LoggerFactory +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.annotation.RestControllerAdvice + +@RestControllerAdvice +class ValidationController { + + private val log = LoggerFactory.getLogger(ValidationController::class.java) + + @ExceptionHandler(ValidationException::class) + fun validationException(e: Exception): ApiResponse { + log.warn("Resolve [${e.javaClass.name}: ${e.message}]") + return ApiResponse.failure( + 400, + null, + "parameter validation error" + ) + } +} \ No newline at end of file diff --git a/src/beckend/src/main/kotlin/club/itstudio/itmanager/filter/CorsFilter.kt b/src/beckend/src/main/kotlin/club/itstudio/itmanager/filter/CorsFilter.kt new file mode 100644 index 0000000..1e97b32 --- /dev/null +++ b/src/beckend/src/main/kotlin/club/itstudio/itmanager/filter/CorsFilter.kt @@ -0,0 +1,38 @@ +package club.itstudio.itmanager.filter + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod +import org.springframework.web.cors.CorsConfiguration +import org.springframework.web.cors.reactive.CorsWebFilter +import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource + +@Configuration +class CorsFilter { + + @Bean + fun corsWebFilter(): CorsWebFilter { + val config = CorsConfiguration().apply { + allowedOriginPatterns = listOf("*") + allowedMethods = listOf( + HttpMethod.GET.name(), + HttpMethod.POST.name(), + HttpMethod.PUT.name(), + HttpMethod.DELETE.name(), + HttpMethod.OPTIONS.name() + ) + allowedHeaders = listOf( + HttpHeaders.AUTHORIZATION, + HttpHeaders.CONTENT_TYPE + ) + allowCredentials = true + } + + val source = UrlBasedCorsConfigurationSource().apply { + registerCorsConfiguration("/**", config) + } + + return CorsWebFilter(source) + } +} \ No newline at end of file diff --git a/src/beckend/src/main/kotlin/club/itstudio/itmanager/filter/FlowLimitFilter.kt b/src/beckend/src/main/kotlin/club/itstudio/itmanager/filter/FlowLimitFilter.kt new file mode 100644 index 0000000..6434383 --- /dev/null +++ b/src/beckend/src/main/kotlin/club/itstudio/itmanager/filter/FlowLimitFilter.kt @@ -0,0 +1,81 @@ +package club.itstudio.itmanager.filter + +import club.itstudio.itmanager.entity.ApiResponse +import club.itstudio.itmanager.entity.toJsonString +import club.itstudio.itmanager.utils.Const +import org.springframework.beans.factory.annotation.Value +import org.springframework.core.annotation.Order +import org.springframework.data.redis.core.ReactiveStringRedisTemplate +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.stereotype.Component +import org.springframework.web.server.ServerWebExchange +import org.springframework.web.server.WebFilter +import org.springframework.web.server.WebFilterChain +import reactor.core.publisher.Mono +import java.time.Duration + +@Component +@Order(Const.FLOW_LIMIT_ORDER) +class FlowLimitFilter( + private val template: ReactiveStringRedisTemplate, + + @param:Value($$"${app.flow-limit.count}") + private val limitCount: Int, + @param:Value($$"${app.flow-limit.limit-time}") + private val limitTime: Int, + @param:Value($$"${app.flow-limit.ban-time}") + private val expireTime: Int, +) : WebFilter { + + override fun filter(exchange: ServerWebExchange, chain: WebFilterChain): Mono { + val address = exchange.request.remoteAddress?.address?.hostAddress ?: "unknown" + + return tryCount(address).flatMap { allowed -> + if (allowed) { + chain.filter(exchange) + } else { + writeBlockMessage(exchange) + } + } + } + + private fun writeBlockMessage(exchange: ServerWebExchange): Mono { + val response = exchange.response + response.statusCode = HttpStatus.FORBIDDEN + response.headers.contentType = MediaType.APPLICATION_JSON + val buffer = response.bufferFactory() + .wrap(ApiResponse.forbidden("Too many requests").toJsonString().toByteArray()) + return response.writeWith(Mono.just(buffer)) + } + + private fun tryCount(ip: String): Mono { + val blockKey = Const.FLOW_LIMIT_BLOCK + ip + val counterKey = Const.FLOW_LIMIT_COUNTER + ip + + return template.hasKey(blockKey).flatMap { isBlocked -> + if (isBlocked) { + Mono.just(false) + } else { + template.opsForValue().increment(counterKey).flatMap { count -> + if (count == 1L) { + template.expire( + counterKey, + Duration.ofSeconds(limitTime.toLong()) + ).thenReturn(true) + } else if (count > limitCount) { + template.opsForValue() + .set( + blockKey, + "1", + Duration.ofSeconds(expireTime.toLong()) + ) + .thenReturn(false) + } else { + Mono.just(true) + } + } + } + } + } +} \ No newline at end of file diff --git a/src/beckend/src/main/kotlin/club/itstudio/itmanager/filter/JwtAuthorizeFilter.kt b/src/beckend/src/main/kotlin/club/itstudio/itmanager/filter/JwtAuthorizeFilter.kt new file mode 100644 index 0000000..52c19fa --- /dev/null +++ b/src/beckend/src/main/kotlin/club/itstudio/itmanager/filter/JwtAuthorizeFilter.kt @@ -0,0 +1,37 @@ +package club.itstudio.itmanager.filter + +import club.itstudio.itmanager.utils.JwtUtils +import org.springframework.http.HttpHeaders +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.context.ReactiveSecurityContextHolder +import org.springframework.stereotype.Component +import org.springframework.web.server.ServerWebExchange +import org.springframework.web.server.WebFilter +import org.springframework.web.server.WebFilterChain +import reactor.core.publisher.Mono + +@Component +class JwtAuthorizeFilter( + private val utils: JwtUtils +) : WebFilter { + + override fun filter(exchange: ServerWebExchange, chain: WebFilterChain): Mono { + val authorization = exchange.request.headers.getFirst(HttpHeaders.AUTHORIZATION) + val jwt = utils.resolveJwt(authorization) + + return if (jwt != null) { + val user = utils.toUser(jwt) + val authentication = UsernamePasswordAuthenticationToken( + user, + null, + user.authorities + ) + exchange.attributes["id"] = utils.toId(jwt) + + chain.filter(exchange) + .contextWrite(ReactiveSecurityContextHolder.withAuthentication(authentication)) + } else { + chain.filter(exchange) + } + } +} \ No newline at end of file diff --git a/src/beckend/src/main/kotlin/club/itstudio/itmanager/listener/MailQueueListener.kt b/src/beckend/src/main/kotlin/club/itstudio/itmanager/listener/MailQueueListener.kt new file mode 100644 index 0000000..ea0d601 --- /dev/null +++ b/src/beckend/src/main/kotlin/club/itstudio/itmanager/listener/MailQueueListener.kt @@ -0,0 +1,59 @@ +package club.itstudio.itmanager.listener + +import club.itstudio.itmanager.entity.dto.EmailVerificationMsg +import kotlinx.serialization.json.Json +import org.slf4j.LoggerFactory +import org.springframework.amqp.rabbit.annotation.RabbitHandler +import org.springframework.amqp.rabbit.annotation.RabbitListener +import org.springframework.beans.factory.annotation.Value +import org.springframework.mail.SimpleMailMessage +import org.springframework.mail.javamail.JavaMailSender +import org.springframework.stereotype.Component + +@Component +@RabbitListener(queues = ["mail"]) +class MailQueueListener( + @param:Value($$"${spring.mail.username}") + private val username: String, + + private val json: Json, + private val sender: JavaMailSender +) { + + private val logger = LoggerFactory.getLogger(this::class.java) + + @RabbitHandler + fun senderMailMessage(content: String) { + val data = json.decodeFromString(content) + val (email, type, code) = data + + val message = when (type) { + "register" -> createMailMessage( + "Welcome to Our Service", + "Thank you for registering! Your verification code is: $code" + + "\nThis code is valid for 3 minutes.", + email + ) + + "reset" -> createMailMessage( + "Password Reset Request", + "You requested a password reset. Your verification code is: $code" + + "\nThis code is valid for 3 minutes.", + email + ) + + else -> return + } + sender.send(message) + logger.info("Email sent to $email.") + } + + fun createMailMessage(title: String, content: String, to: String): SimpleMailMessage { + val message = SimpleMailMessage() + message.subject = title + message.text = content + message.from = username + message.setTo(to) + return message + } +} \ No newline at end of file diff --git a/src/beckend/src/main/kotlin/club/itstudio/itmanager/mapper/AccountMapper.kt b/src/beckend/src/main/kotlin/club/itstudio/itmanager/mapper/AccountMapper.kt new file mode 100644 index 0000000..265df7c --- /dev/null +++ b/src/beckend/src/main/kotlin/club/itstudio/itmanager/mapper/AccountMapper.kt @@ -0,0 +1,8 @@ +package club.itstudio.itmanager.mapper + +import com.baomidou.mybatisplus.core.mapper.BaseMapper +import club.itstudio.itmanager.entity.dto.Account +import org.apache.ibatis.annotations.Mapper + +@Mapper +interface AccountMapper : BaseMapper \ No newline at end of file diff --git a/src/beckend/src/main/kotlin/club/itstudio/itmanager/service/AccountService.kt b/src/beckend/src/main/kotlin/club/itstudio/itmanager/service/AccountService.kt new file mode 100644 index 0000000..9afbc43 --- /dev/null +++ b/src/beckend/src/main/kotlin/club/itstudio/itmanager/service/AccountService.kt @@ -0,0 +1,18 @@ +package club.itstudio.itmanager.service + +import com.baomidou.mybatisplus.extension.service.IService +import club.itstudio.itmanager.entity.dto.Account +import club.itstudio.itmanager.entity.vo.request.EmailRegisterVO +import club.itstudio.itmanager.entity.vo.request.PasswordResetVO +import club.itstudio.itmanager.entity.vo.response.ReloginVO +import org.springframework.security.core.userdetails.ReactiveUserDetailsService +import reactor.core.publisher.Mono + +interface AccountService : IService, ReactiveUserDetailsService { + fun findAccountByNameOrEmail(text: String): Mono + fun askEmailVerifyCode(type: String, email: String, ip: String): Mono + fun registerEmailAccount(vo: EmailRegisterVO): Mono + fun resetEmailAccountPassword(vo: PasswordResetVO): Mono + fun invalidateJwt(headerToken: String?): Boolean + fun jwtTokenRelogin(headerToken: String?): Mono? +} \ No newline at end of file diff --git a/src/beckend/src/main/kotlin/club/itstudio/itmanager/service/impl/AccountServiceImpl.kt b/src/beckend/src/main/kotlin/club/itstudio/itmanager/service/impl/AccountServiceImpl.kt new file mode 100644 index 0000000..4f75139 --- /dev/null +++ b/src/beckend/src/main/kotlin/club/itstudio/itmanager/service/impl/AccountServiceImpl.kt @@ -0,0 +1,209 @@ +package club.itstudio.itmanager.service.impl + +import com.baomidou.mybatisplus.core.toolkit.Wrappers +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl +import club.itstudio.itmanager.entity.dto.Account +import club.itstudio.itmanager.entity.dto.EmailVerificationMsg +import club.itstudio.itmanager.entity.vo.request.EmailRegisterVO +import club.itstudio.itmanager.entity.vo.request.PasswordResetVO +import club.itstudio.itmanager.entity.vo.response.ReloginVO +import club.itstudio.itmanager.mapper.AccountMapper +import club.itstudio.itmanager.service.AccountService +import club.itstudio.itmanager.utils.Const +import club.itstudio.itmanager.utils.DateUtils.getCurrentDateTime +import club.itstudio.itmanager.utils.FlowUtils +import club.itstudio.itmanager.utils.JwtUtils +import org.springframework.amqp.core.AmqpTemplate +import org.springframework.data.redis.core.ReactiveStringRedisTemplate +import org.springframework.security.core.userdetails.User +import org.springframework.security.core.userdetails.UserDetails +import org.springframework.security.core.userdetails.UsernameNotFoundException +import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.stereotype.Service +import reactor.core.publisher.Mono +import reactor.core.scheduler.Schedulers +import java.time.Duration + +@Service +class AccountServiceImpl( + private val utils: FlowUtils, + private val jwtUtils: JwtUtils, + private val amqpTemplate: AmqpTemplate, + private val stringRedisTemplate: ReactiveStringRedisTemplate, + private val encoder: PasswordEncoder + +) : ServiceImpl(), AccountService { + + override fun findByUsername(username: String): Mono { + return findAccountByNameOrEmail(username) + .switchIfEmpty(Mono.error(UsernameNotFoundException("Account with name $username not found"))) + .map { account -> + User.withUsername(username) + .password(account.password) + .roles(account.role) + .build() + } + } + + override fun findAccountByNameOrEmail(text: String): Mono { + return Mono.fromCallable { + this.query() + .eq("username", text).or() + .eq("email", text) + .one() + }.flatMap { account -> + Mono.just(account) + } + } + + override fun askEmailVerifyCode(type: String, email: String, ip: String): Mono { + return verifyLimit(ip).flatMap { allowed -> + if (allowed) { + val code = (100000..999999).random().toString() + val data = EmailVerificationMsg(email, type, code) + + Mono.fromRunnable { + amqpTemplate.convertAndSend("mail", data) + }.then( + stringRedisTemplate.opsForValue() + .set( + Const.VERIFY_EMAIL_DATA + email, + code, + Duration.ofMinutes(3) + ) + ).thenReturn("").subscribeOn(Schedulers.boundedElastic()) + } else { + Mono.just("Request limit exceeded. Please try again later.") + } + } + } + + override fun registerEmailAccount(vo: EmailRegisterVO): Mono { + val (email, code, username, password) = vo + + return verifyCode(email, code).flatMap { error -> + if (!error.isBlank()) { + return@flatMap Mono.just(error) + } + + existAccountByEmail(email).flatMap { emailExists -> + if (emailExists) { + return@flatMap Mono.just("account with the same email already exists.") + } + + existAccountByUsername(username).flatMap { usernameExists -> + if (usernameExists) { + return@flatMap Mono.just("username already exists.") + } + + val encodedPassword = encoder.encode(password) + val account = Account( + null, + username, + encodedPassword!!, + email, + "user", + getCurrentDateTime() + ) + + Mono.fromCallable { this.save(account) } + .flatMap { success -> + if (success) { + stringRedisTemplate.delete(Const.VERIFY_EMAIL_DATA + email) + .thenReturn("") + } else { + Mono.just("something went wrong. Please contact the administrator.") + } + } + } + } + } + } + + override fun resetEmailAccountPassword(vo: PasswordResetVO): Mono { + val (email, code, password) = vo + + return verifyCode(email, code).flatMap { error -> + if (!error.isBlank()) { + return@flatMap Mono.just(error) + } + + existAccountByEmail(email).flatMap { exists -> + if (!exists) { + return@flatMap Mono.just("account with the email does not exist.") + } + + val encodedPassword = encoder.encode(password) + + Mono.fromCallable { + this.update() + .eq("email", email) + .set("password", encodedPassword) + .update() + }.flatMap { success -> + if (success) { + stringRedisTemplate.delete(Const.VERIFY_EMAIL_DATA + email) + .thenReturn("") + } else { + Mono.just("something went wrong. Please contact the administrator.") + } + } + } + } + } + + override fun jwtTokenRelogin(headerToken: String?): Mono? { + val user = jwtUtils.resolveJwt(headerToken?.substring("Bearer ".length)) + ?.let { jwtUtils.toUser(it) } + ?: return null + val accountMono = findAccountByNameOrEmail(user.username) + val voMono = accountMono.map { + ReloginVO( + jwtUtils.createJwt(user, it.id!!, it.username), + jwtUtils.expiresTime().toInstant().toString() + ) + } + return voMono + } + + override fun invalidateJwt(headerToken: String?): Boolean { + return jwtUtils.invalidateJwt(headerToken) + } + + private fun existAccountByEmail(email: String): Mono { + return Mono.fromCallable { + this.baseMapper.exists( + Wrappers.query() + .eq("email", email) + ) + } + } + + private fun existAccountByUsername(username: String): Mono { + return Mono.fromCallable { + this.baseMapper.exists( + Wrappers.query() + .eq("username", username) + ) + } + } + + private fun verifyLimit(ip: String): Mono { + val key = Const.VERIFY_EMAIL_LIMIT + ip + return utils.limitOnceCheck(key, 60) + } + + private fun verifyCode(email: String, receivedCode: String?): Mono { + return stringRedisTemplate.opsForValue() + .get(Const.VERIFY_EMAIL_DATA + email) + .flatMap { code -> + when { + receivedCode == null || code != receivedCode -> + Mono.just("verify code is wrong.") + + else -> Mono.just("") + } + } + .switchIfEmpty(Mono.just("verify code has not been sent")) + } +} \ No newline at end of file diff --git a/src/beckend/src/main/kotlin/club/itstudio/itmanager/utils/Const.kt b/src/beckend/src/main/kotlin/club/itstudio/itmanager/utils/Const.kt new file mode 100644 index 0000000..861d8d3 --- /dev/null +++ b/src/beckend/src/main/kotlin/club/itstudio/itmanager/utils/Const.kt @@ -0,0 +1,12 @@ +package club.itstudio.itmanager.utils + +object Const { + const val FLOW_LIMIT_ORDER = -101 + + const val JWT_BLACK_LIST = "jwt:blacklist:" + const val VERIFY_EMAIL_LIMIT = "verify:email:limit:" + const val VERIFY_EMAIL_DATA = "verify:email:data:" + + const val FLOW_LIMIT_COUNTER = "flow:counter:" + const val FLOW_LIMIT_BLOCK = "flow:block:" +} \ No newline at end of file diff --git a/src/beckend/src/main/kotlin/club/itstudio/itmanager/utils/DateUtils.kt b/src/beckend/src/main/kotlin/club/itstudio/itmanager/utils/DateUtils.kt new file mode 100644 index 0000000..0ce225a --- /dev/null +++ b/src/beckend/src/main/kotlin/club/itstudio/itmanager/utils/DateUtils.kt @@ -0,0 +1,17 @@ +package club.itstudio.itmanager.utils + +import kotlinx.datetime.* +import kotlinx.datetime.TimeZone +import java.util.* + +object DateUtils { + fun getCurrentDateTime() = + Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()) + + fun dateToLocalDateTime(date: Date): LocalDateTime { + return date + .toInstant() + .toKotlinInstant() + .toLocalDateTime(TimeZone.currentSystemDefault()) + } +} diff --git a/src/beckend/src/main/kotlin/club/itstudio/itmanager/utils/FlowUtils.kt b/src/beckend/src/main/kotlin/club/itstudio/itmanager/utils/FlowUtils.kt new file mode 100644 index 0000000..8e05fd0 --- /dev/null +++ b/src/beckend/src/main/kotlin/club/itstudio/itmanager/utils/FlowUtils.kt @@ -0,0 +1,26 @@ +package club.itstudio.itmanager.utils + +import jakarta.annotation.Resource +import org.springframework.data.redis.core.ReactiveStringRedisTemplate +import org.springframework.stereotype.Component +import reactor.core.publisher.Mono +import java.time.Duration + +@Component +class FlowUtils( + @Resource + val template: ReactiveStringRedisTemplate +) { + + fun limitOnceCheck(key: String, blockTime: Int): Mono { + return template.hasKey(key).flatMap { exists -> + if (exists) { + Mono.just(false) + } else { + template.opsForValue() + .set(key, "", Duration.ofSeconds(blockTime.toLong())) + .thenReturn(true) + } + } + } +} \ No newline at end of file diff --git a/src/beckend/src/main/kotlin/club/itstudio/itmanager/utils/JwtUtils.kt b/src/beckend/src/main/kotlin/club/itstudio/itmanager/utils/JwtUtils.kt new file mode 100644 index 0000000..919399e --- /dev/null +++ b/src/beckend/src/main/kotlin/club/itstudio/itmanager/utils/JwtUtils.kt @@ -0,0 +1,107 @@ +package club.itstudio.itmanager.utils + +import com.auth0.jwt.JWT +import com.auth0.jwt.JWTVerifier +import com.auth0.jwt.algorithms.Algorithm +import com.auth0.jwt.interfaces.DecodedJWT +import org.springframework.beans.factory.annotation.Value +import org.springframework.data.redis.core.StringRedisTemplate +import org.springframework.security.core.userdetails.User +import org.springframework.security.core.userdetails.UserDetails +import org.springframework.stereotype.Component +import java.util.* +import java.util.concurrent.TimeUnit + +@Component +class JwtUtils( + @param:Value($$"${spring.security.jwt.key}") + private val key: String, + + @param:Value($$"${spring.security.jwt.expire-hours}") + private val expireHours: Int, + + private val template: StringRedisTemplate, + private val algorithm: Algorithm = Algorithm.HMAC256(key), + private val jwtVerifier: JWTVerifier = JWT.require(algorithm).build() +) { + fun invalidateJwt(headerToken: String?): Boolean { + val token = this.convertToToken(headerToken) ?: return false + try { + val jwt = jwtVerifier.verify(token) + val id = jwt.id + return deleteToken(id, jwt.expiresAt) + } catch (e: Exception) { + println(e.message) + return false + } + } + + private fun deleteToken(uuid: String, time: Date): Boolean { + val now = Date() + val expire = (time.time - now.time).coerceAtLeast(0) + template.opsForValue().set( + Const.JWT_BLACK_LIST + uuid, + "deleted", + expire, + TimeUnit.MILLISECONDS + ) + return true + } + + private fun isInvalidToken(uuid: String): Boolean = + template.hasKey(Const.JWT_BLACK_LIST + uuid) ?: false + + fun resolveJwt(headerToken: String?): DecodedJWT? { + val token = convertToToken(headerToken) ?: return null + try { + val decodedJWT = jwtVerifier.verify(token) + if (this.isInvalidToken(decodedJWT.id)) return null + val expiresAt = decodedJWT.expiresAt + + return if (Date().after(expiresAt)) null + else decodedJWT + + } catch (e: Exception) { + println(e.message) + return null + } + } + + fun createJwt( + details: UserDetails, + id: Int, + username: String + ): String { + return JWT.create() + .withJWTId(UUID.randomUUID().toString()) + .withClaim("id", id) + .withClaim("username", username) + .withClaim( + "authorities", + details.authorities.map { it.authority }.toList() + ) + .withExpiresAt(expiresTime()) + .withIssuedAt(Date()) + .sign(algorithm) + } + + fun toUser(jwt: DecodedJWT): UserDetails { + val claims = jwt.claims + return User + .withUsername(claims["username"]!!.asString()) + .password("********") + .authorities(*(claims["authorities"]!!.asArray(String::class.java))) + .build() + } + + fun toId(jwt: DecodedJWT) = jwt.claims["id"]?.asInt() ?: -1 + + fun expiresTime(): Date { + val cal = Calendar.getInstance() + cal.add(Calendar.HOUR, expireHours) + return cal.time + } + + fun convertToToken(headerToken: String?): String? = + headerToken?.replace("Bearer ", "") +} \ No newline at end of file diff --git a/src/beckend/src/main/resources/application.yaml b/src/beckend/src/main/resources/application.yaml index fca0bdd..13f2349 100644 --- a/src/beckend/src/main/resources/application.yaml +++ b/src/beckend/src/main/resources/application.yaml @@ -1,3 +1,44 @@ spring: + webflux: + base-path: / + + mail: + #host: smtp.example.com + #username: 123456@example.com + #password: password + #port: 123456 + protocol: smtp + properties: + mail.smtp.auth: true + mail.smtp.ssl.enable: true + mail.smtp.timeout: 5000 + + rabbitmq: + host: localhost + port: 5672 + virtual-host: / + username: admin + password: 123456 + application: name: itmanager + + security: + jwt: + key: 6846513216 + expire-hours: 168 + + datasource: + url: jdbc:mysql://localhost:3306/kotlin_springboot + username: root + #password: 123456 + driver-class-name: com.mysql.cj.jdbc.Driver + +mybatis-plus: + type-handlers-package: com.github.sor2171.backend.config + +app: + flow-limit: + count: 1000 + limit-time: 3 + ban-time: 60 \ No newline at end of file