Complete core functions #9

Merged
FatttSnake merged 171 commits from FatttSnake into dev 2024-02-23 11:56:35 +08:00
23 changed files with 289 additions and 31 deletions
Showing only changes of commit cf96d8037d - Show all commits

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
*

View File

@@ -5,7 +5,7 @@ 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 '密码',
verify varchar(36) null comment '验证信息',
verify varchar(50) null comment '验证信息',
locking int not null comment '锁定',
expiration datetime comment '过期时间',
credentials_expiration datetime comment '认证过期时间',

View File

@@ -126,7 +126,7 @@
<select id="selectListWithRoleInfoByIds" resultMap="userWithRoleInfoMap">
select t_user.id as user_id,
t_user.username as user_username,
t_user.password as user_password,
t_user.verify as user_verify,
t_user.locking as user_locking,
t_user.expiration as user_expiration,
t_user.credentials_expiration as user_credentials_expiration,
@@ -177,7 +177,7 @@
<select id="selectOneWithRoleInfoById" resultMap="userWithRoleInfoMap">
select t_user.id as user_id,
t_user.username as user_username,
t_user.password as user_password,
t_user.verify as user_verify,
t_user.locking as user_locking,
t_user.expiration as user_expiration,
t_user.credentials_expiration as user_credentials_expiration,
@@ -224,7 +224,7 @@
<select id="selectListWithInfo" resultMap="userWithInfoMap">
select t_user.id as user_id,
t_user.username as user_username,
t_user.password as user_password,
t_user.verify as user_verify,
t_user.locking as user_locking,
t_user.expiration as user_expiration,
t_user.credentials_expiration as user_credentials_expiration,
@@ -282,6 +282,7 @@
<resultMap id="userBaseMap" type="user">
<id property="id" column="user_id"/>
<result property="username" column="user_username"/>
<result property="verify" column="user_verify"/>
<result property="locking" column="user_locking"/>
<result property="expiration" column="user_expiration"/>
<result property="credentialsExpiration" column="user_credentials_expiration"/>

View File

@@ -0,0 +1,85 @@
<!doctype html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>激活您的账号</title>
<style>
* {
margin: 0;
padding: 0;
color: #4D4D4D;
}
a {
color: #4E47BB;
}
body {
display: flex;
background-color: #F1F2F7;
justify-content: center;
flex-wrap: wrap;
}
.view {
display: flex;
width: 800px;
max-width: 100vw;
align-content: center;
}
.card {
margin: 20px;
padding: 20px;
border-radius: 12px;
flex: 1;
background-color: white;
}
.title {
margin-bottom: 10px;
text-align: center;
font-weight: bolder;
font-size: 1.6em;
color: #4E47BB;
}
.verify-button {
display: flex;
margin: 20px;
padding-bottom: 60px;
justify-content: center;
}
.verify-button a {
padding: 20px 30px;
color: white;
text-decoration: none;
font-size: 1.2em;
background-color: #4E47BB;
border-radius: 10px;
}
.not-reply {
margin-top: 20px;
text-align: center;
font-weight: bold;
}
</style>
</head>
<body>
<div class="view">
<div class="card">
<div class="title">账&nbsp;号&nbsp;激&nbsp;活</div>
<div><strong>${username}</strong>,您好:</div>
<div style="text-indent: 2em">感谢注册 <a target="_blank" href=${appUrl}>${appName}(${appUrl})</a>,在继续使用之前,我们需要确定您的电子邮箱地址的有效性,请在 <u><i>两小时内</i></u> 点击下面的按钮帮助我们验证:</div>
<div class="verify-button"><a target="_blank" href=${verifyUrl}>验证邮箱</a></div>
<div>如果以上按钮无法点击,请复制此链接到浏览器地址栏中访问:<a target="_blank" href=${verifyUrl}>${verifyUrl}</a></div>
<div class="not-reply">此邮件由系统自动发送,请勿回复!</div>
</div>
</div>
</body>
</html>