Add two-factor api
This commit is contained in:
@@ -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.RegisterVo
|
||||
import top.fatweb.oxygen.api.vo.permission.TokenVo
|
||||
import top.fatweb.oxygen.api.vo.permission.TwoFactorVo
|
||||
|
||||
/**
|
||||
* Authentication controller
|
||||
@@ -153,6 +154,33 @@ class AuthenticationController(
|
||||
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
|
||||
*
|
||||
|
||||
@@ -3,12 +3,7 @@ package top.fatweb.oxygen.api.controller.system
|
||||
import io.swagger.v3.oas.annotations.Operation
|
||||
import jakarta.validation.Valid
|
||||
import org.springframework.security.access.prepost.PreAuthorize
|
||||
import org.springframework.web.bind.annotation.DeleteMapping
|
||||
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 org.springframework.web.bind.annotation.*
|
||||
import top.fatweb.oxygen.api.annotation.BaseController
|
||||
import top.fatweb.oxygen.api.annotation.Trim
|
||||
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.MailSettingsVo
|
||||
import top.fatweb.oxygen.api.vo.system.SensitiveWordVo
|
||||
import top.fatweb.oxygen.api.vo.system.TwoFactorSettingsVo
|
||||
|
||||
/**
|
||||
* System settings management controller
|
||||
@@ -45,7 +41,8 @@ class SettingsController(
|
||||
@Operation(summary = "获取基础设置")
|
||||
@GetMapping("/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
|
||||
@@ -78,7 +75,8 @@ class SettingsController(
|
||||
@Operation(summary = "获取邮件设置")
|
||||
@GetMapping("/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
|
||||
@@ -186,4 +184,38 @@ class SettingsController(
|
||||
sensitiveWordService.delete(id)
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ object UserConverter {
|
||||
fun userToUserWithPowerInfoVo(user: User) = UserWithPowerInfoVo(
|
||||
id = user.id,
|
||||
username = user.username,
|
||||
twoFactor = !user.twoFactor.isNullOrBlank() && !user.twoFactor!!.endsWith("?"),
|
||||
verified = user.verify.isNullOrBlank(),
|
||||
locking = user.locking?.let { it == 1 },
|
||||
expiration = user.expiration,
|
||||
@@ -65,6 +66,7 @@ object UserConverter {
|
||||
fun userToUserWithRoleInfoVo(user: User) = UserWithRoleInfoVo(
|
||||
id = user.id,
|
||||
username = user.username,
|
||||
twoFactor = !user.twoFactor.isNullOrBlank() && !user.twoFactor!!.endsWith("?"),
|
||||
verify = user.verify,
|
||||
locking = user.locking?.let { it == 1 },
|
||||
expiration = user.expiration,
|
||||
@@ -94,6 +96,7 @@ object UserConverter {
|
||||
fun userToUserWithInfoVo(user: User) = UserWithInfoVo(
|
||||
id = user.id,
|
||||
username = user.username,
|
||||
twoFactor = !user.twoFactor.isNullOrBlank() && !user.twoFactor!!.endsWith("?"),
|
||||
verified = user.verify.isNullOrBlank(),
|
||||
locking = user.locking?.let { it == 1 },
|
||||
expiration = user.expiration,
|
||||
@@ -122,6 +125,7 @@ object UserConverter {
|
||||
id = user.id,
|
||||
username = user.username,
|
||||
password = user.password,
|
||||
twoFactor = !user.twoFactor.isNullOrBlank() && !user.twoFactor!!.endsWith("?"),
|
||||
verify = user.verify,
|
||||
locking = user.locking?.let { it == 1 },
|
||||
expiration = user.expiration,
|
||||
|
||||
@@ -45,6 +45,9 @@ enum class ResponseCode(val code: Int) {
|
||||
PERMISSION_USER_NOT_FOUND(BusinessCode.PERMISSION, 65),
|
||||
PERMISSION_RETRIEVE_CODE_ERROR_OR_EXPIRED(BusinessCode.PERMISSION, 66),
|
||||
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_FAILED(BusinessCode.DATABASE, 5),
|
||||
|
||||
@@ -46,6 +46,15 @@ class User() : Serializable {
|
||||
@TableField("password")
|
||||
var password: String? = null
|
||||
|
||||
/**
|
||||
* Two-factor
|
||||
*
|
||||
* @author FatttSnake, fatttsnake@gmail.com
|
||||
* @since 1.0.0
|
||||
*/
|
||||
@TableField("two_factor")
|
||||
var twoFactor: String? = null
|
||||
|
||||
/**
|
||||
* Verify email
|
||||
*
|
||||
@@ -251,6 +260,6 @@ class User() : Serializable {
|
||||
var operations: List<Operation>? = null
|
||||
|
||||
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)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -165,6 +165,21 @@ class ExceptionHandler {
|
||||
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 */
|
||||
is DatabaseSelectException -> {
|
||||
logger.debug(e.localizedMessage, e)
|
||||
|
||||
@@ -34,5 +34,14 @@ data class LoginParam(
|
||||
*/
|
||||
@Schema(description = "密码", required = true)
|
||||
@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()
|
||||
@@ -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?
|
||||
)
|
||||
@@ -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?
|
||||
)
|
||||
@@ -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.RegisterVo
|
||||
import top.fatweb.oxygen.api.vo.permission.TokenVo
|
||||
import top.fatweb.oxygen.api.vo.permission.TwoFactorVo
|
||||
|
||||
/**
|
||||
* Authentication service interface
|
||||
@@ -82,6 +83,27 @@ interface IAuthenticationService {
|
||||
*/
|
||||
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
|
||||
*
|
||||
|
||||
@@ -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.settings.BaseSettings
|
||||
import top.fatweb.oxygen.api.settings.SettingsOperator
|
||||
import top.fatweb.oxygen.api.util.JwtUtil
|
||||
import top.fatweb.oxygen.api.util.MailUtil
|
||||
import top.fatweb.oxygen.api.util.RedisUtil
|
||||
import top.fatweb.oxygen.api.util.WebUtil
|
||||
import top.fatweb.oxygen.api.settings.TwoFactorSettings
|
||||
import top.fatweb.oxygen.api.util.*
|
||||
import top.fatweb.oxygen.api.vo.permission.LoginVo
|
||||
import top.fatweb.oxygen.api.vo.permission.RegisterVo
|
||||
import top.fatweb.oxygen.api.vo.permission.TokenVo
|
||||
import top.fatweb.oxygen.api.vo.permission.TwoFactorVo
|
||||
import java.io.StringWriter
|
||||
import java.time.Instant
|
||||
import java.time.LocalDateTime
|
||||
@@ -205,7 +204,39 @@ class AuthenticationServiceImpl(
|
||||
override fun login(request: HttpServletRequest, loginParam: LoginParam): LoginVo {
|
||||
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)
|
||||
@@ -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 =
|
||||
UsernamePasswordAuthenticationToken(account, password)
|
||||
val authentication = authenticationManager.authenticate(usernamePasswordAuthenticationToken)
|
||||
|
||||
@@ -4,6 +4,7 @@ import org.springframework.security.core.userdetails.UserDetails
|
||||
import org.springframework.security.core.userdetails.UserDetailsService
|
||||
import org.springframework.stereotype.Service
|
||||
import top.fatweb.oxygen.api.entity.permission.LoginUser
|
||||
import top.fatweb.oxygen.api.exception.UserNotFoundException
|
||||
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 {
|
||||
override fun loadUserByUsername(account: String): UserDetails {
|
||||
val user = userService.getUserWithPowerByAccount(account)
|
||||
user ?: throw Exception("Username not found")
|
||||
user ?: throw UserNotFoundException()
|
||||
|
||||
return LoginUser(user)
|
||||
}
|
||||
|
||||
@@ -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.MailSendParam
|
||||
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.MailSettingsVo
|
||||
import top.fatweb.oxygen.api.vo.system.TwoFactorSettingsVo
|
||||
|
||||
/**
|
||||
* Settings service interface
|
||||
@@ -62,4 +64,24 @@ interface ISettingsService {
|
||||
* @see MailSettingsParam
|
||||
*/
|
||||
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)
|
||||
}
|
||||
@@ -4,15 +4,18 @@ import org.springframework.stereotype.Service
|
||||
import top.fatweb.oxygen.api.param.system.BaseSettingsParam
|
||||
import top.fatweb.oxygen.api.param.system.MailSendParam
|
||||
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.service.system.ISettingsService
|
||||
import top.fatweb.oxygen.api.settings.BaseSettings
|
||||
import top.fatweb.oxygen.api.settings.MailSettings
|
||||
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.StrUtil
|
||||
import top.fatweb.oxygen.api.vo.system.BaseSettingsVo
|
||||
import top.fatweb.oxygen.api.vo.system.MailSettingsVo
|
||||
import top.fatweb.oxygen.api.vo.system.TwoFactorSettingsVo
|
||||
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -101,7 +101,8 @@ object SettingsOperator {
|
||||
* @see KMutableProperty1
|
||||
* @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
|
||||
@@ -154,7 +155,8 @@ object SettingsOperator {
|
||||
* @see KMutableProperty1
|
||||
* @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
|
||||
@@ -169,4 +171,51 @@ object SettingsOperator {
|
||||
*/
|
||||
fun <V> getMailValue(field: KMutableProperty1<MailSettings, V?>, default: V): V =
|
||||
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
|
||||
}
|
||||
@@ -24,5 +24,13 @@ data class SystemSettings(
|
||||
* @author FatttSnake, fatttsnake@gmail.com
|
||||
* @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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
198
src/main/kotlin/top/fatweb/oxygen/api/util/TOTPUtil.kt
Normal file
198
src/main/kotlin/top/fatweb/oxygen/api/util/TOTPUtil.kt
Normal 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))
|
||||
}
|
||||
@@ -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?
|
||||
)
|
||||
@@ -32,6 +32,15 @@ data class UserWithInfoVo(
|
||||
@Schema(description = "用户名", example = "User")
|
||||
val username: String?,
|
||||
|
||||
/**
|
||||
* Two-factor enable
|
||||
*
|
||||
* @author FatttSnake, fatttsnake@gmail.com
|
||||
* @since 1.0.0
|
||||
*/
|
||||
@Schema(description = "启用二步验证", example = "true")
|
||||
val twoFactor: Boolean?,
|
||||
|
||||
/**
|
||||
* Verified
|
||||
*
|
||||
|
||||
@@ -43,6 +43,15 @@ data class UserWithPasswordRoleInfoVo(
|
||||
@Schema(description = "密码")
|
||||
val password: String?,
|
||||
|
||||
/**
|
||||
* Two-factor enable
|
||||
*
|
||||
* @author FatttSnake, fatttsnake@gmail.com
|
||||
* @since 1.0.0
|
||||
*/
|
||||
@Schema(description = "启用二步验证", example = "true")
|
||||
val twoFactor: Boolean?,
|
||||
|
||||
/**
|
||||
* Verify
|
||||
*
|
||||
|
||||
@@ -32,6 +32,15 @@ data class UserWithPowerInfoVo(
|
||||
@Schema(description = "用户名", example = "User")
|
||||
val username: String?,
|
||||
|
||||
/**
|
||||
* Two-factor enable
|
||||
*
|
||||
* @author FatttSnake, fatttsnake@gmail.com
|
||||
* @since 1.0.0
|
||||
*/
|
||||
@Schema(description = "启用二步验证", example = "true")
|
||||
val twoFactor: Boolean?,
|
||||
|
||||
/**
|
||||
* Verified
|
||||
*
|
||||
|
||||
@@ -34,6 +34,15 @@ data class UserWithRoleInfoVo(
|
||||
@Schema(description = "用户名", example = "User")
|
||||
val username: String?,
|
||||
|
||||
/**
|
||||
* Two-factor enable
|
||||
*
|
||||
* @author FatttSnake, fatttsnake@gmail.com
|
||||
* @since 1.0.0
|
||||
*/
|
||||
@Schema(description = "启用二步验证", example = "true")
|
||||
val twoFactor: Boolean?,
|
||||
|
||||
/**
|
||||
* Verify
|
||||
*
|
||||
|
||||
@@ -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?
|
||||
)
|
||||
Reference in New Issue
Block a user