feat(mfa): 实现 MFA 绑定和生成服务
Some checks failed
Sakura-Miki-build / Explore-Gitea-Actions (push) Failing after 33s

- 新增 MFA 相关的实体类、Mapper 和服务类
- 实现 MFA 绑定和生成的逻辑
- 添加 MFA 工具类,用于生成验证码
- 更新 RandomPhotoService 的 entrance 方法
- 修改日志级别为 info
- 添加 Dockerfile 和 Gitea Actions 配置
This commit is contained in:
Grand-cocoa 2025-03-18 17:51:41 +08:00
parent c9209d1b25
commit 9f286f3dc9
11 changed files with 344 additions and 6 deletions

View File

@ -0,0 +1,17 @@
name: Sakura-Miki-build
run-name: ${{ gitea.actor }} is testing out Gitea Actions 🚀
on: [ push ]
env:
BARE_REPO_DIR: https://git.alina-dace.info/Dace/Sakura-Miki.git
CLONED_REPO_DIR: ./
jobs:
Explore-Gitea-Actions:
runs-on: ubuntu-latest
steps:
- name: Checkout Git Repo
uses: actions/checkout@v4
- run: pwd
- run: chmod +x ./mvnw
- name: build
run: ./mvnw clean package -DskipTests=true -P prod
- run: docker build sakura-miki:latest

10
Dockerfile Normal file
View File

@ -0,0 +1,10 @@
FROM bellsoft/liberica-openjdk-debian:17.0.11-cds
LABEL maintainer="Kane / Arina Dace / Sakura Reimi"
ENV LANG C.UTF-8
ENV LC_ALL C.UTF-8
ADD ./target/sakura-miki.jar /app.jar
ENTRYPOINT java -jar /app.jar

16
pom.xml
View File

@ -148,9 +148,25 @@
<artifactId>thumbnailator</artifactId>
<version>0.4.20</version>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-core</artifactId>
<version>5.8.31</version>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-extra</artifactId>
<version>5.8.31</version>
</dependency>
<dependency>
<groupId>com.google.zxing</groupId>
<artifactId>core</artifactId>
<version>3.3.3</version>
</dependency>
</dependencies>
<build>
<finalName>${project.artifactId}</finalName>
<sourceDirectory>${project.basedir}/src/main/kotlin</sourceDirectory>
<testSourceDirectory>${project.basedir}/src/test/kotlin</testSourceDirectory>
<plugins>

View File

@ -0,0 +1,83 @@
package info.alinadace.sakuramiki.service.mfa
import cn.hutool.core.util.URLUtil
import cn.hutool.extra.qrcode.QrCodeUtil
import info.alinadace.sakuramiki.annotation.BotFunction
import info.alinadace.sakuramiki.service.Service
import info.alinadace.sakuramiki.service.mfa.domain.MFA
import info.alinadace.sakuramiki.service.mfa.mapper.MFAMapper
import io.github.kloping.qqbot.api.v2.FriendMessageEvent
import io.github.kloping.qqbot.entities.ex.Image
import io.github.kloping.qqbot.entities.ex.PlainText
import jakarta.annotation.Resource
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import java.io.ByteArrayInputStream
/**
* MFA绑定服务
* @author Kane
* @since 2025/1/14 14:00
*/
@BotFunction(FriendMessageEvent::class)
class MFABindService : Service<FriendMessageEvent> {
companion object {
val log: Logger = LoggerFactory.getLogger(this::class.java)
}
@Resource
private lateinit var mapper: MFAMapper
/**
* 服务入口
*/
override fun entrance(event: FriendMessageEvent): Boolean {
val message = event.message
if (message.size != 1 && message.size != 2) {
return false
}
if (message[0] is PlainText && message[0].toString().startsWith("#mfa")){
if (message.size == 1){
val split = message[0].toString().split(" ")
if (split.size != 4) {
return false
}
if (split[1] != "bind"){
return false
}
return true
}
if (message.size == 2){
return message[1] is Image
}
}
return false
}
/**
* 服务行为
*/
override fun active(event: FriendMessageEvent) {
val message = event.message
if (message[0] is PlainText && message[0].toString().startsWith("#mfa")){
if (message.size == 1){
val split = message[0].toString().split(" ")
val mfa = MFA(event.friend.id, split[2], split[3])
mapper.insert(mfa)
event.send("绑定成功")
return
}
if (message.size == 2){
val bytes = (message[1] as Image).bytes
val inputStream = ByteArrayInputStream(bytes)
val mfaUrl = QrCodeUtil.decode(inputStream)
val url = URLUtil.url(mfaUrl)
url.query
}
}
}
}

View File

@ -0,0 +1,84 @@
package info.alinadace.sakuramiki.service.mfa
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper
import info.alinadace.sakuramiki.annotation.BotFunction
import info.alinadace.sakuramiki.service.Service
import info.alinadace.sakuramiki.service.mfa.domain.MFA
import info.alinadace.sakuramiki.service.mfa.mapper.MFAMapper
import info.alinadace.sakuramiki.util.MFAUtil
import io.github.kloping.qqbot.api.v2.FriendMessageEvent
import io.github.kloping.qqbot.entities.ex.PlainText
import jakarta.annotation.Resource
import org.slf4j.Logger
import org.slf4j.LoggerFactory
/**
* MFA生成服务
* @author Kane
* @since 2025/1/14 14:00
*/
@BotFunction(FriendMessageEvent::class)
class MFAGenerateService : Service<FriendMessageEvent> {
companion object {
val log: Logger = LoggerFactory.getLogger(this::class.java)
}
@Resource
private lateinit var mapper: MFAMapper
/**
* 服务入口
*/
override fun entrance(event: FriendMessageEvent): Boolean {
val message = event.message
if (message.size != 1 && message.size != 2) {
return false
}
if (message[0] is PlainText && message[0].toString().startsWith("mfa")){
if (message.size == 1){
val split = message[0].toString().split(" ")
return split.size <= 2
}
}
return false
}
/**
* 服务行为
*/
override fun active(event: FriendMessageEvent) {
val message = event.message
if (message[0] is PlainText && message[0].toString().startsWith("mfa")){
if (message.size == 1){
val mfa = mapper.selectOne(
LambdaQueryWrapper<MFA>()
.eq(MFA::uid, event.friend.id)
.eq(MFA::name, "default")
)
if (mfa == null){
event.send("您还没有绑定默认MFA使用 #mfa bind default <secret> 绑定默认MFA")
return
}
event.send(MFAUtil.generate(mfa.secret, 30))
return
}
if (message.size == 2){
val split = message[0].toString().split(" ")
if (split.size == 2){
val mfa = mapper.selectOne(
LambdaQueryWrapper<MFA>()
.eq(MFA::uid, event.friend.id)
.eq(MFA::name, split[1])
)
if (mfa == null){
event.send("您还没有绑定${split[1]} MFA使用 #mfa bind ${split[1]} <secret> 绑定${split[1]} MFA")
return
}
event.send(MFAUtil.generate(mfa.secret, 30))
return
}
}
}
}
}

View File

@ -0,0 +1,15 @@
package info.alinadace.sakuramiki.service.mfa.domain
import com.baomidou.mybatisplus.annotation.TableId
import com.baomidou.mybatisplus.annotation.TableName
/**
* MFA
* @author Kane
* @since 2025/1/14 14:28
*/
@TableName("mfa")
class MFA(val uid: String, val name: String, val secret: String) {
@TableId
var id: Long? = null
}

View File

@ -0,0 +1,12 @@
package info.alinadace.sakuramiki.service.mfa.mapper
import com.baomidou.mybatisplus.core.mapper.BaseMapper
import info.alinadace.sakuramiki.service.mfa.domain.MFA
/**
* MFA 数据层
* @author Kane
* @since 2025/1/14 14:31
*/
interface MFAMapper : BaseMapper<MFA> {
}

View File

@ -36,10 +36,7 @@ class RandomPhotoService : Service<MessageV2Event> {
*/
override fun entrance(event: MessageV2Event): Boolean {
val chain = event.message
if (chain.size == 1 && chain[0] is PlainText && chain[0].toString() == "#photo") {
return true
}
return true
return chain.size == 1 && chain[0] is PlainText && chain[0].toString() == "#photo"
}
/**

View File

@ -0,0 +1,51 @@
package info.alinadace.sakuramiki.util
import cn.hutool.core.codec.Base32
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
import kotlin.math.pow
/**
* MFA 工具类
* @author Kane
* @since 2025/3/18 16:01
*/
class MFAUtil {
companion object{
private const val ALGORITHM = "HmacSHA1"
private val mac = Mac.getInstance(ALGORITHM)
/**
* 生成 MFA 验证码
*/
fun generate(key: String, step: Int): String {
val data = ByteArray(8)
var value = System.currentTimeMillis() / 1000 / step
run {
var i = 8
while (i-- > 0) {
data[i] = value.toByte()
value = value ushr 8
}
}
val decodedKey = Base32.decode(key)
val signKey = SecretKeySpec(decodedKey, ALGORITHM)
mac.init(signKey)
val hash = mac.doFinal(data)
println(hash.contentToString())
val offset = hash[hash.size - 1].toInt() and 0xF
var truncatedHash: Long = 0
for (i in 0..3) {
truncatedHash = truncatedHash shl 8
truncatedHash = truncatedHash or (hash[offset + i].toInt() and 0xFF).toLong()
}
truncatedHash = truncatedHash and 0x7FFFFFFFL
truncatedHash %= 10.0.pow(6.0).toLong()
return String.format("%0" + 6 + "d", truncatedHash)
}
}
}

View File

@ -16,5 +16,5 @@ bot:
logging:
level:
root: debug
sql: debug
root: info
sql: info

View File

@ -0,0 +1,53 @@
package info.alinadace.sakuramiki
import cn.hutool.core.codec.Base32
import org.junit.jupiter.api.Test
import java.security.InvalidKeyException
import java.security.NoSuchAlgorithmException
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
import kotlin.math.pow
/**
* @author Kane
* @since 2025/3/18 15:53
*/
class MFATest {
@Test
@Throws(NoSuchAlgorithmException::class, InvalidKeyException::class)
fun test() {
val algorithm = "HmacSHA1"
val key = "JBSWY3DPEHPK3PXP"
val data = ByteArray(8)
var value = System.currentTimeMillis() / 1000 / 30
run {
var i = 8
while (i-- > 0) {
data[i] = value.toByte()
value = value ushr 8
}
}
// Create a HMAC-SHA1 signing key from the shared key
val decodedKey = Base32.decode(key)
val signKey = SecretKeySpec(decodedKey, algorithm)
val mac = Mac.getInstance(algorithm)
mac.init(signKey)
val hash = mac.doFinal(data)
println(hash.contentToString())
val offset = hash[hash.size - 1].toInt() and 0xF
var truncatedHash: Long = 0
for (i in 0..3) {
truncatedHash = truncatedHash shl 8
truncatedHash = truncatedHash or (hash[offset + i].toInt() and 0xFF).toLong()
}
truncatedHash = truncatedHash and 0x7FFFFFFFL
truncatedHash %= 10.0.pow(6.0).toLong()
// Left pad with 0s for a n-digit code
System.out.printf("%0" + 6 + "d%n", truncatedHash)
}
}