diff --git a/pom.xml b/pom.xml index 133a5d1..6ed7540 100644 --- a/pom.xml +++ b/pom.xml @@ -207,6 +207,16 @@ oshi-core 6.4.9 + + commons-codec + commons-codec + 1.16.1 + + + com.google.zxing + core + 3.5.3 + diff --git a/src/main/kotlin/top/fatweb/oxygen/api/controller/permission/AuthenticationController.kt b/src/main/kotlin/top/fatweb/oxygen/api/controller/permission/AuthenticationController.kt index 6958926..b6c9666 100644 --- a/src/main/kotlin/top/fatweb/oxygen/api/controller/permission/AuthenticationController.kt +++ b/src/main/kotlin/top/fatweb/oxygen/api/controller/permission/AuthenticationController.kt @@ -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 = + 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 = + if (authenticationService.validateTwoFactor(twoFactorValidateParam)) ResponseResult.success() + else ResponseResult.fail() + + /** * Logout * diff --git a/src/main/kotlin/top/fatweb/oxygen/api/controller/system/SettingsController.kt b/src/main/kotlin/top/fatweb/oxygen/api/controller/system/SettingsController.kt index 9ca025b..1dea226 100644 --- a/src/main/kotlin/top/fatweb/oxygen/api/controller/system/SettingsController.kt +++ b/src/main/kotlin/top/fatweb/oxygen/api/controller/system/SettingsController.kt @@ -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 = ResponseResult.success(data = settingsService.getBase()) + fun getApp(): ResponseResult = + 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 = ResponseResult.success(data = settingsService.getMail()) + fun getMail(): ResponseResult = + 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 = + 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 { + settingsService.updateTwoFactor(twoFactorSettingsParam) + return ResponseResult.success() + } } \ No newline at end of file diff --git a/src/main/kotlin/top/fatweb/oxygen/api/converter/permission/UserConverter.kt b/src/main/kotlin/top/fatweb/oxygen/api/converter/permission/UserConverter.kt index 9f299b7..dec373e 100644 --- a/src/main/kotlin/top/fatweb/oxygen/api/converter/permission/UserConverter.kt +++ b/src/main/kotlin/top/fatweb/oxygen/api/converter/permission/UserConverter.kt @@ -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, diff --git a/src/main/kotlin/top/fatweb/oxygen/api/entity/common/ResponseCode.kt b/src/main/kotlin/top/fatweb/oxygen/api/entity/common/ResponseCode.kt index 83a3dfd..60a5300 100644 --- a/src/main/kotlin/top/fatweb/oxygen/api/entity/common/ResponseCode.kt +++ b/src/main/kotlin/top/fatweb/oxygen/api/entity/common/ResponseCode.kt @@ -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), diff --git a/src/main/kotlin/top/fatweb/oxygen/api/entity/permission/User.kt b/src/main/kotlin/top/fatweb/oxygen/api/entity/permission/User.kt index 11324c6..93512fb 100644 --- a/src/main/kotlin/top/fatweb/oxygen/api/entity/permission/User.kt +++ b/src/main/kotlin/top/fatweb/oxygen/api/entity/permission/User.kt @@ -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? = 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)" } } diff --git a/src/main/kotlin/top/fatweb/oxygen/api/exception/AlreadyHasTwoFactorException.kt b/src/main/kotlin/top/fatweb/oxygen/api/exception/AlreadyHasTwoFactorException.kt new file mode 100644 index 0000000..f9339cc --- /dev/null +++ b/src/main/kotlin/top/fatweb/oxygen/api/exception/AlreadyHasTwoFactorException.kt @@ -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") \ No newline at end of file diff --git a/src/main/kotlin/top/fatweb/oxygen/api/exception/NeedTwoFactorException.kt b/src/main/kotlin/top/fatweb/oxygen/api/exception/NeedTwoFactorException.kt new file mode 100644 index 0000000..fb51ca4 --- /dev/null +++ b/src/main/kotlin/top/fatweb/oxygen/api/exception/NeedTwoFactorException.kt @@ -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") \ No newline at end of file diff --git a/src/main/kotlin/top/fatweb/oxygen/api/exception/NoTwoFactorFoundException.kt b/src/main/kotlin/top/fatweb/oxygen/api/exception/NoTwoFactorFoundException.kt new file mode 100644 index 0000000..c739da1 --- /dev/null +++ b/src/main/kotlin/top/fatweb/oxygen/api/exception/NoTwoFactorFoundException.kt @@ -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") \ No newline at end of file diff --git a/src/main/kotlin/top/fatweb/oxygen/api/handler/ExceptionHandler.kt b/src/main/kotlin/top/fatweb/oxygen/api/handler/ExceptionHandler.kt index 8624e10..b8baab0 100644 --- a/src/main/kotlin/top/fatweb/oxygen/api/handler/ExceptionHandler.kt +++ b/src/main/kotlin/top/fatweb/oxygen/api/handler/ExceptionHandler.kt @@ -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) diff --git a/src/main/kotlin/top/fatweb/oxygen/api/param/permission/LoginParam.kt b/src/main/kotlin/top/fatweb/oxygen/api/param/permission/LoginParam.kt index 66bc3e9..efa013f 100644 --- a/src/main/kotlin/top/fatweb/oxygen/api/param/permission/LoginParam.kt +++ b/src/main/kotlin/top/fatweb/oxygen/api/param/permission/LoginParam.kt @@ -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() \ No newline at end of file diff --git a/src/main/kotlin/top/fatweb/oxygen/api/param/permission/TwoFactorValidateParam.kt b/src/main/kotlin/top/fatweb/oxygen/api/param/permission/TwoFactorValidateParam.kt new file mode 100644 index 0000000..a75af7a --- /dev/null +++ b/src/main/kotlin/top/fatweb/oxygen/api/param/permission/TwoFactorValidateParam.kt @@ -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? +) diff --git a/src/main/kotlin/top/fatweb/oxygen/api/param/system/TwoFactorSettingsParam.kt b/src/main/kotlin/top/fatweb/oxygen/api/param/system/TwoFactorSettingsParam.kt new file mode 100644 index 0000000..cbf3601 --- /dev/null +++ b/src/main/kotlin/top/fatweb/oxygen/api/param/system/TwoFactorSettingsParam.kt @@ -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? +) \ No newline at end of file diff --git a/src/main/kotlin/top/fatweb/oxygen/api/service/permission/IAuthenticationService.kt b/src/main/kotlin/top/fatweb/oxygen/api/service/permission/IAuthenticationService.kt index a242964..b9d6eb4 100644 --- a/src/main/kotlin/top/fatweb/oxygen/api/service/permission/IAuthenticationService.kt +++ b/src/main/kotlin/top/fatweb/oxygen/api/service/permission/IAuthenticationService.kt @@ -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 * diff --git a/src/main/kotlin/top/fatweb/oxygen/api/service/permission/impl/AuthenticationServiceImpl.kt b/src/main/kotlin/top/fatweb/oxygen/api/service/permission/impl/AuthenticationServiceImpl.kt index bd0e265..5c782d2 100644 --- a/src/main/kotlin/top/fatweb/oxygen/api/service/permission/impl/AuthenticationServiceImpl.kt +++ b/src/main/kotlin/top/fatweb/oxygen/api/service/permission/impl/AuthenticationServiceImpl.kt @@ -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) diff --git a/src/main/kotlin/top/fatweb/oxygen/api/service/permission/impl/UserDetailsServiceImpl.kt b/src/main/kotlin/top/fatweb/oxygen/api/service/permission/impl/UserDetailsServiceImpl.kt index 5b3bad7..95a3498 100644 --- a/src/main/kotlin/top/fatweb/oxygen/api/service/permission/impl/UserDetailsServiceImpl.kt +++ b/src/main/kotlin/top/fatweb/oxygen/api/service/permission/impl/UserDetailsServiceImpl.kt @@ -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) } diff --git a/src/main/kotlin/top/fatweb/oxygen/api/service/system/ISettingsService.kt b/src/main/kotlin/top/fatweb/oxygen/api/service/system/ISettingsService.kt index 0e7eee6..00e8e28 100644 --- a/src/main/kotlin/top/fatweb/oxygen/api/service/system/ISettingsService.kt +++ b/src/main/kotlin/top/fatweb/oxygen/api/service/system/ISettingsService.kt @@ -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) } \ No newline at end of file diff --git a/src/main/kotlin/top/fatweb/oxygen/api/service/system/impl/SettingsServiceImpl.kt b/src/main/kotlin/top/fatweb/oxygen/api/service/system/impl/SettingsServiceImpl.kt index aa3a7e5..556cc2a 100644 --- a/src/main/kotlin/top/fatweb/oxygen/api/service/system/impl/SettingsServiceImpl.kt +++ b/src/main/kotlin/top/fatweb/oxygen/api/service/system/impl/SettingsServiceImpl.kt @@ -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) + } + } } \ No newline at end of file diff --git a/src/main/kotlin/top/fatweb/oxygen/api/settings/SettingsOperator.kt b/src/main/kotlin/top/fatweb/oxygen/api/settings/SettingsOperator.kt index 7388b72..7f71dea 100644 --- a/src/main/kotlin/top/fatweb/oxygen/api/settings/SettingsOperator.kt +++ b/src/main/kotlin/top/fatweb/oxygen/api/settings/SettingsOperator.kt @@ -101,7 +101,8 @@ object SettingsOperator { * @see KMutableProperty1 * @see BaseSettings */ - fun getAppValue(field: KMutableProperty1): V? = this.getAppValue(field, null) + fun getAppValue(field: KMutableProperty1): V? = + this.getAppValue(field, null) /** * Get base settings value with default value @@ -154,7 +155,8 @@ object SettingsOperator { * @see KMutableProperty1 * @see MailSettings */ - fun getMailValue(field: KMutableProperty1): V? = this.getMailValue(field, null) + fun getMailValue(field: KMutableProperty1): V? = + this.getMailValue(field, null) /** * Get value from mail settings with default value @@ -169,4 +171,51 @@ object SettingsOperator { */ fun getMailValue(field: KMutableProperty1, 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 setTwoFactorValue(field: KMutableProperty1, 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 getTwoFactorValue(field: KMutableProperty1): 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 getTwoFactorValue(field: KMutableProperty1, default: V): V = + systemSettings.twoFactor?.let(field) ?: default } \ No newline at end of file diff --git a/src/main/kotlin/top/fatweb/oxygen/api/settings/SystemSettings.kt b/src/main/kotlin/top/fatweb/oxygen/api/settings/SystemSettings.kt index b15a9f7..ee2b038 100644 --- a/src/main/kotlin/top/fatweb/oxygen/api/settings/SystemSettings.kt +++ b/src/main/kotlin/top/fatweb/oxygen/api/settings/SystemSettings.kt @@ -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 ) diff --git a/src/main/kotlin/top/fatweb/oxygen/api/settings/TwoFactorSettings.kt b/src/main/kotlin/top/fatweb/oxygen/api/settings/TwoFactorSettings.kt new file mode 100644 index 0000000..7eac4a5 --- /dev/null +++ b/src/main/kotlin/top/fatweb/oxygen/api/settings/TwoFactorSettings.kt @@ -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 +) diff --git a/src/main/kotlin/top/fatweb/oxygen/api/util/TOTPUtil.kt b/src/main/kotlin/top/fatweb/oxygen/api/util/TOTPUtil.kt new file mode 100644 index 0000000..4b73cc2 --- /dev/null +++ b/src/main/kotlin/top/fatweb/oxygen/api/util/TOTPUtil.kt @@ -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() + 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("") + + for (y in 0 until qrCode.height) { + for (x in 0 until qrCode.width) { + if (qrCode.get(x, y)) { + stringBuilder.appendLine(" ") + } + } + } + stringBuilder.append("") + + 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)) +} \ No newline at end of file diff --git a/src/main/kotlin/top/fatweb/oxygen/api/vo/permission/TwoFactorVo.kt b/src/main/kotlin/top/fatweb/oxygen/api/vo/permission/TwoFactorVo.kt new file mode 100644 index 0000000..5ce3040 --- /dev/null +++ b/src/main/kotlin/top/fatweb/oxygen/api/vo/permission/TwoFactorVo.kt @@ -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? +) \ No newline at end of file diff --git a/src/main/kotlin/top/fatweb/oxygen/api/vo/permission/UserWithInfoVo.kt b/src/main/kotlin/top/fatweb/oxygen/api/vo/permission/UserWithInfoVo.kt index f1f14f7..a7c53ad 100644 --- a/src/main/kotlin/top/fatweb/oxygen/api/vo/permission/UserWithInfoVo.kt +++ b/src/main/kotlin/top/fatweb/oxygen/api/vo/permission/UserWithInfoVo.kt @@ -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 * diff --git a/src/main/kotlin/top/fatweb/oxygen/api/vo/permission/UserWithPasswordRoleInfoVo.kt b/src/main/kotlin/top/fatweb/oxygen/api/vo/permission/UserWithPasswordRoleInfoVo.kt index 945bbdd..96992de 100644 --- a/src/main/kotlin/top/fatweb/oxygen/api/vo/permission/UserWithPasswordRoleInfoVo.kt +++ b/src/main/kotlin/top/fatweb/oxygen/api/vo/permission/UserWithPasswordRoleInfoVo.kt @@ -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 * diff --git a/src/main/kotlin/top/fatweb/oxygen/api/vo/permission/UserWithPowerInfoVo.kt b/src/main/kotlin/top/fatweb/oxygen/api/vo/permission/UserWithPowerInfoVo.kt index 4616b50..d9f4e87 100644 --- a/src/main/kotlin/top/fatweb/oxygen/api/vo/permission/UserWithPowerInfoVo.kt +++ b/src/main/kotlin/top/fatweb/oxygen/api/vo/permission/UserWithPowerInfoVo.kt @@ -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 * diff --git a/src/main/kotlin/top/fatweb/oxygen/api/vo/permission/UserWithRoleInfoVo.kt b/src/main/kotlin/top/fatweb/oxygen/api/vo/permission/UserWithRoleInfoVo.kt index edc69db..0101b13 100644 --- a/src/main/kotlin/top/fatweb/oxygen/api/vo/permission/UserWithRoleInfoVo.kt +++ b/src/main/kotlin/top/fatweb/oxygen/api/vo/permission/UserWithRoleInfoVo.kt @@ -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 * diff --git a/src/main/kotlin/top/fatweb/oxygen/api/vo/system/TwoFactorSettingsVo.kt b/src/main/kotlin/top/fatweb/oxygen/api/vo/system/TwoFactorSettingsVo.kt new file mode 100644 index 0000000..80ee980 --- /dev/null +++ b/src/main/kotlin/top/fatweb/oxygen/api/vo/system/TwoFactorSettingsVo.kt @@ -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? +) diff --git a/src/main/resources/db/migration/master/R__Basic_data.sql b/src/main/resources/db/migration/master/R__Basic_data.sql index e8bb177..bbf1dd0 100644 --- a/src/main/resources/db/migration/master/R__Basic_data.sql +++ b/src/main/resources/db/migration/master/R__Basic_data.sql @@ -69,9 +69,11 @@ insert into t_s_power (id, type_id) (1530101, 4), (1530102, 4), (1530103, 4), + (1530104, 4), (1530301, 4), (1530302, 4), (1530303, 4), + (1530304, 4), (1540101, 4), (1540102, 4), (1540103, 4), @@ -167,9 +169,11 @@ insert into t_s_operation(id, name, code, func_id) (1530101, '基础', 'system:settings:query:base', 1530100), (1530102, '邮件', 'system:settings:query:mail', 1530100), (1530103, '敏感词', 'system:settings:query:sensitive', 1530100), + (1530104, '二步验证', 'system:settings:query:two-factor', 1530100), (1530301, '基础', 'system:settings:modify:base', 1530300), (1530302, '邮件', 'system:settings:modify:mail', 1530300), (1530303, '敏感词', 'system:settings:modify:sensitive', 1530300), + (1530304, '二步验证', 'system:settings:modify:two-factor', 1530300), (1540101, '类别', 'system:tool:query:category', 1540100), (1540102, '基板', 'system:tool:query:base', 1540100), (1540103, '模板', 'system:tool:query:template', 1540100), diff --git a/src/main/resources/db/migration/master/V1_0_0_231019__Add_table_'t_s_user'.sql b/src/main/resources/db/migration/master/V1_0_0_231019__Add_table_'t_s_user'.sql index 9e2287d..b5b49c4 100644 --- a/src/main/resources/db/migration/master/V1_0_0_231019__Add_table_'t_s_user'.sql +++ b/src/main/resources/db/migration/master/V1_0_0_231019__Add_table_'t_s_user'.sql @@ -5,6 +5,7 @@ create table if not exists t_s_user id bigint not null primary key, username varchar(20) not null comment '用户名', password char(70) not null comment '密码', + two_factor varchar(40) null comment '二步验证', verify varchar(144) null comment '验证邮箱', forget varchar(144) 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, version int not null default 0, 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_forget unique (forget, deleted) ) comment '系统-用户表'; \ No newline at end of file diff --git a/src/main/resources/mapper/permission/UserMapper.xml b/src/main/resources/mapper/permission/UserMapper.xml index 40cb1fb..ecbc757 100644 --- a/src/main/resources/mapper/permission/UserMapper.xml +++ b/src/main/resources/mapper/permission/UserMapper.xml @@ -5,6 +5,7 @@ select t_s_user.id as user_id, t_s_user.username as user_username, 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.forget as user_forget, t_s_user.locking as user_locking, @@ -61,11 +62,11 @@ select t_s_user.id as user_id, 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.forget as user_forget, t_s_user.locking as user_locking, @@ -196,6 +198,7 @@