diff --git a/.gitea/workflows/build-test.yaml b/.gitea/workflows/build-test.yaml
new file mode 100644
index 0000000..c496800
--- /dev/null
+++ b/.gitea/workflows/build-test.yaml
@@ -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
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..f12d37e
--- /dev/null
+++ b/Dockerfile
@@ -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
diff --git a/pom.xml b/pom.xml
index 5e73379..3b7b8f4 100644
--- a/pom.xml
+++ b/pom.xml
@@ -148,9 +148,25 @@
thumbnailator
0.4.20
+
+ cn.hutool
+ hutool-core
+ 5.8.31
+
+
+ cn.hutool
+ hutool-extra
+ 5.8.31
+
+
+ com.google.zxing
+ core
+ 3.3.3
+
+ ${project.artifactId}
${project.basedir}/src/main/kotlin
${project.basedir}/src/test/kotlin
diff --git a/src/main/kotlin/info/alinadace/sakuramiki/service/mfa/MFABindService.kt b/src/main/kotlin/info/alinadace/sakuramiki/service/mfa/MFABindService.kt
new file mode 100644
index 0000000..2866590
--- /dev/null
+++ b/src/main/kotlin/info/alinadace/sakuramiki/service/mfa/MFABindService.kt
@@ -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 {
+
+ 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
+ }
+
+ }
+
+ }
+}
diff --git a/src/main/kotlin/info/alinadace/sakuramiki/service/mfa/MFAGenerateService.kt b/src/main/kotlin/info/alinadace/sakuramiki/service/mfa/MFAGenerateService.kt
new file mode 100644
index 0000000..3613220
--- /dev/null
+++ b/src/main/kotlin/info/alinadace/sakuramiki/service/mfa/MFAGenerateService.kt
@@ -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 {
+
+ 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()
+ .eq(MFA::uid, event.friend.id)
+ .eq(MFA::name, "default")
+ )
+ if (mfa == null){
+ event.send("您还没有绑定默认MFA,使用 #mfa bind default 绑定默认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()
+ .eq(MFA::uid, event.friend.id)
+ .eq(MFA::name, split[1])
+ )
+ if (mfa == null){
+ event.send("您还没有绑定${split[1]} MFA,使用 #mfa bind ${split[1]} 绑定${split[1]} MFA")
+ return
+ }
+ event.send(MFAUtil.generate(mfa.secret, 30))
+ return
+ }
+ }
+ }
+ }
+}
diff --git a/src/main/kotlin/info/alinadace/sakuramiki/service/mfa/domain/MFA.kt b/src/main/kotlin/info/alinadace/sakuramiki/service/mfa/domain/MFA.kt
new file mode 100644
index 0000000..d159977
--- /dev/null
+++ b/src/main/kotlin/info/alinadace/sakuramiki/service/mfa/domain/MFA.kt
@@ -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
+}
diff --git a/src/main/kotlin/info/alinadace/sakuramiki/service/mfa/mapper/MFAMapper.kt b/src/main/kotlin/info/alinadace/sakuramiki/service/mfa/mapper/MFAMapper.kt
new file mode 100644
index 0000000..7d718a7
--- /dev/null
+++ b/src/main/kotlin/info/alinadace/sakuramiki/service/mfa/mapper/MFAMapper.kt
@@ -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 {
+}
diff --git a/src/main/kotlin/info/alinadace/sakuramiki/service/randomphoto/RandomPhotoService.kt b/src/main/kotlin/info/alinadace/sakuramiki/service/randomphoto/RandomPhotoService.kt
index 41b94eb..639f86b 100644
--- a/src/main/kotlin/info/alinadace/sakuramiki/service/randomphoto/RandomPhotoService.kt
+++ b/src/main/kotlin/info/alinadace/sakuramiki/service/randomphoto/RandomPhotoService.kt
@@ -36,10 +36,7 @@ class RandomPhotoService : Service {
*/
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"
}
/**
diff --git a/src/main/kotlin/info/alinadace/sakuramiki/util/MFAUtil.kt b/src/main/kotlin/info/alinadace/sakuramiki/util/MFAUtil.kt
new file mode 100644
index 0000000..1987872
--- /dev/null
+++ b/src/main/kotlin/info/alinadace/sakuramiki/util/MFAUtil.kt
@@ -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)
+ }
+ }
+}
diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml
index 1151e59..6bac7fa 100644
--- a/src/main/resources/application-dev.yml
+++ b/src/main/resources/application-dev.yml
@@ -16,5 +16,5 @@ bot:
logging:
level:
- root: debug
- sql: debug
+ root: info
+ sql: info
diff --git a/src/test/kotlin/info/alinadace/sakuramiki/MFATest.kt b/src/test/kotlin/info/alinadace/sakuramiki/MFATest.kt
new file mode 100644
index 0000000..6b8f55e
--- /dev/null
+++ b/src/test/kotlin/info/alinadace/sakuramiki/MFATest.kt
@@ -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)
+ }
+}