Add two-factor api

This commit is contained in:
2024-02-29 19:33:26 +08:00
parent 376ef81950
commit b52ce7f5e8
31 changed files with 709 additions and 25 deletions

10
pom.xml
View File

@@ -207,6 +207,16 @@
<artifactId>oshi-core</artifactId> <artifactId>oshi-core</artifactId>
<version>6.4.9</version> <version>6.4.9</version>
</dependency> </dependency>
<dependency>
<groupId>commons-codec</groupId>
<artifactId>commons-codec</artifactId>
<version>1.16.1</version>
</dependency>
<dependency>
<groupId>com.google.zxing</groupId>
<artifactId>core</artifactId>
<version>3.5.3</version>
</dependency>
</dependencies> </dependencies>
<build> <build>

View File

@@ -16,6 +16,7 @@ import top.fatweb.oxygen.api.util.WebUtil
import top.fatweb.oxygen.api.vo.permission.LoginVo import top.fatweb.oxygen.api.vo.permission.LoginVo
import top.fatweb.oxygen.api.vo.permission.RegisterVo import top.fatweb.oxygen.api.vo.permission.RegisterVo
import top.fatweb.oxygen.api.vo.permission.TokenVo import top.fatweb.oxygen.api.vo.permission.TokenVo
import top.fatweb.oxygen.api.vo.permission.TwoFactorVo
/** /**
* Authentication controller * Authentication controller
@@ -153,6 +154,33 @@ class AuthenticationController(
authenticationService.login(request, loginParam) authenticationService.login(request, loginParam)
) )
/**
* Create two-factor
*
* @return Response object includes two-factor QR code
* @author FatttSnake, fatttsnake@gmail.com
* @since 1.0.0
* @see ResponseResult
* @see TwoFactorVo
*/
@Operation(summary = "创建二步验证")
@GetMapping("/two-factor")
fun createTwoFactor(): ResponseResult<TwoFactorVo> =
ResponseResult.success(data = authenticationService.createTwoFactor())
/**
* Validate two-factor
*
* @author FatttSnake, fatttsnake@gmail.com
* @since 1.0.0
*/
@Operation(summary = "验证二步验证")
@PostMapping("/two-factor")
fun validateTwoFactor(@RequestBody @Valid twoFactorValidateParam: TwoFactorValidateParam): ResponseResult<Nothing> =
if (authenticationService.validateTwoFactor(twoFactorValidateParam)) ResponseResult.success()
else ResponseResult.fail()
/** /**
* Logout * Logout
* *

View File

@@ -3,12 +3,7 @@ package top.fatweb.oxygen.api.controller.system
import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.Operation
import jakarta.validation.Valid import jakarta.validation.Valid
import org.springframework.security.access.prepost.PreAuthorize import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.*
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.PutMapping
import org.springframework.web.bind.annotation.RequestBody
import top.fatweb.oxygen.api.annotation.BaseController import top.fatweb.oxygen.api.annotation.BaseController
import top.fatweb.oxygen.api.annotation.Trim import top.fatweb.oxygen.api.annotation.Trim
import top.fatweb.oxygen.api.entity.common.ResponseCode import top.fatweb.oxygen.api.entity.common.ResponseCode
@@ -19,6 +14,7 @@ import top.fatweb.oxygen.api.service.system.ISettingsService
import top.fatweb.oxygen.api.vo.system.BaseSettingsVo import top.fatweb.oxygen.api.vo.system.BaseSettingsVo
import top.fatweb.oxygen.api.vo.system.MailSettingsVo import top.fatweb.oxygen.api.vo.system.MailSettingsVo
import top.fatweb.oxygen.api.vo.system.SensitiveWordVo import top.fatweb.oxygen.api.vo.system.SensitiveWordVo
import top.fatweb.oxygen.api.vo.system.TwoFactorSettingsVo
/** /**
* System settings management controller * System settings management controller
@@ -45,7 +41,8 @@ class SettingsController(
@Operation(summary = "获取基础设置") @Operation(summary = "获取基础设置")
@GetMapping("/base") @GetMapping("/base")
@PreAuthorize("hasAnyAuthority('system:settings:query:base')") @PreAuthorize("hasAnyAuthority('system:settings:query:base')")
fun getApp(): ResponseResult<BaseSettingsVo> = ResponseResult.success(data = settingsService.getBase()) fun getApp(): ResponseResult<BaseSettingsVo> =
ResponseResult.success(data = settingsService.getBase())
/** /**
* Update base settings * Update base settings
@@ -78,7 +75,8 @@ class SettingsController(
@Operation(summary = "获取邮件设置") @Operation(summary = "获取邮件设置")
@GetMapping("/mail") @GetMapping("/mail")
@PreAuthorize("hasAnyAuthority('system:settings:query:mail')") @PreAuthorize("hasAnyAuthority('system:settings:query:mail')")
fun getMail(): ResponseResult<MailSettingsVo> = ResponseResult.success(data = settingsService.getMail()) fun getMail(): ResponseResult<MailSettingsVo> =
ResponseResult.success(data = settingsService.getMail())
/** /**
* Update mail settings * Update mail settings
@@ -186,4 +184,38 @@ class SettingsController(
sensitiveWordService.delete(id) sensitiveWordService.delete(id)
return ResponseResult.databaseSuccess(ResponseCode.DATABASE_DELETE_SUCCESS) return ResponseResult.databaseSuccess(ResponseCode.DATABASE_DELETE_SUCCESS)
} }
/**
* Get two-factor settings
*
* @return Response object includes two-factor settings information
* @author FatttSnake, fatttsnake@gmail.com
* @since 1.0.0
* @see ResponseResult
* @see TwoFactorSettingsVo
*/
@Operation(summary = "获取二步验证设置")
@GetMapping("/two-factor")
@PreAuthorize("hasAnyAuthority('system:settings:query:two-factor')")
fun getTwoFactor(): ResponseResult<TwoFactorSettingsVo> =
ResponseResult.success(data = settingsService.getTwoFactor())
/**
* Update two-factor settings
*
* @param twoFactorSettingsParam Two-factor settings parameters
* @return Response object
* @author FatttSnake, fatttsnake@gmail.com
* @since 1.0.0
* @see TwoFactorSettingsParam
* @see ResponseResult
*/
@Trim
@Operation(summary = "更新二步验证设置")
@PutMapping("/two-factor")
@PreAuthorize("hasAnyAuthority('system:settings:modify:two-factor')")
fun updateTwoFactor(@RequestBody twoFactorSettingsParam: TwoFactorSettingsParam): ResponseResult<Nothing> {
settingsService.updateTwoFactor(twoFactorSettingsParam)
return ResponseResult.success()
}
} }

View File

@@ -34,6 +34,7 @@ object UserConverter {
fun userToUserWithPowerInfoVo(user: User) = UserWithPowerInfoVo( fun userToUserWithPowerInfoVo(user: User) = UserWithPowerInfoVo(
id = user.id, id = user.id,
username = user.username, username = user.username,
twoFactor = !user.twoFactor.isNullOrBlank() && !user.twoFactor!!.endsWith("?"),
verified = user.verify.isNullOrBlank(), verified = user.verify.isNullOrBlank(),
locking = user.locking?.let { it == 1 }, locking = user.locking?.let { it == 1 },
expiration = user.expiration, expiration = user.expiration,
@@ -65,6 +66,7 @@ object UserConverter {
fun userToUserWithRoleInfoVo(user: User) = UserWithRoleInfoVo( fun userToUserWithRoleInfoVo(user: User) = UserWithRoleInfoVo(
id = user.id, id = user.id,
username = user.username, username = user.username,
twoFactor = !user.twoFactor.isNullOrBlank() && !user.twoFactor!!.endsWith("?"),
verify = user.verify, verify = user.verify,
locking = user.locking?.let { it == 1 }, locking = user.locking?.let { it == 1 },
expiration = user.expiration, expiration = user.expiration,
@@ -94,6 +96,7 @@ object UserConverter {
fun userToUserWithInfoVo(user: User) = UserWithInfoVo( fun userToUserWithInfoVo(user: User) = UserWithInfoVo(
id = user.id, id = user.id,
username = user.username, username = user.username,
twoFactor = !user.twoFactor.isNullOrBlank() && !user.twoFactor!!.endsWith("?"),
verified = user.verify.isNullOrBlank(), verified = user.verify.isNullOrBlank(),
locking = user.locking?.let { it == 1 }, locking = user.locking?.let { it == 1 },
expiration = user.expiration, expiration = user.expiration,
@@ -122,6 +125,7 @@ object UserConverter {
id = user.id, id = user.id,
username = user.username, username = user.username,
password = user.password, password = user.password,
twoFactor = !user.twoFactor.isNullOrBlank() && !user.twoFactor!!.endsWith("?"),
verify = user.verify, verify = user.verify,
locking = user.locking?.let { it == 1 }, locking = user.locking?.let { it == 1 },
expiration = user.expiration, expiration = user.expiration,

View File

@@ -45,6 +45,9 @@ enum class ResponseCode(val code: Int) {
PERMISSION_USER_NOT_FOUND(BusinessCode.PERMISSION, 65), PERMISSION_USER_NOT_FOUND(BusinessCode.PERMISSION, 65),
PERMISSION_RETRIEVE_CODE_ERROR_OR_EXPIRED(BusinessCode.PERMISSION, 66), PERMISSION_RETRIEVE_CODE_ERROR_OR_EXPIRED(BusinessCode.PERMISSION, 66),
PERMISSION_ACCOUNT_NEED_RESET_PASSWORD(BusinessCode.PERMISSION, 67), PERMISSION_ACCOUNT_NEED_RESET_PASSWORD(BusinessCode.PERMISSION, 67),
PERMISSION_NEED_TWO_FACTOR(BusinessCode.PERMISSION, 68),
PERMISSION_ALREADY_HAS_TWO_FACTOR(BusinessCode.PERMISSION, 69),
PERMISSION_NO_TWO_FACTOR_FOUND(BusinessCode.PERMISSION, 70),
DATABASE_SELECT_SUCCESS(BusinessCode.DATABASE, 0), DATABASE_SELECT_SUCCESS(BusinessCode.DATABASE, 0),
DATABASE_SELECT_FAILED(BusinessCode.DATABASE, 5), DATABASE_SELECT_FAILED(BusinessCode.DATABASE, 5),

View File

@@ -46,6 +46,15 @@ class User() : Serializable {
@TableField("password") @TableField("password")
var password: String? = null var password: String? = null
/**
* Two-factor
*
* @author FatttSnake, fatttsnake@gmail.com
* @since 1.0.0
*/
@TableField("two_factor")
var twoFactor: String? = null
/** /**
* Verify email * Verify email
* *
@@ -251,6 +260,6 @@ class User() : Serializable {
var operations: List<Operation>? = null var operations: List<Operation>? = null
override fun toString(): String { override fun toString(): String {
return "User(id=$id, username=$username, password=$password, verify=$verify, forget=$forget, locking=$locking, expiration=$expiration, credentialsExpiration=$credentialsExpiration, enable=$enable, currentLoginTime=$currentLoginTime, currentLoginIp=$currentLoginIp, lastLoginTime=$lastLoginTime, lastLoginIp=$lastLoginIp, createTime=$createTime, updateTime=$updateTime, deleted=$deleted, version=$version, userInfo=$userInfo, roles=$roles, groups=$groups, modules=$modules, menus=$menus, funcs=$funcs, operations=$operations)" return "User(id=$id, username=$username, password=$password, twoFactor=$twoFactor, verify=$verify, forget=$forget, locking=$locking, expiration=$expiration, credentialsExpiration=$credentialsExpiration, enable=$enable, currentLoginTime=$currentLoginTime, currentLoginIp=$currentLoginIp, lastLoginTime=$lastLoginTime, lastLoginIp=$lastLoginIp, createTime=$createTime, updateTime=$updateTime, deleted=$deleted, version=$version, userInfo=$userInfo, roles=$roles, groups=$groups, modules=$modules, menus=$menus, funcs=$funcs, operations=$operations)"
} }
} }

View File

@@ -0,0 +1,10 @@
package top.fatweb.oxygen.api.exception
/**
* Already has two-factor exception
*
* @author FatttSnake, fatttsnake@gmail.com
* @since 1.0.0
* @see RuntimeException
*/
class AlreadyHasTwoFactorException : RuntimeException("Already has two-factor")

View File

@@ -0,0 +1,10 @@
package top.fatweb.oxygen.api.exception
/**
* Need two-factor code exception
*
* @author FatttSnake, fatttsnake@gmail.com
* @since 1.0.0
* @see RuntimeException
*/
class NeedTwoFactorException : RuntimeException("Need two-factor code")

View File

@@ -0,0 +1,10 @@
package top.fatweb.oxygen.api.exception
/**
* No two-factor found exception
*
* @author FatttSnake, fatttsnake@gmail.com
* @since 1.0.0
* @see RuntimeException
*/
class NoTwoFactorFoundException : RuntimeException("No two-factor found")

View File

@@ -165,6 +165,21 @@ class ExceptionHandler {
ResponseResult.fail(ResponseCode.SYSTEM_INVALID_CAPTCHA_CODE, e.localizedMessage, null) ResponseResult.fail(ResponseCode.SYSTEM_INVALID_CAPTCHA_CODE, e.localizedMessage, null)
} }
is NeedTwoFactorException -> {
logger.debug(e.localizedMessage, e)
ResponseResult.fail(ResponseCode.PERMISSION_NEED_TWO_FACTOR, e.localizedMessage, null)
}
is AlreadyHasTwoFactorException -> {
logger.debug(e.localizedMessage, e)
ResponseResult.fail(ResponseCode.PERMISSION_ALREADY_HAS_TWO_FACTOR, e.localizedMessage, null)
}
is NoTwoFactorFoundException -> {
logger.debug(e.localizedMessage, e)
ResponseResult.fail(ResponseCode.PERMISSION_NO_TWO_FACTOR_FOUND, e.localizedMessage, null)
}
/* SQL */ /* SQL */
is DatabaseSelectException -> { is DatabaseSelectException -> {
logger.debug(e.localizedMessage, e) logger.debug(e.localizedMessage, e)

View File

@@ -34,5 +34,14 @@ data class LoginParam(
*/ */
@Schema(description = "密码", required = true) @Schema(description = "密码", required = true)
@field:NotBlank(message = "Password can not be blank") @field:NotBlank(message = "Password can not be blank")
val password: String? val password: String?,
/**
* Two-factor code
*
* @author FatttSnake, fatttsnake@gmail.com
* @since 1.0.0
*/
@Schema(description = "二步验证码")
val twoFactorCode: String?
) : CaptchaCodeParam() ) : CaptchaCodeParam()

View File

@@ -0,0 +1,23 @@
package top.fatweb.oxygen.api.param.permission
import io.swagger.v3.oas.annotations.media.Schema
import jakarta.validation.constraints.NotBlank
/**
* Validate two-factor parameters
*
* @author FatttSnake, fatttsnake@gmail.com
* @since 1.0.0
*/
@Schema(description = "验证二步验证请求参数")
data class TwoFactorValidateParam(
/**
* Code
*
* @author FatttSnake, fatttsnake@gmail.com
* @since 1.0.0
*/
@Schema(description = "验证码")
@field:NotBlank(message = "Code can not be blank")
val code: String?
)

View File

@@ -0,0 +1,37 @@
package top.fatweb.oxygen.api.param.system
import io.swagger.v3.oas.annotations.media.Schema
import jakarta.validation.constraints.Min
import jakarta.validation.constraints.NotNull
import top.fatweb.oxygen.api.annotation.Trim
/**
* Two-factor settings parameters
*
* @author FatttSnake, fatttsnake@gmail.com
* @since 1.0.0
*/
@Trim
@Schema(description = "二步验证设置请求参数")
data class TwoFactorSettingsParam(
/**
* Issuer
*
* @author FatttSnake, fatttsnake@gmail.com
* @since 1.0.0
*/
@Trim
@Schema(description = "发布者")
var issuer: String?,
/**
* Length of secret key
*
* @author FatttSnake, fatttsnake@gmail.com
* @since 1.0.0
*/
@Schema(description = "密钥长度")
@field:NotNull(message = "Length of secret key can not be null")
@field:Min(value = 3, message = "The length of the key must be greater than or equal to 3")
val secretKeyLength: Int?
)

View File

@@ -6,6 +6,7 @@ import top.fatweb.oxygen.api.param.permission.*
import top.fatweb.oxygen.api.vo.permission.LoginVo import top.fatweb.oxygen.api.vo.permission.LoginVo
import top.fatweb.oxygen.api.vo.permission.RegisterVo import top.fatweb.oxygen.api.vo.permission.RegisterVo
import top.fatweb.oxygen.api.vo.permission.TokenVo import top.fatweb.oxygen.api.vo.permission.TokenVo
import top.fatweb.oxygen.api.vo.permission.TwoFactorVo
/** /**
* Authentication service interface * Authentication service interface
@@ -82,6 +83,27 @@ interface IAuthenticationService {
*/ */
fun login(request: HttpServletRequest, loginParam: LoginParam): LoginVo fun login(request: HttpServletRequest, loginParam: LoginParam): LoginVo
/**
* Create two-factor
*
* @return Two-factor QR code
* @author FatttSnake, fatttsnake@gmail.com
* @since 1.0.0
* @see TwoFactorVo
*/
fun createTwoFactor(): TwoFactorVo
/**
* Validate two-factor
*
* @param twoFactorValidateParam Validate two-factor parameters
* @return Result
* @author FatttSnake, fatttsnake@gmail.com
* @since 1.0.0
* @see TwoFactorValidateParam
*/
fun validateTwoFactor(twoFactorValidateParam: TwoFactorValidateParam): Boolean
/** /**
* Logout * Logout
* *

View File

@@ -29,13 +29,12 @@ import top.fatweb.oxygen.api.service.permission.IUserService
import top.fatweb.oxygen.api.service.system.ISensitiveWordService import top.fatweb.oxygen.api.service.system.ISensitiveWordService
import top.fatweb.oxygen.api.settings.BaseSettings import top.fatweb.oxygen.api.settings.BaseSettings
import top.fatweb.oxygen.api.settings.SettingsOperator import top.fatweb.oxygen.api.settings.SettingsOperator
import top.fatweb.oxygen.api.util.JwtUtil import top.fatweb.oxygen.api.settings.TwoFactorSettings
import top.fatweb.oxygen.api.util.MailUtil import top.fatweb.oxygen.api.util.*
import top.fatweb.oxygen.api.util.RedisUtil
import top.fatweb.oxygen.api.util.WebUtil
import top.fatweb.oxygen.api.vo.permission.LoginVo import top.fatweb.oxygen.api.vo.permission.LoginVo
import top.fatweb.oxygen.api.vo.permission.RegisterVo import top.fatweb.oxygen.api.vo.permission.RegisterVo
import top.fatweb.oxygen.api.vo.permission.TokenVo import top.fatweb.oxygen.api.vo.permission.TokenVo
import top.fatweb.oxygen.api.vo.permission.TwoFactorVo
import java.io.StringWriter import java.io.StringWriter
import java.time.Instant import java.time.Instant
import java.time.LocalDateTime import java.time.LocalDateTime
@@ -205,7 +204,39 @@ class AuthenticationServiceImpl(
override fun login(request: HttpServletRequest, loginParam: LoginParam): LoginVo { override fun login(request: HttpServletRequest, loginParam: LoginParam): LoginVo {
verifyCaptcha(loginParam.captchaCode!!) verifyCaptcha(loginParam.captchaCode!!)
return this.login(request, loginParam.account!!, loginParam.password!!) return this.login(request, loginParam.account!!, loginParam.password!!, loginParam.twoFactorCode)
}
override fun createTwoFactor(): TwoFactorVo {
val user = userService.getById(WebUtil.getLoginUserId()) ?: throw UserNotFoundException()
if (!user.twoFactor.isNullOrBlank() && !user.twoFactor!!.endsWith("?")) {
throw AlreadyHasTwoFactorException()
}
val secretKey = TOTPUtil.generateSecretKey(SettingsOperator.getTwoFactorValue(TwoFactorSettings::secretKeyLength, 16))
val qrCodeSVGBase64 = TOTPUtil.generateQRCodeSVGBase64(
SettingsOperator.getTwoFactorValue(TwoFactorSettings::issuer, "OxygenToolbox"),
user.username!!,
secretKey
)
userService.update(KtUpdateWrapper(User()).eq(User::id, user.id).set(User::twoFactor, "${secretKey}?"))
return TwoFactorVo(qrCodeSVGBase64)
}
override fun validateTwoFactor(twoFactorValidateParam: TwoFactorValidateParam): Boolean {
val user = userService.getById(WebUtil.getLoginUserId()) ?: throw UserNotFoundException()
if (user.twoFactor.isNullOrBlank()) {
throw NoTwoFactorFoundException()
}
if (!user.twoFactor!!.endsWith("?")) {
throw AlreadyHasTwoFactorException()
}
val secretKey = user.twoFactor!!.substring(0, user.twoFactor!!.length - 1)
return TOTPUtil.validateCode(secretKey, twoFactorValidateParam.code!!)
} }
@EventLogRecord(EventLog.Event.LOGOUT) @EventLogRecord(EventLog.Event.LOGOUT)
@@ -302,7 +333,20 @@ class AuthenticationServiceImpl(
) )
} }
private fun login(request: HttpServletRequest, account: String, password: String): LoginVo { private fun login(
request: HttpServletRequest,
account: String,
password: String,
twoFactorCode: String? = null
): LoginVo {
val userWithPowerByAccount = userService.getUserWithPowerByAccount(account) ?: throw UserNotFoundException()
if (!userWithPowerByAccount.twoFactor.isNullOrBlank()
&& !userWithPowerByAccount.twoFactor!!.endsWith("?")
&& twoFactorCode.isNullOrBlank()
) {
throw NeedTwoFactorException()
}
val usernamePasswordAuthenticationToken = val usernamePasswordAuthenticationToken =
UsernamePasswordAuthenticationToken(account, password) UsernamePasswordAuthenticationToken(account, password)
val authentication = authenticationManager.authenticate(usernamePasswordAuthenticationToken) val authentication = authenticationManager.authenticate(usernamePasswordAuthenticationToken)

View File

@@ -4,6 +4,7 @@ import org.springframework.security.core.userdetails.UserDetails
import org.springframework.security.core.userdetails.UserDetailsService import org.springframework.security.core.userdetails.UserDetailsService
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import top.fatweb.oxygen.api.entity.permission.LoginUser import top.fatweb.oxygen.api.entity.permission.LoginUser
import top.fatweb.oxygen.api.exception.UserNotFoundException
import top.fatweb.oxygen.api.service.permission.IUserService import top.fatweb.oxygen.api.service.permission.IUserService
/** /**
@@ -18,7 +19,7 @@ import top.fatweb.oxygen.api.service.permission.IUserService
class UserDetailsServiceImpl(val userService: IUserService) : UserDetailsService { class UserDetailsServiceImpl(val userService: IUserService) : UserDetailsService {
override fun loadUserByUsername(account: String): UserDetails { override fun loadUserByUsername(account: String): UserDetails {
val user = userService.getUserWithPowerByAccount(account) val user = userService.getUserWithPowerByAccount(account)
user ?: throw Exception("Username not found") user ?: throw UserNotFoundException()
return LoginUser(user) return LoginUser(user)
} }

View File

@@ -3,8 +3,10 @@ package top.fatweb.oxygen.api.service.system
import top.fatweb.oxygen.api.param.system.BaseSettingsParam import top.fatweb.oxygen.api.param.system.BaseSettingsParam
import top.fatweb.oxygen.api.param.system.MailSendParam import top.fatweb.oxygen.api.param.system.MailSendParam
import top.fatweb.oxygen.api.param.system.MailSettingsParam import top.fatweb.oxygen.api.param.system.MailSettingsParam
import top.fatweb.oxygen.api.param.system.TwoFactorSettingsParam
import top.fatweb.oxygen.api.vo.system.BaseSettingsVo import top.fatweb.oxygen.api.vo.system.BaseSettingsVo
import top.fatweb.oxygen.api.vo.system.MailSettingsVo import top.fatweb.oxygen.api.vo.system.MailSettingsVo
import top.fatweb.oxygen.api.vo.system.TwoFactorSettingsVo
/** /**
* Settings service interface * Settings service interface
@@ -62,4 +64,24 @@ interface ISettingsService {
* @see MailSettingsParam * @see MailSettingsParam
*/ */
fun sendMail(mailSendParam: MailSendParam) fun sendMail(mailSendParam: MailSendParam)
/**
* Get two-factor settings
*
* @return TwoFactorSettingsVo object
* @author FatttSnake, fatttsnake@gmail.com
* @since 1.0.0
* @see TwoFactorSettingsVo
*/
fun getTwoFactor(): TwoFactorSettingsVo?
/**
* Update two-factor settings
*
* @param twoFactorSettingsParam Two-factor settings parameters
* @author FatttSnake, fatttsnake@gmail.com
* @since 1.0.0
* @see TwoFactorSettingsParam
*/
fun updateTwoFactor(twoFactorSettingsParam: TwoFactorSettingsParam)
} }

View File

@@ -4,15 +4,18 @@ import org.springframework.stereotype.Service
import top.fatweb.oxygen.api.param.system.BaseSettingsParam import top.fatweb.oxygen.api.param.system.BaseSettingsParam
import top.fatweb.oxygen.api.param.system.MailSendParam import top.fatweb.oxygen.api.param.system.MailSendParam
import top.fatweb.oxygen.api.param.system.MailSettingsParam import top.fatweb.oxygen.api.param.system.MailSettingsParam
import top.fatweb.oxygen.api.param.system.TwoFactorSettingsParam
import top.fatweb.oxygen.api.properties.ServerProperties import top.fatweb.oxygen.api.properties.ServerProperties
import top.fatweb.oxygen.api.service.system.ISettingsService import top.fatweb.oxygen.api.service.system.ISettingsService
import top.fatweb.oxygen.api.settings.BaseSettings import top.fatweb.oxygen.api.settings.BaseSettings
import top.fatweb.oxygen.api.settings.MailSettings import top.fatweb.oxygen.api.settings.MailSettings
import top.fatweb.oxygen.api.settings.SettingsOperator import top.fatweb.oxygen.api.settings.SettingsOperator
import top.fatweb.oxygen.api.settings.TwoFactorSettings
import top.fatweb.oxygen.api.util.MailUtil import top.fatweb.oxygen.api.util.MailUtil
import top.fatweb.oxygen.api.util.StrUtil import top.fatweb.oxygen.api.util.StrUtil
import top.fatweb.oxygen.api.vo.system.BaseSettingsVo import top.fatweb.oxygen.api.vo.system.BaseSettingsVo
import top.fatweb.oxygen.api.vo.system.MailSettingsVo import top.fatweb.oxygen.api.vo.system.MailSettingsVo
import top.fatweb.oxygen.api.vo.system.TwoFactorSettingsVo
/** /**
* Settings service implement * Settings service implement
@@ -79,4 +82,16 @@ class SettingsServiceImpl : ISettingsService {
) )
} }
} }
override fun getTwoFactor()= TwoFactorSettingsVo(
issuer = SettingsOperator.getTwoFactorValue(TwoFactorSettings::issuer, "OxygenToolbox"),
secretKeyLength = SettingsOperator.getTwoFactorValue(TwoFactorSettings::secretKeyLength, 16)
)
override fun updateTwoFactor(twoFactorSettingsParam: TwoFactorSettingsParam) {
twoFactorSettingsParam.run {
SettingsOperator.setTwoFactorValue(TwoFactorSettings::issuer, issuer)
SettingsOperator.setTwoFactorValue(TwoFactorSettings::secretKeyLength, secretKeyLength)
}
}
} }

View File

@@ -101,7 +101,8 @@ object SettingsOperator {
* @see KMutableProperty1 * @see KMutableProperty1
* @see BaseSettings * @see BaseSettings
*/ */
fun <V> getAppValue(field: KMutableProperty1<BaseSettings, V?>): V? = this.getAppValue(field, null) fun <V> getAppValue(field: KMutableProperty1<BaseSettings, V?>): V? =
this.getAppValue(field, null)
/** /**
* Get base settings value with default value * Get base settings value with default value
@@ -154,7 +155,8 @@ object SettingsOperator {
* @see KMutableProperty1 * @see KMutableProperty1
* @see MailSettings * @see MailSettings
*/ */
fun <V> getMailValue(field: KMutableProperty1<MailSettings, V?>): V? = this.getMailValue(field, null) fun <V> getMailValue(field: KMutableProperty1<MailSettings, V?>): V? =
this.getMailValue(field, null)
/** /**
* Get value from mail settings with default value * Get value from mail settings with default value
@@ -169,4 +171,51 @@ object SettingsOperator {
*/ */
fun <V> getMailValue(field: KMutableProperty1<MailSettings, V?>, default: V): V = fun <V> getMailValue(field: KMutableProperty1<MailSettings, V?>, default: V): V =
systemSettings.mail?.let(field) ?: default systemSettings.mail?.let(field) ?: default
/**
* Set two-factor settings value
*
* @param field Field to set value. e.g. TwoFactorSettings::type
* @param value Value to set
* @author FatttSnake, fatttsnake@gmail.com
* @since 1.0.0
* @see KMutableProperty1
* @see TwoFactorSettings
*/
fun <V> setTwoFactorValue(field: KMutableProperty1<TwoFactorSettings, V?>, value: V?) {
systemSettings.twoFactor?.let {
field.set(it, value)
} ?: let {
systemSettings.twoFactor = TwoFactorSettings().also { field.set(it, value) }
}
saveSettingsToFile()
}
/**
* Get value from two-factor settings
*
* @param field Field to get value from. e.g. TwoFactorSettings::type
* @return Value
* @author FatttSnake, fatttsnake@gmail.com
* @since 1.0.0
* @see KMutableProperty1
* @see TwoFactorSettings
*/
fun <V> getTwoFactorValue(field: KMutableProperty1<TwoFactorSettings, V?>): V? =
this.getTwoFactorValue(field, null)
/**
* Get value from two-factor settings with default value
*
* @param field Field to get value from. e.g. TwoFactorSettings::type
* @param default Return default value when setting not found
* @return Value
* @author FatttSnake, fatttsnake@gmail.com
* @since 1.0.0
* @see KMutableProperty1
* @see TwoFactorSettings
*/
fun <V> getTwoFactorValue(field: KMutableProperty1<TwoFactorSettings, V?>, default: V): V =
systemSettings.twoFactor?.let(field) ?: default
} }

View File

@@ -24,5 +24,13 @@ data class SystemSettings(
* @author FatttSnake, fatttsnake@gmail.com * @author FatttSnake, fatttsnake@gmail.com
* @since 1.0.0 * @since 1.0.0
*/ */
var mail: MailSettings? = null var mail: MailSettings? = null,
/**
* Two-factor setting
*
* @author FatttSnake, fatttsnake@gmail.com
* @since 1.0.0
*/
var twoFactor: TwoFactorSettings? = null
) )

View File

@@ -0,0 +1,28 @@
package top.fatweb.oxygen.api.settings
import com.fasterxml.jackson.annotation.JsonInclude
/**
* Two-factor settings entity
*
* @author FatttSnake, fatttsnake@gmail.com
* @since 1.0.0
*/
@JsonInclude(JsonInclude.Include.NON_EMPTY)
data class TwoFactorSettings(
/**
* Issuer
*
* @author FatttSnake, fatttsnake@gmail.com
* @since 1.0.0
*/
var issuer: String? = null,
/**
* Length of secret key
*
* @author FatttSnake, fatttsnake@gmail.com
* @since 1.0.0
*/
var secretKeyLength: Int? = 16
)

View File

@@ -0,0 +1,198 @@
package top.fatweb.oxygen.api.util
import com.google.zxing.BarcodeFormat
import com.google.zxing.EncodeHintType
import com.google.zxing.common.BitMatrix
import com.google.zxing.qrcode.QRCodeWriter
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
import org.apache.commons.codec.binary.Base32
import java.net.URLEncoder
import java.nio.ByteBuffer
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
import kotlin.experimental.and
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.math.pow
import kotlin.random.Random
import kotlin.time.Duration.Companion.seconds
/**
* TOTP util
*
* @author FatttSnake, fatttsnake@gmail.com
* @since 1.0.0
*/
object TOTPUtil {
private val TIME_PERIOD = 30.seconds
private val FLEXIBLE_TIME = 5.seconds
private const val DIGITS = 6
private fun computeCounterForMillis(millis: Long): Long = Math.floorDiv(millis, TIME_PERIOD.inWholeMilliseconds)
private fun generateHash(secret: ByteArray, payload: ByteArray): ByteArray {
val secretKey = Base32().decode(secret)
val hmacSha1 = Mac.getInstance("HmacSHA1")
hmacSha1.init(SecretKeySpec(secretKey, "RAW"))
return hmacSha1.doFinal(payload)
}
private fun truncateHash(hash: ByteArray): ByteArray {
val offset = hash.last().and(0x0F).toInt()
val truncatedHash = ByteArray(4)
for (i in 0..3) {
truncatedHash[i] = hash[offset + i]
}
truncatedHash[0] = truncatedHash[0].and(0x7F)
return truncatedHash
}
private fun calculateCode(key: String, time: Long): String {
val timeCounter = computeCounterForMillis(time)
val payload = ByteBuffer.allocate(8).putLong(0, timeCounter).array()
val secretKey = key.toByteArray(Charsets.UTF_8)
val hash = generateHash(secretKey, payload)
val truncatedHash = truncateHash(hash)
val code = ByteBuffer.wrap(truncatedHash).int % 10.0.pow(DIGITS).toInt()
return code.toString().padStart(DIGITS, '0')
}
/**
* Generate TOTP code
*
* @param secretKey Secret key
* @return TOTP Code
* @author FatttSnake, fatttsnake@gmail.com
* @since 1.0.0
*/
fun generateCode(secretKey: String, time: Long = System.currentTimeMillis()): String =
calculateCode(secretKey, time)
/**
* Validate TOTP code
*
* @param secretKey Secret key
* @param code TOTP code
* @return Result
* @author FatttSnake, fatttsnake@gmail.com
* @since 1.0.0
*/
fun validateCode(secretKey: String, code: String): Boolean {
var time = System.currentTimeMillis()
if (calculateCode(secretKey, time) == code) {
return true
}
time -= FLEXIBLE_TIME.inWholeMilliseconds
return calculateCode(secretKey, time) == code
}
private fun encodeUrl(str: String) = URLEncoder.encode(str, Charsets.UTF_8).replace("+", "%20")
/**
* Generate secret key
*
* @param length Secret key length
* @return Secret key
* @author FatttSnake, fatttsnake@gmail.com
* @since 1.0.0
*/
fun generateSecretKey(length: Int = 16): String {
if (length < 3) {
throw IllegalArgumentException("Password length should be at least 3.")
}
val lowercaseChars = "abcdefghijklmnopqrstuvwxyz"
val uppercaseChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
val digits = "0123456789"
val allChars = lowercaseChars + uppercaseChars + digits
val secretKey = StringBuilder()
secretKey.append(
lowercaseChars[Random.nextInt(lowercaseChars.length)],
uppercaseChars[Random.nextInt(uppercaseChars.length)],
digits[Random.nextInt(digits.length)]
)
repeat(length - 3) {
secretKey.append(allChars[Random.nextInt(allChars.length)])
}
return secretKey.toString().toList().shuffled().joinToString()
}
/**
* Generate TOTP URL
*
* @param issuer Issuer
* @param username Username
* @param secretKey Secret key
* @return TOTP URL
* @author FatttSnake, fatttsnake@gmail.com
* @since 1.0.0
*/
fun generateUrl(issuer: String, username: String, secretKey: String): String =
"otpauth://totp/${encodeUrl(issuer)}:${encodeUrl(username)}?secret=${encodeUrl(secretKey)}"
/**
* Generate TOTP QR code
*
* @param issuer Issuer
* @param username Username
* @param secretKey Secret key
* @return QR code
* @author FatttSnake, fatttsnake@gmail.com
* @since 1.0.0
* @see BitMatrix
*/
fun generateQRCode(issuer: String, username: String, secretKey: String): BitMatrix {
val hints = HashMap<EncodeHintType, Any>()
hints[EncodeHintType.CHARACTER_SET] = Charsets.UTF_8
hints[EncodeHintType.ERROR_CORRECTION] = ErrorCorrectionLevel.H
hints[EncodeHintType.MARGIN] = 1
return QRCodeWriter().encode(generateUrl(issuer, username, secretKey), BarcodeFormat.QR_CODE, 200, 200, hints)
}
/**
* Generate TOTP QR code SVG
*
* @param issuer Issuer
* @param username Username
* @param secretKey Secret key
* @return QR code as SVG string
* @author FatttSnake, fatttsnake@gmail.com
* @since 1.0.0
*/
fun generateQRCodeSVG(issuer: String, username: String, secretKey: String): String {
val qrCode = generateQRCode(issuer, username, secretKey)
val stringBuilder = StringBuilder("<svg xmlns=\"http://www.w3.org/2000/svg\" version=\"1.1\" viewBox=\"0 0 ${qrCode.width} ${qrCode.height}\">")
for (y in 0 until qrCode.height) {
for (x in 0 until qrCode.width) {
if (qrCode.get(x, y)) {
stringBuilder.appendLine(" <rect width=\"1\" height=\"1\" x=\"$x\" y=\"$y\"/>")
}
}
}
stringBuilder.append("</svg>")
return stringBuilder.toString()
}
/**
* Generate TOTP QR code SVG as Base64 string
*
* @param issuer Issuer
* @param username Username
* @param secretKey Secret key
* @return TOTP QR code base64
* @author FatttSnake, fatttsnake@gmail.com
* @since 1.0.0
*/
@OptIn(ExperimentalEncodingApi::class)
fun generateQRCodeSVGBase64(issuer: String, username: String, secretKey: String) =
Base64.encode(generateQRCodeSVG(issuer, username, secretKey).toByteArray(Charsets.UTF_8))
}

View File

@@ -0,0 +1,21 @@
package top.fatweb.oxygen.api.vo.permission
import io.swagger.v3.oas.annotations.media.Schema
/**
* Two-factor value object
*
* @author FatttSnake, fatttsnake@gmail.com
* @since 1.0.0
*/
@Schema(description = "二步验证返回参数")
data class TwoFactorVo (
/**
* QR code SVG as base64
*
* @author FatttSnake, fatttsnake@gmail.com
* @since 1.0.0
*/
@Schema(description = "二维码 SVG Base64")
val qrCodeSVGBase64: String?
)

View File

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

View File

@@ -43,6 +43,15 @@ data class UserWithPasswordRoleInfoVo(
@Schema(description = "密码") @Schema(description = "密码")
val password: String?, val password: String?,
/**
* Two-factor enable
*
* @author FatttSnake, fatttsnake@gmail.com
* @since 1.0.0
*/
@Schema(description = "启用二步验证", example = "true")
val twoFactor: Boolean?,
/** /**
* Verify * Verify
* *

View File

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

View File

@@ -34,6 +34,15 @@ data class UserWithRoleInfoVo(
@Schema(description = "用户名", example = "User") @Schema(description = "用户名", example = "User")
val username: String?, val username: String?,
/**
* Two-factor enable
*
* @author FatttSnake, fatttsnake@gmail.com
* @since 1.0.0
*/
@Schema(description = "启用二步验证", example = "true")
val twoFactor: Boolean?,
/** /**
* Verify * Verify
* *

View File

@@ -0,0 +1,30 @@
package top.fatweb.oxygen.api.vo.system
import io.swagger.v3.oas.annotations.media.Schema
/**
* Two-factor settings value object
*
* @author FatttSnake, fatttsnake@gmail.com
* @since 1.0.0
*/
@Schema(description = "二步验证设置返回参数")
data class TwoFactorSettingsVo(
/**
* Issuer
*
* @author FatttSnake, fatttsnake@gmail.com
* @since 1.0.0
*/
@Schema(description = "发布者")
val issuer: String?,
/**
* Length of secret key
*
* @author FatttSnake, fatttsnake@gmail.com
* @since 1.0.0
*/
@Schema(description = "密钥长度")
val secretKeyLength: Int?
)

View File

@@ -69,9 +69,11 @@ insert into t_s_power (id, type_id)
(1530101, 4), (1530101, 4),
(1530102, 4), (1530102, 4),
(1530103, 4), (1530103, 4),
(1530104, 4),
(1530301, 4), (1530301, 4),
(1530302, 4), (1530302, 4),
(1530303, 4), (1530303, 4),
(1530304, 4),
(1540101, 4), (1540101, 4),
(1540102, 4), (1540102, 4),
(1540103, 4), (1540103, 4),
@@ -167,9 +169,11 @@ insert into t_s_operation(id, name, code, func_id)
(1530101, '基础', 'system:settings:query:base', 1530100), (1530101, '基础', 'system:settings:query:base', 1530100),
(1530102, '邮件', 'system:settings:query:mail', 1530100), (1530102, '邮件', 'system:settings:query:mail', 1530100),
(1530103, '敏感词', 'system:settings:query:sensitive', 1530100), (1530103, '敏感词', 'system:settings:query:sensitive', 1530100),
(1530104, '二步验证', 'system:settings:query:two-factor', 1530100),
(1530301, '基础', 'system:settings:modify:base', 1530300), (1530301, '基础', 'system:settings:modify:base', 1530300),
(1530302, '邮件', 'system:settings:modify:mail', 1530300), (1530302, '邮件', 'system:settings:modify:mail', 1530300),
(1530303, '敏感词', 'system:settings:modify:sensitive', 1530300), (1530303, '敏感词', 'system:settings:modify:sensitive', 1530300),
(1530304, '二步验证', 'system:settings:modify:two-factor', 1530300),
(1540101, '类别', 'system:tool:query:category', 1540100), (1540101, '类别', 'system:tool:query:category', 1540100),
(1540102, '基板', 'system:tool:query:base', 1540100), (1540102, '基板', 'system:tool:query:base', 1540100),
(1540103, '模板', 'system:tool:query:template', 1540100), (1540103, '模板', 'system:tool:query:template', 1540100),

View File

@@ -5,6 +5,7 @@ create table if not exists t_s_user
id bigint not null primary key, id bigint not null primary key,
username varchar(20) not null comment '用户名', username varchar(20) not null comment '用户名',
password char(70) not null comment '密码', password char(70) not null comment '密码',
two_factor varchar(40) null comment '二步验证',
verify varchar(144) null comment '验证邮箱', verify varchar(144) null comment '验证邮箱',
forget varchar(144) null comment '忘记密码', forget varchar(144) null comment '忘记密码',
locking int not null comment '锁定', locking int not null comment '锁定',
@@ -20,6 +21,7 @@ create table if not exists t_s_user
deleted bigint not null default 0, deleted bigint not null default 0,
version int not null default 0, version int not null default 0,
constraint t_s_user_unique_username unique (username, deleted), constraint t_s_user_unique_username unique (username, deleted),
constraint t_s_user_unique_two_factor unique (two_factor, deleted),
constraint t_s_user_unique_verify unique (verify, deleted), constraint t_s_user_unique_verify unique (verify, deleted),
constraint t_s_user_unique_forget unique (forget, deleted) constraint t_s_user_unique_forget unique (forget, deleted)
) comment '系统-用户表'; ) comment '系统-用户表';

View File

@@ -5,6 +5,7 @@
select t_s_user.id as user_id, select t_s_user.id as user_id,
t_s_user.username as user_username, t_s_user.username as user_username,
t_s_user.password as user_password, t_s_user.password as user_password,
t_s_user.two_factor as user_two_factor,
t_s_user.verify as user_verify, t_s_user.verify as user_verify,
t_s_user.forget as user_forget, t_s_user.forget as user_forget,
t_s_user.locking as user_locking, t_s_user.locking as user_locking,
@@ -140,6 +141,7 @@
<select id="selectListWithRoleInfoByIds" resultMap="userWithRoleInfoMap"> <select id="selectListWithRoleInfoByIds" resultMap="userWithRoleInfoMap">
select t_s_user.id as user_id, select t_s_user.id as user_id,
t_s_user.username as user_username, t_s_user.username as user_username,
t_s_user.two_factor as user_two_factor,
t_s_user.verify as user_verify, t_s_user.verify as user_verify,
t_s_user.forget as user_forget, t_s_user.forget as user_forget,
t_s_user.locking as user_locking, t_s_user.locking as user_locking,
@@ -196,6 +198,7 @@
<select id="selectOneWithRoleInfoById" resultMap="userWithRoleInfoMap"> <select id="selectOneWithRoleInfoById" resultMap="userWithRoleInfoMap">
select t_s_user.id as user_id, select t_s_user.id as user_id,
t_s_user.username as user_username, t_s_user.username as user_username,
t_s_user.two_factor as user_two_factor,
t_s_user.verify as user_verify, t_s_user.verify as user_verify,
t_s_user.forget as user_forget, t_s_user.forget as user_forget,
t_s_user.locking as user_locking, t_s_user.locking as user_locking,
@@ -303,6 +306,7 @@
<resultMap id="userBaseMap" type="user"> <resultMap id="userBaseMap" type="user">
<id property="id" column="user_id"/> <id property="id" column="user_id"/>
<result property="username" column="user_username"/> <result property="username" column="user_username"/>
<result property="twoFactor" column="user_two_factor"/>
<result property="verify" column="user_verify"/> <result property="verify" column="user_verify"/>
<result property="forget" column="user_forget"/> <result property="forget" column="user_forget"/>
<result property="locking" column="user_locking"/> <result property="locking" column="user_locking"/>