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

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.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
*

View File

@@ -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()
}
}

View File

@@ -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,

View File

@@ -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),

View File

@@ -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)"
}
}

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)
}
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)

View File

@@ -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()

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.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
*

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.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)

View File

@@ -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)
}

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.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)
}

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.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)
}
}
}

View File

@@ -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
}

View File

@@ -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
)

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")
val username: String?,
/**
* Two-factor enable
*
* @author FatttSnake, fatttsnake@gmail.com
* @since 1.0.0
*/
@Schema(description = "启用二步验证", example = "true")
val twoFactor: Boolean?,
/**
* Verified
*

View File

@@ -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
*

View File

@@ -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
*

View File

@@ -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
*

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?
)