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