Add account register and verify api

This commit is contained in:
2023-12-22 18:02:42 +08:00
parent 660f879ccd
commit cf96d8037d
23 changed files with 289 additions and 31 deletions

View File

@@ -2,6 +2,7 @@ package top.fatweb.api.config
import com.baomidou.mybatisplus.extension.kotlin.KtQueryWrapper
import jakarta.annotation.PostConstruct
import org.apache.velocity.app.VelocityEngine
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import org.springframework.context.annotation.DependsOn
@@ -29,7 +30,7 @@ import top.fatweb.avatargenerator.GitHubAvatar
class InitConfig(
private val userService: IUserService,
private val userInfoService: IUserInfoService,
private val passwordEncoder: PasswordEncoder,
private val passwordEncoder: PasswordEncoder
) {
private val logger: Logger = LoggerFactory.getLogger(this::class.java)

View File

@@ -22,5 +22,4 @@ class MybatisPlusConfig {
return mybatisPlusInterceptor
}
}

View File

@@ -75,6 +75,7 @@ class SecurityConfig(
"/swagger-ui.html",
"/favicon.ico",
"/login",
"/register"
).anonymous()
// Authentication required
.anyRequest().authenticated()

View File

@@ -0,0 +1,17 @@
package top.fatweb.api.config
import org.apache.velocity.app.VelocityEngine
import org.apache.velocity.runtime.RuntimeConstants
import org.apache.velocity.runtime.resource.loader.ClasspathResourceLoader
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
@Configuration
class VelocityEngineConfig {
@Bean
fun velocityEngine() = VelocityEngine().apply {
setProperty(RuntimeConstants.RESOURCE_LOADER, "classpath")
setProperty("classpath.resource.loader.class", ClasspathResourceLoader::class.java.name)
init()
}
}

View File

@@ -42,6 +42,20 @@ class AuthenticationController(
return ResponseResult.success(ResponseCode.PERMISSION_REGISTER_SUCCESS)
}
/**
* Send verify email
*
* @author FatttSnake, fatttsnake@gmail.com
* @since 1.0.0
*/
@Operation(summary = "发送验证邮箱")
@PostMapping("/resend")
fun resend(): ResponseResult<Nothing> {
authenticationService.resend()
return ResponseResult.success(ResponseCode.PERMISSION_RESEND_SUCCESS)
}
/**
* Verify
*

View File

@@ -34,6 +34,7 @@ object UserConverter {
fun userToUserWithPowerInfoVo(user: User) = UserWithPowerInfoVo(
id = user.id,
username = user.username,
verified = user.verify.isNullOrBlank(),
locking = user.locking?.let { it == 1 },
expiration = user.expiration,
credentialsExpiration = user.credentialsExpiration,
@@ -109,6 +110,7 @@ object UserConverter {
fun userToUserWithInfoVo(user: User) = UserWithInfoVo(
id = user.id,
username = user.username,
verified = user.verify.isNullOrBlank(),
locking = user.locking?.let { it == 1 },
expiration = user.expiration,
credentialsExpiration = user.credentialsExpiration,

View File

@@ -19,7 +19,8 @@ enum class ResponseCode(val code: Int) {
PERMISSION_LOGOUT_SUCCESS(BusinessCode.PERMISSION, 2),
PERMISSION_TOKEN_RENEW_SUCCESS(BusinessCode.PERMISSION, 3),
PERMISSION_REGISTER_SUCCESS(BusinessCode.PERMISSION, 4),
PERMISSION_VERIFY_SUCCESS(BusinessCode.PERMISSION, 5),
PERMISSION_RESEND_SUCCESS(BusinessCode.PERMISSION, 5),
PERMISSION_VERIFY_SUCCESS(BusinessCode.PERMISSION, 6),
PERMISSION_UNAUTHORIZED(BusinessCode.PERMISSION, 50),
PERMISSION_USERNAME_NOT_FOUND(BusinessCode.PERMISSION, 51),
@@ -33,6 +34,9 @@ enum class ResponseCode(val code: Int) {
PERMISSION_LOGOUT_FAILED(BusinessCode.PERMISSION, 59),
PERMISSION_TOKEN_ILLEGAL(BusinessCode.PERMISSION, 60),
PERMISSION_TOKEN_HAS_EXPIRED(BusinessCode.PERMISSION, 61),
PERMISSION_NO_VERIFICATION_REQUIRED(BusinessCode.PERMISSION, 62),
PERMISSION_VERIFY_CODE_ERROR_OR_EXPIRED(BusinessCode.PERMISSION, 63),
PERMISSION_ACCOUNT_NEED_INIT(BusinessCode.PERMISSION, 64),
DATABASE_SELECT_SUCCESS(BusinessCode.DATABASE, 0),
DATABASE_SELECT_FAILED(BusinessCode.DATABASE, 5),

View File

@@ -0,0 +1,3 @@
package top.fatweb.api.exception
class AccountNeedInitException : RuntimeException("Account need initialize")

View File

@@ -0,0 +1,5 @@
package top.fatweb.api.exception
class NoEmailConfigException(
vararg configs: String
) : RuntimeException("Email settings not configured: ${configs.joinToString(", ")}")

View File

@@ -0,0 +1,3 @@
package top.fatweb.api.exception
class NoVerificationRequiredException : RuntimeException("No verification required")

View File

@@ -0,0 +1,3 @@
package top.fatweb.api.exception
class VerificationCodeErrorOrExpiredException : RuntimeException("Verification code is error or has expired")

View File

@@ -16,8 +16,7 @@ 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.NoRecordFoundException
import top.fatweb.api.exception.TokenHasExpiredException
import top.fatweb.api.exception.*
import top.fatweb.avatargenerator.AvatarException
/**
@@ -122,6 +121,22 @@ class ExceptionHandler {
ResponseResult.fail(ResponseCode.PERMISSION_ACCESS_DENIED, "Access Denied", null)
}
is NoVerificationRequiredException -> {
logger.debug(e.localizedMessage, e)
ResponseResult.fail(ResponseCode.PERMISSION_NO_VERIFICATION_REQUIRED, e.localizedMessage, null)
}
is VerificationCodeErrorOrExpiredException -> {
logger.debug(e.localizedMessage, e)
ResponseResult.fail(ResponseCode.PERMISSION_VERIFY_CODE_ERROR_OR_EXPIRED, e.localizedMessage, null)
}
is AccountNeedInitException -> {
logger.debug(e.localizedMessage, e)
ResponseResult.fail(ResponseCode.PERMISSION_ACCOUNT_NEED_INIT, e.localizedMessage, null)
}
is BadSqlGrammarException -> {
logger.debug(e.localizedMessage, e)
ResponseResult.fail(ResponseCode.DATABASE_EXECUTE_ERROR, "Incorrect SQL syntax", null)

View File

@@ -1,8 +1,9 @@
package top.fatweb.api.param.permission
import io.swagger.v3.oas.annotations.media.Schema
import jakarta.validation.constraints.Email
import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.Pattern
import jakarta.validation.constraints.Size
/**
* Register parameters
@@ -12,6 +13,17 @@ import jakarta.validation.constraints.NotBlank
*/
@Schema(description = "注册请求参数")
data class RegisterParam(
/**
* Username
*
* @author FatttSnake, fatttsnake@gmail.com
* @since 1.0.0
*/
@Schema(description = "用户名", example = "abc", required = true)
@field:NotBlank(message = "Username can not be blank")
@field:Pattern(regexp = "[a-zA-Z-_][0-9a-zA-Z-_]{2,38}", message = "Illegal username")
val username: String?,
/**
* Email
*
@@ -19,7 +31,8 @@ data class RegisterParam(
* @since 1.0.0
*/
@Schema(description = "邮箱", example = "guest@fatweb.top", required = true)
@field:Email(message = "Illegal email address")
@field:NotBlank(message = "Email can not be blank")
@field:Pattern(regexp = "^\\w+([-+.]\\w+)*@\\w+([-.]\\w+)*\\.\\w+([-.]\\w+)*\$", message = "Illegal email address")
val email: String?,
/**
@@ -30,5 +43,6 @@ data class RegisterParam(
*/
@Schema(description = "密码", example = "test123456", required = true)
@field:NotBlank(message = "Password can not be blank")
@field:Size(min = 10, max = 30)
val password: String?
)

View File

@@ -21,15 +21,6 @@ data class VerifyParam(
@field:NotBlank(message = "Code can not be blank")
val code: String?,
/**
* Username
*
* @author FatttSnake, fatttsnake@gmail.com
* @since 1.0.0
*/
@Schema(description = "用户名", example = "adb")
val username: String?,
/**
* Nickname
*

View File

@@ -23,6 +23,14 @@ interface IAuthenticationService {
*/
fun register(registerParam: RegisterParam)
/**
* Send verify email
*
* @author FatttSnake, fatttsnake@gmail.com
* @since 1.0.0
*/
fun resend()
/**
* Verify
*

View File

@@ -2,8 +2,11 @@ package top.fatweb.api.service.permission.impl
import com.baomidou.mybatisplus.extension.kotlin.KtUpdateWrapper
import jakarta.servlet.http.HttpServletRequest
import org.apache.velocity.VelocityContext
import org.apache.velocity.app.VelocityEngine
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import org.springframework.security.access.AccessDeniedException
import org.springframework.security.authentication.AuthenticationManager
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.crypto.password.PasswordEncoder
@@ -14,7 +17,10 @@ import top.fatweb.api.entity.permission.LoginUser
import top.fatweb.api.entity.permission.User
import top.fatweb.api.entity.permission.UserInfo
import top.fatweb.api.entity.system.EventLog
import top.fatweb.api.exception.AccountNeedInitException
import top.fatweb.api.exception.NoVerificationRequiredException
import top.fatweb.api.exception.TokenHasExpiredException
import top.fatweb.api.exception.VerificationCodeErrorOrExpiredException
import top.fatweb.api.param.permission.LoginParam
import top.fatweb.api.param.permission.RegisterParam
import top.fatweb.api.param.permission.VerifyParam
@@ -24,10 +30,13 @@ import top.fatweb.api.service.permission.IAuthenticationService
import top.fatweb.api.service.permission.IUserInfoService
import top.fatweb.api.service.permission.IUserService
import top.fatweb.api.util.JwtUtil
import top.fatweb.api.util.MailUtil
import top.fatweb.api.util.RedisUtil
import top.fatweb.api.util.WebUtil
import top.fatweb.api.vo.permission.LoginVo
import top.fatweb.api.vo.permission.TokenVo
import java.io.StringWriter
import java.time.Instant
import java.time.LocalDateTime
import java.time.ZoneOffset
import java.util.*
@@ -44,6 +53,7 @@ import java.util.*
*/
@Service
class AuthenticationServiceImpl(
private val velocityEngine: VelocityEngine,
private val authenticationManager: AuthenticationManager,
private val passwordEncoder: PasswordEncoder,
private val redisUtil: RedisUtil,
@@ -56,8 +66,10 @@ class AuthenticationServiceImpl(
@Transactional
override fun register(registerParam: RegisterParam) {
val user = User().apply {
username = "\$UNNAMED_${UUID.randomUUID()}"
username = registerParam.username
password = passwordEncoder.encode(registerParam.password)
verify =
"${LocalDateTime.now(ZoneOffset.UTC).toInstant(ZoneOffset.UTC).toEpochMilli()}-${UUID.randomUUID()}"
locking = 0
enable = 1
}
@@ -67,9 +79,66 @@ class AuthenticationServiceImpl(
avatar = avatarService.randomBase64(null).base64
email = registerParam.email
})
sendVerifyMail(user.username!!, "http://localhost:5173/verify?code=${user.verify!!}", registerParam.email!!)
}
@Transactional
override fun resend() {
val user = userService.getById(WebUtil.getLoginUserId()) ?: throw AccessDeniedException("Access Denied")
user.verify ?: throw NoVerificationRequiredException()
user.verify =
"${LocalDateTime.now(ZoneOffset.UTC).toInstant(ZoneOffset.UTC).toEpochMilli()}-${UUID.randomUUID()}"
user.updateTime = LocalDateTime.now(ZoneOffset.UTC)
userService.updateById(user)
WebUtil.getLoginUser()?.user?.userInfo?.email?.let {
sendVerifyMail(user.username!!, "http://localhost:5173/verify?code=${user.verify!!}", it)
} ?: throw AccessDeniedException("Access Denied")
}
private fun sendVerifyMail(username: String, verifyUrl: String, email: String) {
val velocityContext = VelocityContext().apply {
put("appName", "氮工具")
put("appUrl", "http://localhost:5173/")
put("username", username)
put("verifyUrl", verifyUrl)
}
val template = velocityEngine.getTemplate("templates/email-verify-account-cn.vm")
val stringWriter = StringWriter()
template.merge(velocityContext, stringWriter)
MailUtil.sendSimpleMail(
"激活您的账号", stringWriter.toString(), true,
email
)
}
@Transactional
override fun verify(verifyParam: VerifyParam) {
val user = userService.getById(WebUtil.getLoginUserId()) ?: throw AccessDeniedException("Access Denied")
user.verify ?: throw NoVerificationRequiredException()
if (LocalDateTime.ofInstant(Instant.ofEpochMilli(user.verify!!.split("-").first().toLong()), ZoneOffset.UTC)
.isBefore(LocalDateTime.now(ZoneOffset.UTC).minusHours(2)) || user.verify != verifyParam.code
) {
throw VerificationCodeErrorOrExpiredException()
}
if (verifyParam.nickname.isNullOrBlank() || verifyParam.avatar.isNullOrBlank()) {
throw AccountNeedInitException()
}
userService.update(
KtUpdateWrapper(User()).eq(User::id, user.id).set(User::verify, null)
.set(User::updateTime, LocalDateTime.now(ZoneOffset.UTC))
)
userInfoService.update(
KtUpdateWrapper(UserInfo()).eq(UserInfo::userId, user.id).set(UserInfo::nickname, verifyParam.nickname)
.set(UserInfo::avatar, verifyParam.avatar)
)
}
@EventLogRecord(EventLog.Event.LOGIN)

View File

@@ -45,6 +45,7 @@ class SettingsServiceImpl : ISettingsService {
MailUtil.sendSimpleMail(
"${ServerProperties.appName} Test Message",
"This is a test email sent when testing the system email sending service.",
false,
it
)
}

View File

@@ -1,8 +1,8 @@
package top.fatweb.api.util
import org.springframework.mail.MailSender
import org.springframework.mail.SimpleMailMessage
import org.springframework.mail.javamail.JavaMailSenderImpl
import org.springframework.mail.javamail.MimeMessageHelper
import top.fatweb.api.exception.NoEmailConfigException
import top.fatweb.api.settings.MailSecurityType
import top.fatweb.api.settings.MailSettings
import top.fatweb.api.settings.SettingsOperator
@@ -33,15 +33,19 @@ object MailUtil {
mailSender.javaMailProperties = properties
}
fun getSender(): MailSender = mailSender
fun sendSimpleMail(subject: String, text: String, html: Boolean = false, vararg to: String) {
val fromName = SettingsOperator.getMailValue(MailSettings::fromName) ?: throw NoEmailConfigException("fromName")
val from = SettingsOperator.getMailValue(MailSettings::from) ?: throw NoEmailConfigException("from")
fun sendSimpleMail(subject: String, text: String, vararg to: String) {
mailSender.send(SimpleMailMessage().apply {
val mimeMessage = mailSender.createMimeMessage()
val messageHelper = MimeMessageHelper(mimeMessage, true)
messageHelper.apply {
setSubject(subject)
from = "${SettingsOperator.getMailValue(MailSettings::fromName)}<${SettingsOperator.getMailValue(MailSettings::from)}>"
sentDate = Date()
setTo(*to)
setText(text)
})
setFrom(from, fromName)
setSentDate(Date())
setTo(to)
setText(text, html)
}
mailSender.send(mimeMessage)
}
}

View File

@@ -32,6 +32,15 @@ data class UserWithInfoVo(
@Schema(description = "用户名", example = "User")
val username: String?,
/**
* Verified
*
* @author FatttSnake, fatttsnake@gmail.com
* @since 1.0.0
*/
@Schema(description = "已验证", example = "true")
val verified: Boolean?,
/**
* Locking
*

View File

@@ -32,6 +32,15 @@ data class UserWithPowerInfoVo(
@Schema(description = "用户名", example = "User")
val username: String?,
/**
* Verified
*
* @author FatttSnake, fatttsnake@gmail.com
* @since 1.0.0
*/
@Schema(description = "已验证", example = "true")
val verified: Boolean?,
/**
* Locking
*