feat(mfa): 实现 MFA 绑定和生成服务
Some checks failed
Sakura-Miki-build / Explore-Gitea-Actions (push) Failing after 33s
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:
parent
c9209d1b25
commit
9f286f3dc9
17
.gitea/workflows/build-test.yaml
Normal file
17
.gitea/workflows/build-test.yaml
Normal 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
10
Dockerfile
Normal 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
16
pom.xml
@ -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>
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
@ -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> {
|
||||
}
|
@ -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"
|
||||
}
|
||||
|
||||
/**
|
||||
|
51
src/main/kotlin/info/alinadace/sakuramiki/util/MFAUtil.kt
Normal file
51
src/main/kotlin/info/alinadace/sakuramiki/util/MFAUtil.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
@ -16,5 +16,5 @@ bot:
|
||||
|
||||
logging:
|
||||
level:
|
||||
root: debug
|
||||
sql: debug
|
||||
root: info
|
||||
sql: info
|
||||
|
53
src/test/kotlin/info/alinadace/sakuramiki/MFATest.kt
Normal file
53
src/test/kotlin/info/alinadace/sakuramiki/MFATest.kt
Normal 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)
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user