├── src ├── test │ ├── readMe.md │ └── java │ │ └── ociserver │ │ └── MfaStartApplicationTests.java └── main │ ├── java │ └── com │ │ └── doubledimple │ │ └── mfa │ │ ├── entity │ │ ├── readMe.md │ │ ├── AListParam.java │ │ ├── SyncHistory.java │ │ ├── SyncSettings.java │ │ └── OTPKey.java │ │ ├── service │ │ ├── impl │ │ │ ├── readMe.md │ │ │ └── SyncServiceImpl.java │ │ ├── SyncService.java │ │ ├── QRCodeService.java │ │ ├── OTPService.java │ │ └── GoogleAuthenticatorDef.java │ │ ├── response │ │ ├── AListTokenResponse.java │ │ ├── OtpResponse.java │ │ ├── AListResponse.java │ │ └── OtpResponse2.java │ │ ├── request │ │ └── OtpBatchRequest.java │ │ ├── constant │ │ └── Constant.java │ │ ├── controller │ │ ├── LoginController.java │ │ ├── SettingsController.java │ │ └── OTPController.java │ │ ├── repository │ │ ├── SyncSettingsRepository.java │ │ ├── OTPKeyRepository.java │ │ └── SyncHistoryRepository.java │ │ ├── MfaStartApplication.java │ │ ├── config │ │ ├── JpaConfig.java │ │ ├── SyncConfig.java │ │ └── SecurityConfig.java │ │ ├── task │ │ └── SyncTaskService.java │ │ └── utils │ │ ├── DesktopUtils.java │ │ ├── TOTPUtils.java │ │ └── GoogleAuthMigrationParser.java │ ├── resources │ ├── application-release.yml │ ├── application.yml │ ├── logback-spring.xml │ ├── mfa-start.sh │ ├── templates │ │ ├── common │ │ │ └── sidebar.ftl │ │ ├── login.ftl │ │ ├── settings.ftl │ │ └── index.ftl │ └── script │ │ └── mfa-start.sh │ └── proto │ └── migration.proto ├── service.yaml ├── Dockerfile ├── .github └── workflows │ └── ant.yml ├── deploy.yaml ├── README.md └── pom.xml /src/test/readMe.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/main/java/com/doubledimple/mfa/entity/readMe.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/main/java/com/doubledimple/mfa/service/impl/readMe.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/main/java/com/doubledimple/mfa/response/AListTokenResponse.java: -------------------------------------------------------------------------------- 1 | package com.doubledimple.mfa.response; 2 | 3 | import lombok.Data; 4 | 5 | /** 6 | * @author doubleDimple 7 | * @date 2024:11:03日 09:41 8 | */ 9 | @Data 10 | public class AListTokenResponse { 11 | 12 | private String token; 13 | } 14 | -------------------------------------------------------------------------------- /src/main/java/com/doubledimple/mfa/response/OtpResponse.java: -------------------------------------------------------------------------------- 1 | package com.doubledimple.mfa.response; 2 | 3 | import lombok.Data; 4 | 5 | /** 6 | * @author doubleDimple 7 | * @date 2024:11:01日 23:21 8 | */ 9 | @Data 10 | public class OtpResponse { 11 | 12 | private String otpCode; 13 | 14 | private String secretKey; 15 | } 16 | -------------------------------------------------------------------------------- /src/main/java/com/doubledimple/mfa/request/OtpBatchRequest.java: -------------------------------------------------------------------------------- 1 | package com.doubledimple.mfa.request; 2 | 3 | import lombok.Data; 4 | 5 | import java.util.List; 6 | 7 | /** 8 | * @author doubleDimple 9 | * @date 2024:11:01日 23:18 10 | */ 11 | @Data 12 | public class OtpBatchRequest { 13 | 14 | private List secretKeys; 15 | } 16 | -------------------------------------------------------------------------------- /src/main/java/com/doubledimple/mfa/response/AListResponse.java: -------------------------------------------------------------------------------- 1 | package com.doubledimple.mfa.response; 2 | 3 | import lombok.Data; 4 | 5 | /** 6 | * @author doubleDimple 7 | * @date 2024:11:03日 09:30 8 | */ 9 | @Data 10 | public class AListResponse { 11 | 12 | private String code; 13 | private String message; 14 | private T data; 15 | } 16 | -------------------------------------------------------------------------------- /src/main/java/com/doubledimple/mfa/entity/AListParam.java: -------------------------------------------------------------------------------- 1 | package com.doubledimple.mfa.entity; 2 | 3 | import lombok.Builder; 4 | import lombok.Data; 5 | 6 | /** 7 | * @author doubleDimple 8 | * @date 2024:11:03日 09:27 9 | */ 10 | @Data 11 | @Builder 12 | public class AListParam { 13 | 14 | private String userName; 15 | private String password; 16 | } 17 | -------------------------------------------------------------------------------- /service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: mfa-start 5 | namespace: default # 写上更明确一点,跟你现在用的命名空间一致 6 | spec: 7 | type: NodePort 8 | selector: 9 | app: mfa-start 10 | ports: 11 | - name: http 12 | port: 9999 # cluster 内部访问用的端口 13 | targetPort: 9999 # Pod里容器实际上监听的端口 14 | nodePort: 30099 # 外面访问用的端口 15 | -------------------------------------------------------------------------------- /src/main/java/com/doubledimple/mfa/constant/Constant.java: -------------------------------------------------------------------------------- 1 | package com.doubledimple.mfa.constant; 2 | 3 | /** 4 | * @author doubleDimple 5 | * @date 2024:11:03日 09:22 6 | */ 7 | public class Constant { 8 | 9 | public static final String A_LIST_AUTH_LOGIN_URL_SUFFIX = "/api/auth/login"; 10 | 11 | public static final String A_LIST_PUT_URL_SUFFIX = "/api/fs/put"; 12 | } 13 | -------------------------------------------------------------------------------- /src/main/java/com/doubledimple/mfa/response/OtpResponse2.java: -------------------------------------------------------------------------------- 1 | package com.doubledimple.mfa.response; 2 | 3 | import lombok.Data; 4 | 5 | /** 6 | * @author doubleDimple 7 | * @date 2024:11:01日 23:28 8 | */ 9 | @Data 10 | public class OtpResponse2 { 11 | 12 | private String otpCode; 13 | 14 | public OtpResponse2(String otpCpde){ 15 | this.otpCode = otpCpde; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # 使用官方的 OpenJDK 运行时镜像作为基础镜像 2 | FROM eclipse-temurin:8-jdk-jammy 3 | 4 | # 设置环境变量 5 | ENV APP_HOME=/mfa-start 6 | 7 | # 在容器中创建一个目录来存放应用 8 | WORKDIR $APP_HOME 9 | 10 | # 复制 jar 包到容器并命名为 mfa-start.jar 11 | COPY target/mfa-start-release.jar mfa-start.jar 12 | 13 | # 暴露应用运行端口 14 | EXPOSE 9999 15 | 16 | # 启动 Spring Boot 应用 17 | ENTRYPOINT ["java", "-Xms256m", "-Xmx512m", "-jar", "mfa-start.jar"] 18 | -------------------------------------------------------------------------------- /src/main/java/com/doubledimple/mfa/controller/LoginController.java: -------------------------------------------------------------------------------- 1 | package com.doubledimple.mfa.controller; 2 | 3 | import org.springframework.stereotype.Controller; 4 | import org.springframework.web.bind.annotation.GetMapping; 5 | 6 | /** 7 | * @author doubleDimple 8 | * @date 2024:10:06日 00:03 9 | */ 10 | @Controller 11 | public class LoginController { 12 | 13 | @GetMapping("/login") 14 | public String login() { 15 | return "login"; // 指定视图名为 `login.ftl` 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/main/resources/application-release.yml: -------------------------------------------------------------------------------- 1 | server: 2 | port: 9999 3 | 4 | spring: 5 | security: 6 | user: 7 | name: admin 8 | password: admin1234 9 | datasource: 10 | username: sa 11 | password: password 12 | url: jdbc:h2:file:./data/otp_keys_db 13 | 14 | 15 | 16 | #下载链接 17 | #jar包 wget https://github.com/doubleDimple/mfa-start/releases/latest/download/mfa-start-release.jar 18 | #脚本 wget https://github.com/doubleDimple/mfa-start/releases/latest/download/mfa-start.sh 19 | #配置文件 wget https://github.com/doubleDimple/mfa-start/releases/latest/download/mfa-start.yml -------------------------------------------------------------------------------- /src/main/java/com/doubledimple/mfa/entity/SyncHistory.java: -------------------------------------------------------------------------------- 1 | package com.doubledimple.mfa.entity; 2 | 3 | import lombok.Data; 4 | 5 | import javax.persistence.*; 6 | import java.time.LocalDateTime; 7 | 8 | /** 9 | * @author doubleDimple 10 | * @date 2024:11:02日 12:39 11 | */ 12 | @Entity 13 | @Table(name = "sync_history") 14 | @Data 15 | public class SyncHistory { 16 | @Id 17 | @GeneratedValue(strategy = GenerationType.IDENTITY) 18 | private Long id; 19 | 20 | @Column(nullable = false) 21 | private LocalDateTime time; 22 | 23 | private boolean success; 24 | private String details; 25 | private Long size; // 文件大小(字节) 26 | } 27 | -------------------------------------------------------------------------------- /src/main/java/com/doubledimple/mfa/repository/SyncSettingsRepository.java: -------------------------------------------------------------------------------- 1 | package com.doubledimple.mfa.repository; 2 | 3 | import com.doubledimple.mfa.entity.OTPKey; 4 | import com.doubledimple.mfa.entity.SyncSettings; 5 | import org.springframework.data.jpa.repository.JpaRepository; 6 | import org.springframework.data.jpa.repository.JpaSpecificationExecutor; 7 | import org.springframework.stereotype.Repository; 8 | 9 | import java.util.Optional; 10 | 11 | @Repository 12 | public interface SyncSettingsRepository extends JpaRepository , JpaSpecificationExecutor { 13 | // 获取第一条配置 14 | Optional findFirstByOrderById(); 15 | } 16 | -------------------------------------------------------------------------------- /.github/workflows/ant.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a Java project with Ant 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-java-with-ant 3 | 4 | name: Java CI 5 | 6 | on: 7 | push: 8 | branches: [ "master" ] 9 | pull_request: 10 | branches: [ "master" ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | - name: Set up JDK 11 20 | uses: actions/setup-java@v4 21 | with: 22 | java-version: '11' 23 | distribution: 'temurin' 24 | - name: Build with Ant 25 | run: ant -noinput -buildfile build.xml 26 | -------------------------------------------------------------------------------- /src/main/java/com/doubledimple/mfa/repository/OTPKeyRepository.java: -------------------------------------------------------------------------------- 1 | package com.doubledimple.mfa.repository; 2 | 3 | import com.doubledimple.mfa.entity.OTPKey; 4 | import org.springframework.data.jpa.repository.JpaRepository; 5 | import org.springframework.data.jpa.repository.JpaSpecificationExecutor; 6 | import org.springframework.stereotype.Repository; 7 | 8 | @Repository 9 | public interface OTPKeyRepository extends JpaRepository, JpaSpecificationExecutor { 10 | 11 | // 根据 name 删除一条记录 12 | void deleteByKeyName(String keyName); 13 | 14 | OTPKey queryOTPKeyByKeyName(String keyName); 15 | 16 | OTPKey findByKeyName(String keyName); 17 | 18 | OTPKey findBySecretKey(String secretKey); 19 | } 20 | -------------------------------------------------------------------------------- /src/test/java/ociserver/MfaStartApplicationTests.java: -------------------------------------------------------------------------------- 1 | package ociserver; 2 | 3 | import com.doubledimple.mfa.MfaStartApplication; 4 | import com.doubledimple.mfa.task.SyncTaskService; 5 | import lombok.extern.slf4j.Slf4j; 6 | import org.junit.jupiter.api.Test; 7 | import org.springframework.boot.test.context.SpringBootTest; 8 | import org.springframework.transaction.annotation.Transactional; 9 | 10 | import javax.annotation.Resource; 11 | 12 | @SpringBootTest(classes = {MfaStartApplication.class}) 13 | @Slf4j 14 | @Transactional 15 | class MfaStartApplicationTests { 16 | 17 | 18 | @Resource 19 | SyncTaskService syncTaskService; 20 | 21 | 22 | @Test 23 | public void testTask(){ 24 | syncTaskService.checkAndSync(); 25 | } 26 | 27 | } 28 | -------------------------------------------------------------------------------- /src/main/java/com/doubledimple/mfa/MfaStartApplication.java: -------------------------------------------------------------------------------- 1 | package com.doubledimple.mfa; 2 | 3 | import lombok.extern.slf4j.Slf4j; 4 | import org.springframework.boot.SpringApplication; 5 | import org.springframework.boot.autoconfigure.SpringBootApplication; 6 | import org.springframework.data.jpa.repository.config.EnableJpaRepositories; 7 | 8 | /** 9 | * @author doubleDimple 10 | * @date 2024:10:05日 00:52 11 | */ 12 | @SpringBootApplication 13 | //@EnableJpaRepositories(basePackages = {"com.doubledimple.mfa.service.*"}) 14 | @Slf4j 15 | public class MfaStartApplication { 16 | 17 | public static void main(String[] args) { 18 | SpringApplication.run(MfaStartApplication.class, args); 19 | 20 | log.info("MFA-START START SUCCESS........"); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/main/java/com/doubledimple/mfa/repository/SyncHistoryRepository.java: -------------------------------------------------------------------------------- 1 | package com.doubledimple.mfa.repository; 2 | 3 | import com.doubledimple.mfa.entity.SyncHistory; 4 | import com.doubledimple.mfa.entity.SyncSettings; 5 | import org.springframework.data.domain.PageRequest; 6 | import org.springframework.data.jpa.repository.JpaRepository; 7 | import org.springframework.data.jpa.repository.JpaSpecificationExecutor; 8 | import org.springframework.stereotype.Repository; 9 | 10 | import java.awt.print.Pageable; 11 | import java.util.List; 12 | 13 | @Repository 14 | public interface SyncHistoryRepository extends JpaRepository, JpaSpecificationExecutor { 15 | // 按时间倒序获取历史记录 16 | List findAllByOrderByTimeDesc(PageRequest time); 17 | } 18 | -------------------------------------------------------------------------------- /src/main/java/com/doubledimple/mfa/service/SyncService.java: -------------------------------------------------------------------------------- 1 | package com.doubledimple.mfa.service; 2 | 3 | import com.doubledimple.mfa.entity.SyncHistory; 4 | import com.doubledimple.mfa.entity.SyncSettings; 5 | 6 | import java.time.LocalDateTime; 7 | import java.util.List; 8 | import java.util.concurrent.CompletableFuture; 9 | 10 | public interface SyncService { 11 | 12 | void saveSettings(SyncSettings settings); 13 | 14 | public SyncSettings getSettings(); 15 | 16 | public List getHistory(); 17 | 18 | public boolean testConnection(String url,String username, String password); 19 | 20 | public CompletableFuture syncNow(); 21 | 22 | public LocalDateTime getNextSyncTime(); 23 | 24 | public void syncNowTask(); 25 | 26 | public String getAListToken(SyncSettings settings); 27 | } 28 | -------------------------------------------------------------------------------- /src/main/proto/migration.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | option java_package = "com.google.authenticator.proto"; 4 | option java_outer_classname = "MigrationProtos"; 5 | 6 | message MigrationPayload { 7 | int32 version = 1; 8 | repeated OtpParameters otp_parameters = 2; 9 | int32 batch_size = 3; 10 | int32 batch_index = 4; 11 | int32 batch_id = 5; 12 | } 13 | 14 | message OtpParameters { 15 | bytes secret = 1; 16 | bytes name = 2; 17 | bytes issuer = 3; 18 | Algorithm algorithm = 4; 19 | int32 digits = 5; 20 | OtpType type = 6; 21 | int64 counter = 7; 22 | 23 | enum Algorithm { 24 | ALGORITHM_UNSPECIFIED = 0; 25 | SHA1 = 1; 26 | SHA256 = 2; 27 | SHA512 = 3; 28 | MD5 = 4; 29 | } 30 | 31 | enum OtpType { 32 | OTP_TYPE_UNSPECIFIED = 0; 33 | HOTP = 1; 34 | TOTP = 2; 35 | } 36 | } -------------------------------------------------------------------------------- /src/main/java/com/doubledimple/mfa/entity/SyncSettings.java: -------------------------------------------------------------------------------- 1 | package com.doubledimple.mfa.entity; 2 | 3 | import lombok.Data; 4 | 5 | import javax.persistence.*; 6 | import java.time.LocalDateTime; 7 | 8 | /** 9 | * @author doubleDimple 10 | * @date 2024:11:02日 12:38 11 | */ 12 | @Entity 13 | @Table(name = "sync_settings") 14 | @Data 15 | public class SyncSettings { 16 | @Id 17 | @GeneratedValue(strategy = GenerationType.IDENTITY) 18 | private Long id; 19 | 20 | private boolean enabled; 21 | private String aListUrl; 22 | private String aListToken; 23 | private String backupPath; 24 | private String password; 25 | private String userName; 26 | // 同步周期(天数) 27 | private Integer syncInterval; 28 | 29 | // 上次同步时间 30 | @Column(name = "last_sync_time") 31 | private LocalDateTime lastSyncTime; 32 | } 33 | -------------------------------------------------------------------------------- /src/main/java/com/doubledimple/mfa/service/QRCodeService.java: -------------------------------------------------------------------------------- 1 | package com.doubledimple.mfa.service; 2 | 3 | import com.google.zxing.BarcodeFormat; 4 | import com.google.zxing.WriterException; 5 | import com.google.zxing.client.j2se.MatrixToImageWriter; 6 | import com.google.zxing.common.BitMatrix; 7 | import com.google.zxing.qrcode.QRCodeWriter; 8 | import org.springframework.stereotype.Service; 9 | 10 | import java.io.ByteArrayOutputStream; 11 | import java.io.IOException; 12 | import java.util.Base64; 13 | 14 | @Service 15 | public class QRCodeService { 16 | 17 | public String generateQRCodeImage(String otpAuthUrl) throws WriterException, IOException { 18 | QRCodeWriter qrCodeWriter = new QRCodeWriter(); 19 | BitMatrix bitMatrix = qrCodeWriter.encode(otpAuthUrl, BarcodeFormat.QR_CODE, 250, 250); 20 | 21 | ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); 22 | MatrixToImageWriter.writeToStream(bitMatrix, "PNG", byteArrayOutputStream); 23 | byte[] qrCodeBytes = byteArrayOutputStream.toByteArray(); 24 | return Base64.getEncoder().encodeToString(qrCodeBytes); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/main/java/com/doubledimple/mfa/config/JpaConfig.java: -------------------------------------------------------------------------------- 1 | package com.doubledimple.mfa.config; 2 | 3 | import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer; 4 | import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer; 5 | import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer; 6 | import org.springframework.context.annotation.Bean; 7 | import org.springframework.context.annotation.Configuration; 8 | 9 | import java.text.SimpleDateFormat; 10 | import java.time.format.DateTimeFormatter; 11 | 12 | /** 13 | * @author doubleDimple 14 | * @date 2024:11:02日 12:20 15 | */ 16 | @Configuration 17 | public class JpaConfig { 18 | @Bean 19 | public Jackson2ObjectMapperBuilderCustomizer jsonCustomizer() { 20 | return builder -> { 21 | builder.dateFormat(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss")); 22 | builder.serializers(new LocalDateTimeSerializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); 23 | builder.deserializers(new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); 24 | }; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | jackson: 3 | date-format: yyyy-MM-dd HH:mm:ss 4 | time-zone: UTC 5 | servlet: 6 | multipart: 7 | max-file-size: 100MB 8 | max-request-size: 100MB 9 | freemarker: 10 | template-loader-path: classpath:/templates/ 11 | content-type: text/html 12 | cache: false 13 | charset: utf-8 14 | check-template-location: true 15 | expose-request-attributes: false 16 | expose-session-attributes: false 17 | request-context-attribute: req 18 | suffix: .ftl 19 | datasource: 20 | url: jdbc:h2:file:./data/otp_keys_db;MODE=MySQL 21 | driverClassName: org.h2.Driver 22 | username: otp_keys_user 23 | password: password_20240901aAa-@@@ 24 | jpa: 25 | hibernate: 26 | ddl-auto: update 27 | database-platform: org.hibernate.dialect.H2Dialect 28 | properties: 29 | hibernate: 30 | jdbc: 31 | time_zone: UTC 32 | h2: 33 | console: 34 | enabled: false 35 | settings: 36 | web-allow-others: false # 禁止外部访问 37 | trace: false 38 | web-port: -1 # 禁用web端口 39 | profiles: 40 | active: release 41 | upload: 42 | baseUrl: ./data -------------------------------------------------------------------------------- /src/main/java/com/doubledimple/mfa/config/SyncConfig.java: -------------------------------------------------------------------------------- 1 | package com.doubledimple.mfa.config; 2 | 3 | import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler; 4 | import org.springframework.aop.interceptor.SimpleAsyncUncaughtExceptionHandler; 5 | import org.springframework.context.annotation.Configuration; 6 | import org.springframework.scheduling.annotation.AsyncConfigurer; 7 | import org.springframework.scheduling.annotation.EnableAsync; 8 | import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; 9 | 10 | import java.util.concurrent.Executor; 11 | 12 | /** 13 | * @author doubleDimple 14 | * @date 2024:11:02日 12:49 15 | */ 16 | @Configuration 17 | @EnableAsync 18 | public class SyncConfig implements AsyncConfigurer { 19 | 20 | @Override 21 | public Executor getAsyncExecutor() { 22 | ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); 23 | executor.setCorePoolSize(2); 24 | executor.setMaxPoolSize(2); 25 | executor.setQueueCapacity(500); 26 | executor.setThreadNamePrefix("Sync-"); 27 | executor.initialize(); 28 | return executor; 29 | } 30 | 31 | @Override 32 | public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() { 33 | return new SimpleAsyncUncaughtExceptionHandler(); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /deploy.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: mfa-start 5 | labels: 6 | app: mfa-start 7 | spec: 8 | replicas: 1 9 | 10 | # ✅ 单副本必须显式指定滚动策略 11 | strategy: 12 | type: Recreate 13 | 14 | selector: 15 | matchLabels: 16 | app: mfa-start 17 | 18 | template: 19 | metadata: 20 | labels: 21 | app: mfa-start 22 | spec: 23 | containers: 24 | - name: mfa-start 25 | image: lovele/mfa-start:latest 26 | imagePullPolicy: Always 27 | 28 | # ⚠️ 必须和你程序真实监听端口一致 29 | ports: 30 | - containerPort: 9999 31 | 32 | # ✅ 关键:rollout 是否结束,全看这个 33 | readinessProbe: 34 | tcpSocket: 35 | port: 9999 36 | initialDelaySeconds: 20 37 | periodSeconds: 5 38 | timeoutSeconds: 2 39 | failureThreshold: 3 40 | 41 | # ✅ 可选但强烈推荐(避免以后升级再卡) 42 | livenessProbe: 43 | tcpSocket: 44 | port: 9999 45 | initialDelaySeconds: 60 46 | periodSeconds: 10 47 | timeoutSeconds: 2 48 | failureThreshold: 3 49 | 50 | # ✅ 给 Java 应用一个优雅退出时间 51 | lifecycle: 52 | preStop: 53 | exec: 54 | command: ["sh", "-c", "sleep 10"] 55 | -------------------------------------------------------------------------------- /src/main/resources/logback-spring.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | %d{yyyy-MM-dd HH:mm:ss} [%thread] %highlight(%-5level) %logger{36} - %msg%n 8 | 9 | 10 | 11 | 12 | ${LOG_HOME}/application.log 13 | 14 | 15 | ${LOG_HOME}/application-%d{yyyy-MM-dd}.%i.log 16 | 17 | 3 18 | 1GB 19 | true 20 | 21 | 100MB 22 | 23 | 24 | %d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /src/main/java/com/doubledimple/mfa/task/SyncTaskService.java: -------------------------------------------------------------------------------- 1 | package com.doubledimple.mfa.task; 2 | 3 | import com.doubledimple.mfa.entity.SyncSettings; 4 | import com.doubledimple.mfa.service.SyncService; 5 | import lombok.extern.slf4j.Slf4j; 6 | import org.springframework.scheduling.annotation.Scheduled; 7 | import org.springframework.stereotype.Component; 8 | 9 | import javax.annotation.Resource; 10 | import java.time.LocalDateTime; 11 | import java.time.temporal.ChronoUnit; 12 | 13 | /** 14 | * @author doubleDimple 15 | * @date 2024:11:02日 13:14 16 | */ 17 | @Component 18 | @Slf4j 19 | public class SyncTaskService { 20 | 21 | @Resource 22 | SyncService syncService; 23 | 24 | 25 | @Scheduled(cron = "0 0 1 * * ?") 26 | public void checkAndSync() { 27 | try { 28 | SyncSettings settings = syncService.getSettings(); 29 | if (!settings.isEnabled() || settings.getSyncInterval() == null) { 30 | return; 31 | } 32 | 33 | LocalDateTime now = LocalDateTime.now(); 34 | LocalDateTime lastSync = settings.getLastSyncTime(); 35 | 36 | if (lastSync == null || 37 | ChronoUnit.DAYS.between(lastSync, now) >= settings.getSyncInterval()) { 38 | log.info("Starting scheduled sync..."); 39 | syncService.syncNowTask(); 40 | } 41 | } catch (Exception e) { 42 | log.error("Failed to check and sync", e); 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/main/java/com/doubledimple/mfa/utils/DesktopUtils.java: -------------------------------------------------------------------------------- 1 | package com.doubledimple.mfa.utils; 2 | 3 | import javax.servlet.http.HttpServletRequest; 4 | 5 | /** 6 | * @version 1.0.0 7 | * @ClassName DesktopUtils 8 | * @Description TODO 9 | * @Author doubleDimple 10 | * @Date 2025-07-05 11:16 11 | */ 12 | public class DesktopUtils { 13 | 14 | 15 | public static boolean isMobileRequest(HttpServletRequest request) { 16 | String userAgent = request.getHeader("User-Agent"); 17 | 18 | if (userAgent == null) { 19 | return false; 20 | } 21 | 22 | userAgent = userAgent.toLowerCase(); 23 | 24 | // 检查移动端标识 25 | String[] mobileKeywords = { 26 | "mobile", "android", "iphone", "ipad", "ipod", "blackberry", 27 | "windows phone", "opera mini", "webos", "palm", "symbian" 28 | }; 29 | 30 | for (String keyword : mobileKeywords) { 31 | if (userAgent.contains(keyword)) { 32 | return true; 33 | } 34 | } 35 | 36 | // 检查请求参数中是否明确指定移动端 37 | String mobileParam = request.getParameter("mobile"); 38 | if ("true".equals(mobileParam) || "1".equals(mobileParam)) { 39 | return true; 40 | } 41 | 42 | // 检查请求路径中是否包含mobile标识 43 | String requestURI = request.getRequestURI(); 44 | if (requestURI != null && requestURI.contains("/mobile/")) { 45 | return true; 46 | } 47 | 48 | return false; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/main/java/com/doubledimple/mfa/utils/TOTPUtils.java: -------------------------------------------------------------------------------- 1 | package com.doubledimple.mfa.utils; 2 | 3 | import org.apache.commons.codec.binary.Hex; 4 | 5 | import javax.crypto.Mac; 6 | import javax.crypto.spec.SecretKeySpec; 7 | 8 | /** 9 | * @author doubleDimple 10 | * @date 2024:10:05日 13:11 11 | */ 12 | public class TOTPUtils { 13 | 14 | public static String generateTOTP(String key, String time, String returnDigits) { 15 | int codeDigits = Integer.decode(returnDigits); 16 | String result = null; 17 | try { 18 | byte[] keyBytes = Hex.decodeHex(key); 19 | byte[] data = Hex.decodeHex(time); 20 | SecretKeySpec signKey = new SecretKeySpec(keyBytes, "HmacSHA1"); 21 | Mac mac = Mac.getInstance("HmacSHA1"); 22 | mac.init(signKey); 23 | byte[] hash = mac.doFinal(data); 24 | 25 | int offset = hash[hash.length - 1] & 0xf; 26 | int binary = ((hash[offset] & 0x7f) << 24) | 27 | ((hash[offset + 1] & 0xff) << 16) | 28 | ((hash[offset + 2] & 0xff) << 8) | 29 | (hash[offset + 3] & 0xff); 30 | 31 | int otp = binary % (int) Math.pow(10, codeDigits); 32 | result = Integer.toString(otp); 33 | while (result.length() < codeDigits) { 34 | result = "0" + result; 35 | } 36 | } catch (Exception e) { 37 | throw new RuntimeException("Error generating TOTP code", e); 38 | } 39 | return result; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/main/java/com/doubledimple/mfa/config/SecurityConfig.java: -------------------------------------------------------------------------------- 1 | package com.doubledimple.mfa.config; 2 | 3 | import org.springframework.context.annotation.Bean; 4 | import org.springframework.context.annotation.Configuration; 5 | import org.springframework.security.config.annotation.web.builders.HttpSecurity; 6 | import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; 7 | import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; 8 | import org.springframework.security.web.SecurityFilterChain; 9 | import org.springframework.security.web.firewall.HttpFirewall; 10 | import org.springframework.security.web.firewall.StrictHttpFirewall; 11 | 12 | /** 13 | * @author doubleDimple 14 | * @date 2024:10:05日 23:27 15 | */ 16 | @Configuration 17 | @EnableWebSecurity 18 | public class SecurityConfig { 19 | 20 | @Bean 21 | public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { 22 | http 23 | .authorizeRequests() 24 | .antMatchers("/login", "/css/**", "/js/**").permitAll() // 允许访问登录页面和静态资源 25 | .anyRequest().authenticated() // 其他请求需要认证 26 | .and() 27 | .formLogin() 28 | .loginPage("/login") // 自定义登录页面 29 | .loginProcessingUrl("/perform_login") // 表单提交处理路径 30 | .defaultSuccessUrl("/", true) // 登录成功后跳转到根路径 31 | .permitAll() 32 | .and() 33 | .logout() 34 | .logoutUrl("/perform_logout") 35 | .logoutSuccessUrl("/login?logout") // 登出后跳转的页面 36 | .permitAll(); 37 | 38 | return http.build(); 39 | } 40 | 41 | @Bean 42 | public HttpFirewall allowUrlSemicolonHttpFirewall() { 43 | StrictHttpFirewall firewall = new StrictHttpFirewall(); 44 | // 允许分号 45 | firewall.setAllowSemicolon(true); 46 | // 如果需要,还可以允许其他字符 47 | firewall.setAllowUrlEncodedSlash(true); // 允许URL编码的斜杠 48 | firewall.setAllowBackSlash(true); // 允许反斜杠 49 | firewall.setAllowUrlEncodedPercent(true);// 允许URL编码的百分号 50 | return firewall; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/main/resources/mfa-start.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | JAR_PATH="/root/mfa-start/mfa-start-release.jar" 4 | CONFIG_FILE="/root/mfa-start/mfa-start.yml" 5 | LOG_FILE="/dev/null" 6 | PID_FILE="mfa-start.pid" 7 | 8 | # 检查JAR包是否存在 9 | if [ ! -f "$JAR_PATH" ]; then 10 | echo "Error: JAR file not found at $JAR_PATH" 11 | exit 1 12 | fi 13 | 14 | start() { 15 | # 检查JAR包是否已经在运行 16 | if [ -f "$PID_FILE" ]; then 17 | PID=$(cat "$PID_FILE") 18 | if ps -p $PID > /dev/null; then 19 | echo "Application is already running with PID: $PID" 20 | exit 0 21 | fi 22 | fi 23 | 24 | # 启动JAR包,指定外部配置文件,并将输出重定向到日志文件 25 | nohup java -jar "$JAR_PATH" --spring.config.additional-location="$CONFIG_FILE" > "$LOG_FILE" 2>&1 & 26 | 27 | # 获取PID并输出 28 | PID=$! 29 | echo "Application started with PID: $PID" 30 | 31 | # 将PID保存到文件,方便管理(停止服务时使用) 32 | echo $PID > "$PID_FILE" 33 | } 34 | 35 | stop() { 36 | # 检查PID文件是否存在 37 | if [ ! -f "$PID_FILE" ]; then 38 | echo "PID file not found. Is the application running?" 39 | exit 1 40 | fi 41 | 42 | # 读取PID并停止进程 43 | PID=$(cat "$PID_FILE") 44 | if ps -p $PID > /dev/null; then 45 | echo "Stopping application with PID: $PID" 46 | kill $PID 47 | sleep 2 48 | if ps -p $PID > /dev/null; then 49 | echo "Application did not stop gracefully, forcing shutdown" 50 | kill -9 $PID 51 | fi 52 | echo "Application stopped." 53 | rm "$PID_FILE" 54 | else 55 | echo "No process found with PID: $PID" 56 | rm "$PID_FILE" 57 | fi 58 | } 59 | 60 | restart() { 61 | stop 62 | start 63 | } 64 | 65 | status() { 66 | if [ -f "$PID_FILE" ]; then 67 | PID=$(cat "$PID_FILE") 68 | if ps -p $PID > /dev/null; then 69 | echo "Application is running with PID: $PID" 70 | else 71 | echo "PID file found but no process is running with PID: $PID" 72 | fi 73 | else 74 | echo "Application is not running." 75 | fi 76 | } 77 | 78 | case "$1" in 79 | start) 80 | start 81 | ;; 82 | stop) 83 | stop 84 | ;; 85 | restart) 86 | restart 87 | ;; 88 | status) 89 | status 90 | ;; 91 | *) 92 | echo "Usage: $0 {start|stop|restart|status}" 93 | exit 1 94 | ;; 95 | esac -------------------------------------------------------------------------------- /src/main/java/com/doubledimple/mfa/entity/OTPKey.java: -------------------------------------------------------------------------------- 1 | package com.doubledimple.mfa.entity; 2 | import com.fasterxml.jackson.annotation.JsonFormat; 3 | import org.hibernate.annotations.CreationTimestamp; 4 | import org.hibernate.annotations.UpdateTimestamp; 5 | import org.springframework.format.annotation.DateTimeFormat; 6 | 7 | import javax.persistence.*; 8 | import java.time.LocalDateTime; 9 | import java.time.format.DateTimeFormatter; 10 | 11 | /** 12 | * @author doubleDimple 13 | * @date 2024:10:05日 00:57 14 | */ 15 | @Entity 16 | public class OTPKey { 17 | 18 | @Id 19 | @GeneratedValue(strategy = GenerationType.IDENTITY) 20 | private Long id; 21 | 22 | @Column(name = "keyName",unique = true) 23 | private String keyName; 24 | private String secretKey; 25 | 26 | @Column(name = "qrCode", length = 1024, nullable = true) 27 | private String qrCode; 28 | 29 | private String issuer; 30 | 31 | // 添加创建时间字段 32 | @Column(name = "createTime", nullable = false) 33 | @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") 34 | @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") 35 | private LocalDateTime createTime = LocalDateTime.now(); // 设置默认值 36 | 37 | // 添加更新时间字段 38 | @Column(name = "updateTime", nullable = false) 39 | @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") 40 | @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") 41 | private LocalDateTime updateTime = LocalDateTime.now(); // 设置默认值 42 | 43 | // 在每次更新实体时自动更新updateTime 44 | @PreUpdate 45 | public void preUpdate() { 46 | updateTime = LocalDateTime.now(); 47 | } 48 | 49 | 50 | public OTPKey() { 51 | } 52 | 53 | public OTPKey(String keyName, String secretKey) { 54 | this.keyName = keyName; 55 | this.secretKey = secretKey; 56 | } 57 | 58 | // Getters and Setters 59 | public String getKeyName() { 60 | return keyName; 61 | } 62 | 63 | public void setKeyName(String keyName) { 64 | this.keyName = keyName; 65 | } 66 | 67 | public String getSecretKey() { 68 | return secretKey; 69 | } 70 | 71 | public void setSecretKey(String secretKey) { 72 | this.secretKey = secretKey; 73 | } 74 | 75 | public String getQrCode() { 76 | return qrCode; 77 | } 78 | 79 | public void setQrCode(String qrCode) { 80 | this.qrCode = qrCode; 81 | } 82 | 83 | public Long getId() { 84 | return id; 85 | } 86 | 87 | public void setId(Long id) { 88 | this.id = id; 89 | } 90 | 91 | public String getIssuer() { 92 | return issuer; 93 | } 94 | 95 | public void setIssuer(String issuer) { 96 | this.issuer = issuer; 97 | } 98 | 99 | public LocalDateTime getCreateTime() { 100 | return createTime; 101 | } 102 | 103 | public void setCreateTime(LocalDateTime createTime) { 104 | this.createTime = createTime; 105 | } 106 | 107 | public LocalDateTime getUpdateTime() { 108 | return updateTime; 109 | } 110 | 111 | public void setUpdateTime(LocalDateTime updateTime) { 112 | this.updateTime = updateTime; 113 | } 114 | 115 | public String getFormattedCreateTime() { 116 | return createTime != null ? 117 | createTime.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")) : ""; 118 | } 119 | 120 | public String getFormattedUpdateTime() { 121 | return updateTime != null ? 122 | updateTime.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")) : ""; 123 | } 124 | } 125 | 126 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MFA-START 2 | 3 | 一个用于管理和验证 MFA(多因素认证)密钥的开源工具 4 | 5 | ## 📌 重要说明 6 | 7 | > **⚠️ 注意:** 密码和数据都存储在部署服务器上的 H2 数据库中,您可以随时关闭服务。如果介意数据存储位置,请勿使用本工具,谢谢。 8 | 9 | ### 主要功能 10 | - ✅ MFA 密钥验证 11 | - ✅ 密钥存储管理 12 | - ✅ 密钥导出功能 13 | - ✅ Web 界面管理 14 | 15 | ### 使用说明 16 | 此程序**完全免费开源**,仅可用于**测试和学习**目的,**禁止用于任何商业或非法用途**。 17 | 18 | ## ⚖️ 免责声明 19 | 20 | > **请仔细阅读以下免责条款:** 21 | 22 | 1. 本仓库发布的项目中涉及的任何脚本,仅用于测试和学习研究,禁止用于商业用途,不能保证其合法性、准确性、完整性和有效性,请根据情况自行判断。 23 | 24 | 2. 所有使用者在使用项目的任何部分时,需先遵守法律法规。对于一切使用不当所造成的后果,需自行承担。对任何脚本问题概不负责,包括但不限于由任何脚本错误导致的任何损失或损害。 25 | 26 | 3. 如果任何单位或个人认为该项目可能涉嫌侵犯其权利,则应及时通知并提供身份证明、所有权证明,我们将在收到认证文件后删除相关文件。 27 | 28 | 4. 任何以任何方式查看此项目的人或直接或间接使用该项目的任何脚本的使用者都应仔细阅读此声明。本人保留随时更改或补充此免责声明的权利。一旦使用并复制了任何相关脚本或本项目的规则,则视为您已接受此免责声明。 29 | 30 | 5. **您必须在下载后的 24 小时内从计算机或手机中完全删除以上内容。** 31 | 32 | 6. 您使用或者复制了本仓库且本人制作的任何脚本,则视为已接受此声明,请仔细阅读。 33 | 34 | ## 🔧 环境要求 35 | 36 | - **操作系统**:Linux (推荐 CentOS/Ubuntu) 37 | - **Java 版本**:JDK 8+ 38 | - **用户权限**:需要 root 权限 39 | - **网络要求**:能够访问 GitHub 下载资源 40 | 41 | ## 📦 部署步骤 42 | 43 | ### 1. 登录服务器 44 | ```bash 45 | # 切换到 root 用户 46 | sudo su - root 47 | ``` 48 | 49 | ### 2. 创建目录 50 | ```bash 51 | # 创建应用目录并进入 52 | mkdir -p /root/mfa-start && cd /root/mfa-start 53 | 54 | ``` 55 | 56 | ### 3. 下载部署脚本 57 | ```bash 58 | # 下载管理脚本并赋予执行权限 59 | wget -O mfa-start.sh https://raw.githubusercontent.com/doubleDimple/shell-tools/master/mfa-start.sh && chmod +x mfa-start.sh 60 | ``` 61 | 62 | ## 🚀 使用指南 63 | 64 | ### 基本命令 65 | 66 | #### 1️⃣ 启动应用 67 | ```bash 68 | ./mfa-start.sh start 69 | ``` 70 | > 首次启动会自动下载所需文件并生成登录凭据 71 | 72 | #### 2️⃣ 停止应用 73 | ```bash 74 | ./mfa-start.sh stop 75 | ``` 76 | 77 | #### 3️⃣ 重启应用 78 | ```bash 79 | ./mfa-start.sh restart 80 | ``` 81 | 82 | #### 4️⃣ 查看状态 83 | ```bash 84 | ./mfa-start.sh status 85 | ``` 86 | 87 | #### 5️⃣ 更新应用 88 | ```bash 89 | ./mfa-start.sh update 90 | ``` 91 | > 更新到最新版本,保留现有配置和凭据 92 | 93 | ### 凭据管理 94 | 95 | #### 6️⃣ 查看当前凭据 96 | ```bash 97 | ./mfa-start.sh password 98 | # 或 99 | ./mfa-start.sh passwd 100 | ``` 101 | 102 | #### 7️⃣ 修改凭据 103 | 104 | **修改用户名和密码:** 105 | ```bash 106 | ./mfa-start.sh password 自定义用户名 自定义密码 107 | ``` 108 | 109 | ## 📋 快速开始示例 110 | 111 | ```bash 112 | # 1. 创建目录 113 | mkdir -p /root/mfa-start && cd /root/mfa-start 114 | 115 | # 2. 下载脚本 116 | wget -O mfa-start.sh https://raw.githubusercontent.com/doubleDimple/shell-tools/master/mfa-start.sh 117 | chmod +x mfa-start.sh 118 | 119 | # 3. 启动应用(首次会自动生成凭据) 120 | ./mfa-start.sh start 121 | 122 | # 4. 查看生成的凭据 123 | ./mfa-start.sh password 124 | 125 | # 5. 访问 Web 界面 126 | # 浏览器访问: http://服务器IP:9999 127 | # 使用上面显示的用户名和密码登录 128 | ``` 129 | 130 | ## 📁 文件结构 131 | 132 | ``` 133 | /root/mfa-start/ 134 | ├── mfa-start.sh # 管理脚本 135 | ├── mfa-start-release.jar # 主程序 136 | ├── mfa-start.yml # 配置文件 137 | ├── .mfa-password # 密码文件(自动生成) 138 | └── data/ # 数据目录 139 | └── mfa.mv.db # H2 数据库文件 140 | ``` 141 | 142 | ## 🔐 安全建议 143 | 144 | 1. **修改默认密码**:首次启动后请立即修改默认生成的密码 145 | 2. **限制访问**:建议配置防火墙,仅允许可信 IP 访问 9999 端口 146 | 3. **定期备份**:重要数据请定期备份 `data` 目录 147 | 4. **HTTPS 访问**:生产环境建议配置反向代理启用 HTTPS 148 | 149 | ## ❓ 常见问题 150 | 151 | ### Q: 忘记密码怎么办? 152 | A: 可以通过 `./mfa-start.sh password` 查看当前密码,或使用 `./mfa-start.sh password "你的用户名" 新密码` 重置密码。 153 | 154 | ### Q: 如何修改默认端口? 155 | A: 编辑 `mfa-start.yml` 文件,修改 `server.port` 配置项,然后重启应用。 156 | 157 | ### Q: 数据存储在哪里? 158 | A: 所有数据存储在 `data` 目录下的 H2 数据库文件中。 159 | 160 | ### Q: 如何完全卸载? 161 | A: 停止服务后,删除整个 `/root/mfa-start` 目录即可。 162 | 163 | ## 📝 许可证 164 | 165 | 本项目采用开源许可,仅供学习和测试使用,禁止商业用途。 166 | 167 | ## 📞 联系方式 168 | 169 | 如有问题,请在 GitHub 上提交 Issue。 170 | 171 | --- 172 | -------------------------------------------------------------------------------- /src/main/java/com/doubledimple/mfa/service/OTPService.java: -------------------------------------------------------------------------------- 1 | package com.doubledimple.mfa.service; 2 | 3 | 4 | import com.doubledimple.mfa.entity.OTPKey; 5 | import com.doubledimple.mfa.repository.OTPKeyRepository; 6 | import com.doubledimple.mfa.response.OtpResponse; 7 | import com.google.zxing.WriterException; 8 | import lombok.extern.slf4j.Slf4j; 9 | import org.springframework.beans.factory.annotation.Autowired; 10 | import org.springframework.data.domain.Page; 11 | import org.springframework.data.domain.Pageable; 12 | import org.springframework.data.jpa.domain.Specification; 13 | import org.springframework.stereotype.Service; 14 | import org.springframework.transaction.annotation.Transactional; 15 | 16 | import javax.annotation.Resource; 17 | import javax.persistence.criteria.Predicate; 18 | import java.io.IOException; 19 | import java.util.ArrayList; 20 | import java.util.List; 21 | 22 | /** 23 | * @author doubleDimple 24 | * @date 2024:10:05日 13:01 25 | */ 26 | @Service 27 | @Slf4j 28 | public class OTPService { 29 | 30 | String issuer = "mfa-start"; 31 | String accountName = "user@example.com"; 32 | 33 | @Autowired 34 | private OTPKeyRepository otpKeyRepository; 35 | 36 | @Autowired 37 | private QRCodeService qrCodeService; 38 | 39 | @Resource 40 | private GoogleAuthenticatorDef googleAuthenticatorDef; 41 | 42 | 43 | public List getAllKeys() { 44 | return otpKeyRepository.findAll(); 45 | } 46 | 47 | @Transactional 48 | public void saveKey(OTPKey otpKey) { 49 | 50 | OTPKey otpKeyDb = otpKeyRepository.queryOTPKeyByKeyName(otpKey.getKeyName()); 51 | String secretKey = otpKey.getSecretKey(); 52 | String otpAuthUri = String.format("otpauth://totp/%s:%s?secret=%s&issuer=%s", 53 | issuer, accountName, secretKey, issuer); 54 | try { 55 | String qrCode = qrCodeService.generateQRCodeImage(otpAuthUri); 56 | otpKey.setQrCode(qrCode); 57 | } catch (WriterException e) { 58 | throw new RuntimeException(e); 59 | } catch (IOException e) { 60 | throw new RuntimeException(e); 61 | } 62 | if (null != otpKeyDb){ 63 | otpKey.setId(otpKeyDb.getId()); 64 | } 65 | otpKeyRepository.save(otpKey); 66 | } 67 | 68 | public String generateOtpCode(String secretKey) { 69 | return googleAuthenticatorDef.generateCode(secretKey); 70 | } 71 | 72 | @Transactional 73 | public void deleteKey(String keyName) { 74 | otpKeyRepository.deleteByKeyName(keyName); 75 | } 76 | 77 | 78 | public Page getPagedKeys(String searchTerm, Pageable pageable) { 79 | Specification spec = (root, query, criteriaBuilder) -> { 80 | List predicates = new ArrayList<>(); 81 | if (searchTerm != null && !searchTerm.isEmpty()) { 82 | predicates.add(criteriaBuilder.like(criteriaBuilder.lower(root.get("keyName")), "%" + searchTerm.toLowerCase() + "%")); 83 | } 84 | return criteriaBuilder.and(predicates.toArray(new Predicate[0])); 85 | }; 86 | return otpKeyRepository.findAll(spec, pageable); 87 | } 88 | 89 | @Transactional 90 | public void saveListKey(List otpKey) { 91 | otpKeyRepository.saveAll(otpKey); 92 | } 93 | 94 | public List generateOtpBatch(List secretKeys) { 95 | List otpResponses = new ArrayList<>(secretKeys.size()); 96 | for (String secretKey : secretKeys) { 97 | OtpResponse response = new OtpResponse(); 98 | String code = generateOtpCode(secretKey); 99 | response.setOtpCode(code); 100 | response.setSecretKey(secretKey); 101 | otpResponses.add(response); 102 | } 103 | return otpResponses; 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/main/java/com/doubledimple/mfa/service/GoogleAuthenticatorDef.java: -------------------------------------------------------------------------------- 1 | package com.doubledimple.mfa.service; 2 | 3 | import lombok.extern.slf4j.Slf4j; 4 | import org.apache.commons.codec.binary.Base32; 5 | import org.springframework.stereotype.Service; 6 | 7 | import javax.crypto.Mac; 8 | import javax.crypto.spec.SecretKeySpec; 9 | import java.security.InvalidKeyException; 10 | import java.security.NoSuchAlgorithmException; 11 | import java.time.Instant; 12 | 13 | /** 14 | * @author doubleDimple 15 | * @date 2024:11:02日 01:02 16 | */ 17 | @Service 18 | @Slf4j 19 | public class GoogleAuthenticatorDef { 20 | private static final int TIME_STEP = 30; // 时间片长度,单位秒 21 | private static final int CODE_LENGTH = 6; // 验证码长度 22 | 23 | public String generateCode(String secretKey) { 24 | try { 25 | // 1. 获取当前时间戳 26 | long currentTime = Instant.now().getEpochSecond(); 27 | // 2. 获取时间片数值 28 | long timeSlice = currentTime / TIME_STEP; 29 | 30 | // 调试信息 31 | if (log.isDebugEnabled()){ 32 | log.debug("Current Time: " + currentTime); 33 | log.debug("Time Slice: " + timeSlice); 34 | } 35 | 36 | 37 | // 3. 解码密钥 38 | Base32 base32 = new Base32(); 39 | byte[] decodedKey = base32.decode(secretKey); 40 | 41 | // 4. 生成 HMAC-SHA1 散列 42 | byte[] data = new byte[8]; 43 | for (int i = 8; i-- > 0; timeSlice >>>= 8) { 44 | data[i] = (byte) timeSlice; 45 | } 46 | 47 | SecretKeySpec signKey = new SecretKeySpec(decodedKey, "HmacSHA1"); 48 | Mac mac = Mac.getInstance("HmacSHA1"); 49 | mac.init(signKey); 50 | byte[] hash = mac.doFinal(data); 51 | 52 | // 5. 动态截断 53 | int offset = hash[hash.length - 1] & 0xF; 54 | long truncatedHash = 0; 55 | for (int i = 0; i < 4; ++i) { 56 | truncatedHash <<= 8; 57 | truncatedHash |= (hash[offset + i] & 0xFF); 58 | } 59 | truncatedHash &= 0x7FFFFFFF; 60 | truncatedHash %= Math.pow(10, CODE_LENGTH); 61 | 62 | // 6. 补齐位数 63 | return String.format("%0" + CODE_LENGTH + "d", truncatedHash); 64 | 65 | } catch (NoSuchAlgorithmException | InvalidKeyException e) { 66 | throw new RuntimeException("Error generating TOTP", e); 67 | } 68 | } 69 | 70 | // 验证方法 71 | public boolean verify(String secretKey, String code) { 72 | try { 73 | // 获取当前代码 74 | String currentCode = generateCode(secretKey); 75 | 76 | // 调试信息 77 | if (log.isDebugEnabled()){ 78 | log.debug("Input Code: " + code); 79 | log.debug("Generated Code: " + currentCode); 80 | } 81 | 82 | 83 | // 考虑前后 30 秒的容差 84 | return code.equals(currentCode) || 85 | code.equals(generateCodeForTime(secretKey, -30)) || 86 | code.equals(generateCodeForTime(secretKey, 30)); 87 | 88 | } catch (Exception e) { 89 | throw new RuntimeException("Error verifying TOTP", e); 90 | } 91 | } 92 | 93 | // 用于调试:生成指定时间偏移的验证码 94 | private String generateCodeForTime(String secretKey, int secondsOffset) { 95 | try { 96 | long currentTime = Instant.now().getEpochSecond() + secondsOffset; 97 | long timeSlice = currentTime / TIME_STEP; 98 | 99 | Base32 base32 = new Base32(); 100 | byte[] decodedKey = base32.decode(secretKey); 101 | 102 | byte[] data = new byte[8]; 103 | for (int i = 8; i-- > 0; timeSlice >>>= 8) { 104 | data[i] = (byte) timeSlice; 105 | } 106 | 107 | SecretKeySpec signKey = new SecretKeySpec(decodedKey, "HmacSHA1"); 108 | Mac mac = Mac.getInstance("HmacSHA1"); 109 | mac.init(signKey); 110 | byte[] hash = mac.doFinal(data); 111 | 112 | int offset = hash[hash.length - 1] & 0xF; 113 | long truncatedHash = 0; 114 | for (int i = 0; i < 4; ++i) { 115 | truncatedHash <<= 8; 116 | truncatedHash |= (hash[offset + i] & 0xFF); 117 | } 118 | truncatedHash &= 0x7FFFFFFF; 119 | truncatedHash %= Math.pow(10, CODE_LENGTH); 120 | 121 | return String.format("%0" + CODE_LENGTH + "d", truncatedHash); 122 | 123 | } catch (Exception e) { 124 | throw new RuntimeException("Error generating TOTP", e); 125 | } 126 | } 127 | 128 | // 主方法用于测试 129 | public static void main(String[] args) { 130 | GoogleAuthenticatorDef ga = new GoogleAuthenticatorDef(); 131 | String secretKey = "YOUR_SECRET_KEY"; // 替换为您的密钥 132 | 133 | // 打印调试信息 134 | System.out.println("System Time: " + Instant.now()); 135 | System.out.println("Generated Code: " + ga.generateCode(secretKey)); 136 | 137 | // 测试验证 138 | String testCode = "123456"; // 替换为要验证的代码 139 | boolean isValid = ga.verify(secretKey, testCode); 140 | System.out.println("Verification result: " + isValid); 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /src/main/java/com/doubledimple/mfa/controller/SettingsController.java: -------------------------------------------------------------------------------- 1 | package com.doubledimple.mfa.controller; 2 | 3 | import com.doubledimple.mfa.entity.OTPKey; 4 | import com.doubledimple.mfa.entity.SyncHistory; 5 | import com.doubledimple.mfa.entity.SyncSettings; 6 | import com.doubledimple.mfa.service.SyncService; 7 | import com.google.zxing.WriterException; 8 | import lombok.extern.slf4j.Slf4j; 9 | import org.apache.commons.lang3.StringUtils; 10 | import org.springframework.beans.factory.annotation.Autowired; 11 | import org.springframework.http.ResponseEntity; 12 | import org.springframework.stereotype.Controller; 13 | import org.springframework.ui.Model; 14 | import org.springframework.web.bind.annotation.*; 15 | 16 | import javax.annotation.Resource; 17 | import java.io.IOException; 18 | import java.time.LocalDateTime; 19 | import java.time.format.DateTimeFormatter; 20 | import java.util.HashMap; 21 | import java.util.List; 22 | import java.util.Map; 23 | import java.util.concurrent.TimeUnit; 24 | 25 | /** 26 | * @author doubleDimple 27 | * @date 2024:11:02日 12:30 28 | */ 29 | @Controller 30 | @Slf4j 31 | @RequestMapping("/") 32 | public class SettingsController { 33 | 34 | @Resource 35 | private SyncService syncService; 36 | 37 | @GetMapping("/settings") 38 | public String settings(Model model) { 39 | model.addAttribute("syncSettings", syncService.getSettings()); 40 | model.addAttribute("syncHistory", syncService.getHistory()); 41 | return "settings"; 42 | } 43 | 44 | @PostMapping("/api/test-connection") 45 | @ResponseBody 46 | public Map testConnection(@RequestBody Map request) { 47 | String userName = request.get("userName"); 48 | String password = request.get("password"); 49 | String url = request.get("url"); 50 | boolean success = syncService.testConnection(url, userName,password); 51 | Map response = new HashMap<>(); 52 | response.put("success", success); 53 | response.put("message", success ? "Connection successful" : "Connection failed"); 54 | return response; 55 | } 56 | 57 | @PostMapping("/api/sync-now") 58 | @ResponseBody 59 | public Map sync() { 60 | Map response = new HashMap<>(); 61 | try { 62 | boolean success = syncService.syncNow().get(30, TimeUnit.SECONDS); 63 | response.put("success", success); 64 | response.put("message", success ? "Sync completed" : "Sync failed"); 65 | } catch (Exception e) { 66 | response.put("success", false); 67 | response.put("message", "Sync failed: " + e.getMessage()); 68 | } 69 | return response; 70 | } 71 | 72 | @GetMapping("/api/sync-history") 73 | @ResponseBody 74 | public List getHistory() { 75 | return syncService.getHistory(); 76 | } 77 | 78 | @GetMapping("/api/settings") 79 | @ResponseBody 80 | public SyncSettings getSettings() { 81 | return syncService.getSettings(); 82 | } 83 | 84 | @PostMapping("/api/save-settings") 85 | @ResponseBody 86 | public Map saveSettings(@RequestBody Map request) { 87 | Map response = new HashMap<>(); 88 | String aListUrl = request.get("alistUrl"); 89 | Boolean enabled = Boolean.valueOf(request.get("enabled")); 90 | String password = request.get("password"); 91 | String userName = request.get("userName"); 92 | String backupPath = request.get("backupPath"); 93 | Integer interval = Integer.valueOf(request.get("syncInterval")); 94 | try { 95 | SyncSettings settingsDb = syncService.getSettings(); 96 | if (null != settingsDb) { 97 | settingsDb.setId(settingsDb.getId()); 98 | settingsDb.setSyncInterval(interval); 99 | settingsDb.setBackupPath(backupPath); 100 | settingsDb.setEnabled(enabled); 101 | settingsDb.setAListUrl(aListUrl); 102 | settingsDb.setPassword(password); 103 | settingsDb.setUserName(userName); 104 | syncService.saveSettings(settingsDb); 105 | 106 | }else { 107 | settingsDb.setSyncInterval(interval); 108 | settingsDb.setBackupPath(backupPath); 109 | settingsDb.setEnabled(enabled); 110 | settingsDb.setAListUrl(aListUrl); 111 | settingsDb.setPassword(password); 112 | settingsDb.setUserName(userName); 113 | syncService.saveSettings(settingsDb); 114 | } 115 | response.put("success", true); 116 | } catch (Exception e) { 117 | response.put("success", false); 118 | response.put("message", e.getMessage()); 119 | } 120 | return response; 121 | } 122 | 123 | @GetMapping("/api/sync-status") // 这个路径会变成 /api/sync-status 124 | public ResponseEntity> getSyncStatus() { 125 | Map status = new HashMap<>(); 126 | try { 127 | SyncSettings settings = syncService.getSettings(); 128 | 129 | status.put("lastSync", settings.getLastSyncTime() != null ? 130 | settings.getLastSyncTime().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")) : null); 131 | 132 | LocalDateTime nextSync = syncService.getNextSyncTime(); 133 | status.put("nextSync", nextSync != null ? 134 | nextSync.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")) : null); 135 | 136 | status.put("interval", settings.getSyncInterval()); 137 | status.put("enabled", settings.isEnabled()); 138 | 139 | return ResponseEntity.ok(status); 140 | } catch (Exception e) { 141 | status.put("error", e.getMessage()); 142 | return ResponseEntity.badRequest().body(status); 143 | } 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /src/main/resources/templates/common/sidebar.ftl: -------------------------------------------------------------------------------- 1 | 2 | 143 | 144 | 145 | 148 | 149 | 180 | 181 | -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 4.0.0 5 | 6 | 7 | org.springframework.boot 8 | spring-boot-starter-parent 9 | 2.7.6 10 | 11 | 12 | 13 | com.doubleDimple 14 | mfa-start 15 | 0.0.1-SNAPSHOT 16 | mfa-start 17 | oci-server 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 8 33 | 3.24.0 34 | 35 | 36 | 37 | org.springframework.boot 38 | spring-boot-starter 39 | 40 | 41 | 42 | org.springframework.boot 43 | spring-boot-starter-test 44 | test 45 | 46 | 47 | 48 | 49 | org.projectlombok 50 | lombok 51 | 52 | 53 | 54 | com.fasterxml.jackson.core 55 | jackson-databind 56 | 57 | 58 | com.fasterxml.jackson.core 59 | jackson-core 60 | 61 | 62 | com.fasterxml.jackson.core 63 | jackson-annotations 64 | 65 | 66 | 67 | org.springframework.boot 68 | spring-boot-configuration-processor 69 | 2.4.13 70 | true 71 | 72 | 73 | 74 | org.springframework.boot 75 | spring-boot-starter-freemarker 76 | 77 | 78 | 79 | com.h2database 80 | h2 81 | 82 | 83 | 84 | org.springframework.boot 85 | spring-boot-starter-web 86 | 87 | 88 | 89 | com.google.zxing 90 | core 91 | 3.3.3 92 | 93 | 94 | com.google.zxing 95 | javase 96 | 3.3.3 97 | 98 | 99 | org.springframework.data 100 | spring-data-commons 101 | 2.7.6 102 | 103 | 104 | 105 | javax.persistence 106 | javax.persistence-api 107 | 2.2 108 | 109 | 110 | 111 | 112 | org.springframework.boot 113 | spring-boot-starter-data-jpa 114 | 115 | 116 | 117 | 118 | com.warrenstrange 119 | googleauth 120 | 1.5.0 121 | 122 | 123 | 124 | org.springframework.boot 125 | spring-boot-starter-security 126 | 1.5.15.RELEASE 127 | 128 | 129 | 130 | 131 | com.google.protobuf 132 | protobuf-java 133 | 3.19.4 134 | 135 | 136 | 137 | 138 | com.google.protobuf 139 | protobuf-java 140 | ${protobuf.version} 141 | 142 | 143 | 144 | commons-codec 145 | commons-codec 146 | 1.15 147 | 148 | 149 | 150 | org.apache.commons 151 | commons-lang3 152 | 3.14.0 153 | 154 | 155 | 156 | org.apache.httpcomponents 157 | httpclient 158 | 4.5.13 159 | 160 | 161 | org.apache.httpcomponents 162 | httpmime 163 | 4.5.13 164 | 165 | 166 | 167 | com.squareup.okhttp3 168 | okhttp 169 | 4.12.0 170 | 171 | 172 | 173 | 174 | cn.hutool 175 | hutool-all 176 | 5.8.25 177 | 178 | 179 | 180 | 181 | mfa-start-release 182 | 183 | 184 | kr.motd.maven 185 | os-maven-plugin 186 | 1.7.0 187 | 188 | 189 | 190 | 191 | org.springframework.boot 192 | spring-boot-maven-plugin 193 | 194 | com.doubledimple.mfa.MfaStartApplication 195 | 196 | 197 | 198 | org.xolstice.maven.plugins 199 | protobuf-maven-plugin 200 | 0.6.1 201 | 202 | 203 | com.google.protobuf:protoc:3.19.4:exe:${os.detected.classifier} 204 | 205 | 206 | 207 | 208 | 209 | compile 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | -------------------------------------------------------------------------------- /src/main/java/com/doubledimple/mfa/utils/GoogleAuthMigrationParser.java: -------------------------------------------------------------------------------- 1 | package com.doubledimple.mfa.utils; 2 | 3 | /** 4 | * @author doubleDimple 5 | * @date 2024:10:29日 20:28 6 | */ 7 | import lombok.Data; 8 | import org.apache.commons.codec.binary.Base32; 9 | 10 | import java.net.URLDecoder; 11 | import java.nio.ByteBuffer; 12 | import java.nio.charset.StandardCharsets; 13 | import java.util.ArrayList; 14 | import java.util.Base64; 15 | import java.util.List; 16 | 17 | public class GoogleAuthMigrationParser { 18 | 19 | @Data 20 | public static class OtpParameters { 21 | private String secret; 22 | private String name; 23 | private String issuer; 24 | private int algorithm = 1; 25 | private int digits = 6; 26 | private int type = 1; 27 | 28 | // Getters and setters 29 | public String getSecretInBase32() { 30 | if (secret == null) return null; 31 | // 先将 Base64 转回字节数组 32 | byte[] secretBytes = Base64.getDecoder().decode(secret); 33 | // 然后转换为 Base32 34 | return new Base32().encodeToString(secretBytes).replaceAll("=", ""); 35 | } 36 | } 37 | 38 | public static List parseUri(String migrationUri) { 39 | try { 40 | // 解析URI数据 41 | String data = migrationUri.substring(migrationUri.indexOf("data=") + 5); 42 | String urlDecoded = URLDecoder.decode(data, StandardCharsets.UTF_8.name()); 43 | byte[] decoded = Base64.getDecoder().decode(urlDecoded); 44 | ByteBuffer buffer = ByteBuffer.wrap(decoded); 45 | List accounts = new ArrayList<>(); 46 | 47 | // 读取外层消息 48 | while (buffer.hasRemaining()) { 49 | int tag = readTag(buffer); 50 | int fieldNumber = tag >>> 3; 51 | 52 | if (fieldNumber == 1) { // OTP parameters 53 | int length = (int) readVarint(buffer); 54 | int endPosition = buffer.position() + length; 55 | parseOtpParameters(buffer, endPosition, accounts); 56 | } else { 57 | skipUnknownField(buffer, tag); 58 | } 59 | } 60 | 61 | return accounts; 62 | } catch (Exception e) { 63 | System.err.println("Error parsing migration data: " + e.getMessage()); 64 | e.printStackTrace(); 65 | throw new RuntimeException("Failed to parse migration data", e); 66 | } 67 | } 68 | 69 | private static void parseOtpParameters(ByteBuffer buffer, int endPosition, List accounts) { 70 | OtpParameters params = new OtpParameters(); 71 | boolean validParams = false; 72 | 73 | while (buffer.position() < endPosition) { 74 | int tag = readTag(buffer); 75 | int fieldNumber = tag >>> 3; 76 | 77 | switch (fieldNumber) { 78 | case 1: // secret 79 | int secretLength = (int) readVarint(buffer); 80 | byte[] secretBytes = new byte[secretLength]; 81 | buffer.get(secretBytes); 82 | params.setSecret(Base64.getEncoder().encodeToString(secretBytes)); 83 | validParams = true; 84 | break; 85 | 86 | case 2: // name 87 | int nameLength = (int) readVarint(buffer); 88 | byte[] nameBytes = new byte[nameLength]; 89 | buffer.get(nameBytes); 90 | params.setName(new String(nameBytes, StandardCharsets.UTF_8)); 91 | break; 92 | 93 | case 3: // issuer 94 | int issuerLength = (int) readVarint(buffer); 95 | byte[] issuerBytes = new byte[issuerLength]; 96 | buffer.get(issuerBytes); 97 | params.setIssuer(new String(issuerBytes, StandardCharsets.UTF_8)); 98 | break; 99 | 100 | default: 101 | skipUnknownField(buffer, tag); 102 | break; 103 | } 104 | } 105 | 106 | if (validParams) { 107 | accounts.add(params); 108 | System.out.println("Successfully parsed: " + params); 109 | } 110 | } 111 | 112 | private static void skipUnknownField(ByteBuffer buffer, int tag) { 113 | int wireType = tag & 0x7; 114 | switch (wireType) { 115 | case 0: // Varint 116 | readVarint(buffer); 117 | break; 118 | case 1: // Fixed64 119 | buffer.position(buffer.position() + 8); 120 | break; 121 | case 2: // Length-delimited 122 | int length = (int) readVarint(buffer); 123 | buffer.position(buffer.position() + length); 124 | break; 125 | case 5: // Fixed32 126 | buffer.position(buffer.position() + 4); 127 | break; 128 | default: 129 | // 对于未知的类型,跳过一个字节 130 | buffer.position(buffer.position() + 1); 131 | } 132 | } 133 | 134 | private static int readTag(ByteBuffer buffer) { 135 | return (int) readVarint(buffer); 136 | } 137 | 138 | private static long readVarint(ByteBuffer buffer) { 139 | long value = 0; 140 | int shift = 0; 141 | 142 | while (buffer.hasRemaining()) { 143 | byte b = buffer.get(); 144 | value |= (long) (b & 0x7F) << shift; 145 | if ((b & 0x80) == 0) { 146 | break; 147 | } 148 | shift += 7; 149 | if (shift >= 64) { 150 | throw new IllegalArgumentException("Varint is too long"); 151 | } 152 | } 153 | 154 | return value; 155 | } 156 | 157 | private static void readField(ByteBuffer buffer) { 158 | // 1. 读取 tag,包含字段编号和 wire type 159 | int tag = buffer.get() & 0xFF; 160 | 161 | // 2. 解析 wire type (低3位) 162 | int wireType = tag & 0x7; 163 | 164 | // 3. 解析字段编号 (右移3位) 165 | int fieldNumber = tag >>> 3; 166 | 167 | // 4. 根据 wire type 处理相应类型的数据 168 | switch (wireType) { 169 | case 0: // Varint 170 | readVarint(buffer); 171 | break; 172 | 173 | case 1: // Fixed64 174 | buffer.position(buffer.position() + 8); 175 | break; 176 | 177 | case 2: // Length-delimited 178 | // 读取长度 179 | int length = (int) readVarint(buffer); 180 | // 跳过指定长度的数据 181 | buffer.position(buffer.position() + length); 182 | break; 183 | 184 | case 5: // Fixed32 185 | buffer.position(buffer.position() + 4); 186 | break; 187 | 188 | default: 189 | throw new IllegalArgumentException( 190 | String.format("Unknown wire type: %d (field number: %d)", wireType, fieldNumber) 191 | ); 192 | } 193 | } 194 | 195 | // 示例用法 196 | public static void main(String[] args) { 197 | String migrationUri = "otpauth-migration://offline?data=CjcKEP4eovjWG9YYu7W1FiKPyCISETEwMDQ5NDA3MjVAcXEuY29tGgplMDA0OTQwNzI1IAEoATACCjoKEMMomMAYRQdnMK/xhithGbsSE2xvdmVsZS5jbkBnbWFpbC5jb20aC2Zlcm5hbmRvdGJsIAEoATACCi8KFE2ovHvrdx2A3JFVcUxSXoUsBdAKEgZsb3ZlbGUaCVNwYWNlc2hpcCABKAEwAgo2Cgqx5lCaKX6nJvW6EhVtaXNub21tYXNpY0BnbWFpbC5jb20aC0JpbmFuY2UuY29tIAEoATACCkAKEEo/XpH3OwbB9c83ghKAcr4SF3Jlbnl1YW54aW4wMDFAZ21haWwuY29tGg1ld2FyZG9laHJsZWluIAEoATACCiYKFKNEo83H/I73mqAW8UDECjLthT4MEghyZWRvdHBheSABKAEwAgojChQxp5GWbjM1ghEebGcHRgPnpKkn1BIFdGhQYXkgASgBMAIKJAoKE6gixtdRrzFUuhIQc21zLWFjdGl2YXRlLm9yZyABKAEwAgoyChCwSapRadYxa9JSgjStuEuxEg5tb2thbmRlckBiay5ydRoIbW9rYW5kZXIgASgBMAIKNgoQuZYewwSl8gKRNsQ96ZB8gBISb2JqYm95QGhvdG1haWwuY29tGghtb2thbmRlciABKAEwAhACGAcgAA=="; 198 | 199 | List accounts = parseUri(migrationUri); 200 | for (OtpParameters account : accounts) { 201 | System.out.println("\nAccount Details:"); 202 | System.out.println("Name: " + account.getName()); 203 | System.out.println("Issuer: " + account.getIssuer()); 204 | System.out.println("Secret (Base64): " + account.getSecret()); 205 | System.out.println("Secret (Base32): " + account.getSecretInBase32()); 206 | System.out.println("Type: " + (account.getType() == 1 ? "TOTP" : "HOTP")); 207 | System.out.println("Algorithm: " + (account.getAlgorithm() == 1 ? "SHA1" : "Unknown")); 208 | System.out.println("Digits: " + account.getDigits()); 209 | } 210 | } 211 | } 212 | -------------------------------------------------------------------------------- /src/main/resources/templates/login.ftl: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Login - OTP Management 7 | 8 | 237 | 238 | 239 | 295 | 296 | 325 | 326 | -------------------------------------------------------------------------------- /src/main/java/com/doubledimple/mfa/service/impl/SyncServiceImpl.java: -------------------------------------------------------------------------------- 1 | package com.doubledimple.mfa.service.impl; 2 | 3 | import cn.hutool.core.util.StrUtil; 4 | import cn.hutool.http.HttpException; 5 | import cn.hutool.http.HttpResponse; 6 | import cn.hutool.http.HttpUtil; 7 | import cn.hutool.json.JSONObject; 8 | import cn.hutool.json.JSONUtil; 9 | import com.doubledimple.mfa.entity.AListParam; 10 | import com.doubledimple.mfa.entity.OTPKey; 11 | import com.doubledimple.mfa.entity.SyncHistory; 12 | import com.doubledimple.mfa.entity.SyncSettings; 13 | import com.doubledimple.mfa.repository.OTPKeyRepository; 14 | import com.doubledimple.mfa.repository.SyncHistoryRepository; 15 | import com.doubledimple.mfa.repository.SyncSettingsRepository; 16 | import com.doubledimple.mfa.response.AListResponse; 17 | import com.doubledimple.mfa.response.AListTokenResponse; 18 | import com.doubledimple.mfa.service.SyncService; 19 | import com.fasterxml.jackson.databind.ObjectMapper; 20 | import lombok.extern.slf4j.Slf4j; 21 | import okhttp3.*; 22 | import org.apache.commons.lang3.StringUtils; 23 | import org.apache.http.client.methods.CloseableHttpResponse; 24 | import org.apache.http.client.methods.HttpGet; 25 | import org.apache.http.impl.client.CloseableHttpClient; 26 | import org.apache.http.impl.client.HttpClients; 27 | import org.springframework.beans.factory.annotation.Value; 28 | import org.springframework.data.domain.PageRequest; 29 | import org.springframework.data.domain.Sort; 30 | import org.springframework.scheduling.annotation.Async; 31 | import org.springframework.stereotype.Service; 32 | import org.springframework.transaction.annotation.Transactional; 33 | 34 | import javax.annotation.Resource; 35 | import java.awt.print.Pageable; 36 | import java.io.*; 37 | import java.net.HttpURLConnection; 38 | import java.net.URI; 39 | import java.net.URL; 40 | import java.net.URLEncoder; 41 | import java.nio.charset.StandardCharsets; 42 | import java.nio.file.Files; 43 | import java.time.Duration; 44 | import java.time.LocalDateTime; 45 | import java.time.format.DateTimeFormatter; 46 | import java.util.ArrayList; 47 | import java.util.HashMap; 48 | import java.util.List; 49 | import java.util.Map; 50 | import java.util.concurrent.CompletableFuture; 51 | 52 | import static com.doubledimple.mfa.constant.Constant.A_LIST_AUTH_LOGIN_URL_SUFFIX; 53 | import static com.doubledimple.mfa.constant.Constant.A_LIST_PUT_URL_SUFFIX; 54 | 55 | /** 56 | * @author doubleDimple 57 | * @date 2024:11:02日 12:46 58 | */ 59 | @Service 60 | @Slf4j 61 | public class SyncServiceImpl implements SyncService { 62 | @Resource 63 | private SyncSettingsRepository syncSettingsRepository; 64 | 65 | @Resource 66 | private SyncHistoryRepository syncHistoryRepository; 67 | 68 | @Resource 69 | OTPKeyRepository otpKeyRepository; 70 | 71 | @Value("${upload.baseUrl}") 72 | private String baseurl; 73 | 74 | private final ObjectMapper objectMapper = new ObjectMapper(); 75 | 76 | 77 | public SyncSettings getSettings() { 78 | return syncSettingsRepository.findFirstByOrderById() 79 | .orElse(new SyncSettings()); 80 | } 81 | 82 | @Override 83 | @Transactional 84 | public void saveSettings(SyncSettings settings) { 85 | if (settings.getId() == null) { 86 | // 如果是新设置,确保只有一条记录 87 | syncSettingsRepository.findFirstByOrderById().ifPresent(existing -> { 88 | settings.setId(existing.getId()); 89 | }); 90 | } 91 | syncSettingsRepository.save(settings); 92 | } 93 | 94 | @Override 95 | public List getHistory() { 96 | int pageSize = 15; 97 | PageRequest time = PageRequest.of(0, pageSize, Sort.by(Sort.Direction.DESC, "time")); 98 | return syncHistoryRepository.findAllByOrderByTimeDesc(time); 99 | } 100 | 101 | @Override 102 | public boolean testConnection(String url,String username, String password) { 103 | SyncSettings syncSettings = new SyncSettings(); 104 | syncSettings.setAListUrl(url); 105 | syncSettings.setUserName(username); 106 | syncSettings.setPassword(password); 107 | String aListToken = getAListToken(syncSettings); 108 | if (StringUtils.isEmpty(aListToken)){ 109 | log.error("Test connection failed please your userName and password"); 110 | return false; 111 | } 112 | return true; 113 | } 114 | 115 | @Async 116 | @Transactional 117 | public CompletableFuture syncNow() { 118 | SyncSettings settings = getSettings(); 119 | if (!settings.isEnabled()) { 120 | return CompletableFuture.completedFuture(false); 121 | } 122 | 123 | SyncHistory history = new SyncHistory(); 124 | history.setTime(LocalDateTime.now()); 125 | 126 | File tempFile = null; 127 | try { 128 | // 创建备份文件 129 | tempFile = createBackupFile(); 130 | 131 | // 上传到Alist 132 | boolean success = uploadToAlist(tempFile, settings); 133 | 134 | // 记录历史 135 | history.setSuccess(success); 136 | history.setSize(tempFile.length()); 137 | history.setDetails(success ? "Successfully synced " + otpKeyRepository.count() + " keys" 138 | : "Sync failed"); 139 | 140 | return CompletableFuture.completedFuture(success); 141 | 142 | } catch (Exception e) { 143 | log.error("Sync failed", e); 144 | history.setSuccess(false); 145 | history.setDetails("Error: " + e.getMessage()); 146 | return CompletableFuture.completedFuture(false); 147 | 148 | } finally { 149 | // 保存历史记录 150 | syncHistoryRepository.save(history); 151 | 152 | // 清理临时文件 153 | if (tempFile != null && tempFile.exists()) { 154 | tempFile.delete(); 155 | } 156 | } 157 | } 158 | 159 | private File createBackupFile() throws Exception { 160 | SyncSettings settings = getSettings(); 161 | String backupPath = baseurl + settings.getBackupPath(); 162 | File backupFolder = new File(backupPath); 163 | if (!backupFolder.exists()) { 164 | backupFolder.mkdirs(); 165 | } 166 | // 获取所有OTP密钥数据 167 | List keys = otpKeyRepository.findAll(); 168 | 169 | // 创建导出数据对象 170 | Map exportData = new HashMap<>(); 171 | exportData.put("exportTime", LocalDateTime.now().toString()); 172 | exportData.put("totalCount", keys.size()); 173 | 174 | // 转换密钥数据,移除不需要的字段 175 | List> keyList = new ArrayList<>(); 176 | for (OTPKey key : keys) { 177 | Map keyMap = new HashMap<>(); 178 | keyMap.put("keyName", key.getKeyName()); 179 | keyMap.put("secretKey", key.getSecretKey()); 180 | keyMap.put("issuer", key.getIssuer()); 181 | keyList.add(keyMap); 182 | } 183 | exportData.put("keys", keyList); 184 | 185 | String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss")); 186 | File backupFile = new File(backupPath, "otp_backup_" + timestamp + ".json"); 187 | objectMapper.writerWithDefaultPrettyPrinter().writeValue(backupFile, exportData); 188 | 189 | // 创建临时文件 190 | //File tempFile = File.createTempFile("otp_backup_", ".json"); 191 | //objectMapper.writerWithDefaultPrettyPrinter().writeValue(tempFile, exportData); 192 | 193 | // 验证文件是否成功写入 194 | if (!backupFile.exists()) { 195 | log.error("File was not created: {}", backupFile.getAbsolutePath()); 196 | throw new IOException("Failed to create backup file"); 197 | } 198 | 199 | if (backupFile.length() == 0) { 200 | log.error("File was created but is empty: {}", backupFile.getAbsolutePath()); 201 | throw new IOException("Backup file is empty"); 202 | } 203 | 204 | // 尝试读取文件内容进行验证 205 | String content = new String(Files.readAllBytes(backupFile.toPath())); 206 | if (content.isEmpty()) { 207 | log.error("File content is empty"); 208 | throw new IOException("Backup file content is empty"); 209 | } 210 | 211 | log.info("Successfully created backup file: {}, size: {} bytes", 212 | backupFile.getAbsolutePath(), backupFile.length()); 213 | 214 | return backupFile; 215 | } 216 | 217 | private boolean uploadToAlist(File file, SyncSettings settings) { 218 | try { 219 | String alistUrl = settings.getAListUrl(); 220 | // 确保URL格式正确 221 | if (!alistUrl.startsWith("http://") && !alistUrl.startsWith("https://")) { 222 | alistUrl = "http://" + alistUrl; 223 | } 224 | alistUrl = alistUrl.replaceAll("/$", ""); 225 | 226 | // 构建上传路径 227 | String uploadPath = settings.getBackupPath(); 228 | // 确保路径格式正确 (新版本不需要开头的斜杠) 229 | uploadPath = uploadPath.replaceAll("^/+", ""); 230 | 231 | // 构建完整的上传URL 232 | String uploadUrl = alistUrl + A_LIST_PUT_URL_SUFFIX; 233 | 234 | log.info("Uploading to: {}", uploadUrl); 235 | log.info("Upload path: {}", uploadPath); 236 | String aListToken = getAListToken(settings); 237 | if (StringUtils.isEmpty(aListToken)){ 238 | log.error("Failed to get aList token"); 239 | return false; 240 | } 241 | uploadFile(file.getPath(),uploadPath,uploadUrl,aListToken); 242 | return true; 243 | 244 | } catch (Exception e) { 245 | log.error("Failed to upload file to Alist", e); 246 | return false; 247 | } 248 | } 249 | 250 | @Override 251 | public void syncNowTask() { 252 | File tempFile = null; 253 | try { 254 | SyncSettings settings = getSettings(); 255 | if (!settings.isEnabled()) { 256 | log.info("Sync is disabled, skipping backup"); 257 | return; 258 | } 259 | 260 | log.info("Creating backup file..."); 261 | tempFile = createBackupFile(); 262 | 263 | log.info("Uploading backup file to Alist..."); 264 | boolean success = uploadToAlist(tempFile, settings); 265 | 266 | if (success) { 267 | log.info("Backup completed successfully"); 268 | // 更新上次同步时间 269 | settings.setLastSyncTime(LocalDateTime.now()); 270 | syncSettingsRepository.save(settings); 271 | } else { 272 | log.error("Backup failed"); 273 | } 274 | 275 | } catch (Exception e) { 276 | log.error("Backup failed", e); 277 | } finally { 278 | if (tempFile != null && tempFile.exists()) { 279 | tempFile.delete(); 280 | } 281 | } 282 | } 283 | 284 | @Override 285 | public String getAListToken(SyncSettings settings) { 286 | String userName = settings.getUserName(); 287 | String password = settings.getPassword(); 288 | String aListUrl = settings.getAListUrl(); 289 | // 如果URL以/结尾,去掉/ 290 | String token = null; 291 | try { 292 | if (aListUrl.endsWith("/")) { 293 | aListUrl = StrUtil.removeSuffix(aListUrl, "/"); 294 | } 295 | String fullPath = aListUrl + A_LIST_AUTH_LOGIN_URL_SUFFIX; 296 | AListParam build = AListParam.builder().userName(userName).password(password).build(); 297 | String s = JSONUtil.toJsonStr(build); 298 | HttpResponse execute = HttpUtil.createPost(fullPath).body(s).execute(); 299 | String body = execute.body(); 300 | 301 | AListResponse aListResponse = JSONUtil.toBean(body, AListResponse.class); 302 | // 将data转换为JSONObject 303 | JSONObject jsonObject = JSONUtil.parseObj(aListResponse.getData()); 304 | // 获取token 305 | token = jsonObject.getStr("token"); 306 | } catch (HttpException e) { 307 | log.error("get aList token err: {}",e.getMessage(),e); 308 | return StringUtils.EMPTY; 309 | } 310 | return token; 311 | } 312 | 313 | public LocalDateTime getNextSyncTime() { 314 | SyncSettings settings = getSettings(); 315 | if (!settings.isEnabled() || settings.getSyncInterval() == null 316 | || settings.getLastSyncTime() == null) { 317 | return null; 318 | } 319 | return settings.getLastSyncTime().plusDays(settings.getSyncInterval()); 320 | } 321 | 322 | 323 | public static void uploadFile(String filePath, String targetPath,String url,String token) { 324 | OkHttpClient client = new OkHttpClient(); 325 | 326 | try { 327 | // 创建文件对象 328 | File file = new File(filePath); 329 | 330 | // 构建完整的上传路径:挂载路径 + 子路径 331 | String fullPath = targetPath; 332 | // 添加文件名 333 | fullPath += "/" + file.getName(); 334 | 335 | System.out.println("完整上传路径: " + fullPath); 336 | 337 | // 构建multipart请求体 338 | RequestBody requestBody = new MultipartBody.Builder() 339 | .setType(MultipartBody.FORM) 340 | .addFormDataPart("file", file.getName(), 341 | RequestBody.create(MediaType.parse("application/octet-stream"), file)) 342 | .build(); 343 | 344 | // 构建请求 345 | Request request = new Request.Builder() 346 | .url(url) 347 | .put(requestBody) 348 | .addHeader("Authorization", token) 349 | .addHeader("File-Path", URLEncoder.encode(targetPath + "/" + file.getName(), StandardCharsets.UTF_8.toString())) 350 | .addHeader("Content-Length", String.valueOf(file.length())) 351 | .addHeader("Content-Type", "multipart/form-data") 352 | .build(); 353 | 354 | // 发送请求 355 | try (Response response = client.newCall(request).execute()) { 356 | if (response.isSuccessful()) { 357 | if (response.code() == 200){ 358 | log.info("Upload successful: {}",response.body().string()); 359 | }else { 360 | log.error("Upload failed code:{} message:{}",response.code(),response.message()); 361 | } 362 | } else { 363 | log.error("Upload failed code:{} message:{}",response.code(),response.message()); 364 | } 365 | } 366 | 367 | } catch (IOException e) { 368 | e.printStackTrace(); 369 | } 370 | } 371 | 372 | public static void main(String[] args) { 373 | String userName = "admin"; 374 | String password = "NEW_PASSWORD_20240919@@@"; 375 | AListParam build = AListParam.builder().userName(userName).password(password).build(); 376 | String s = JSONUtil.toJsonStr(build); 377 | HttpResponse execute = HttpUtil.createPost(" http://152.53.1.173:5244/api/auth/login").body(s).execute(); 378 | String body = execute.body(); 379 | 380 | AListResponse aListResponse = JSONUtil.toBean(body, AListResponse.class); 381 | // 将data转换为JSONObject 382 | JSONObject jsonObject = JSONUtil.parseObj(aListResponse.getData()); 383 | // 获取token 384 | String token = jsonObject.getStr("token"); 385 | System.out.println(token); 386 | } 387 | } 388 | -------------------------------------------------------------------------------- /src/main/java/com/doubledimple/mfa/controller/OTPController.java: -------------------------------------------------------------------------------- 1 | package com.doubledimple.mfa.controller; 2 | 3 | import com.doubledimple.mfa.entity.OTPKey; 4 | import com.doubledimple.mfa.repository.OTPKeyRepository; 5 | import com.doubledimple.mfa.request.OtpBatchRequest; 6 | import com.doubledimple.mfa.response.OtpResponse; 7 | import com.doubledimple.mfa.response.OtpResponse2; 8 | import com.doubledimple.mfa.service.OTPService; 9 | import com.doubledimple.mfa.service.QRCodeService; 10 | import com.doubledimple.mfa.utils.GoogleAuthMigrationParser; 11 | import com.google.zxing.BinaryBitmap; 12 | import com.google.zxing.MultiFormatReader; 13 | import com.google.zxing.Result; 14 | import com.google.zxing.WriterException; 15 | import com.google.zxing.client.j2se.BufferedImageLuminanceSource; 16 | import com.google.zxing.common.HybridBinarizer; 17 | import lombok.extern.slf4j.Slf4j; 18 | import org.apache.commons.lang3.StringUtils; 19 | import org.springframework.http.HttpStatus; 20 | import org.springframework.http.ResponseEntity; 21 | import org.springframework.stereotype.Controller; 22 | import org.springframework.ui.Model; 23 | import org.springframework.web.bind.annotation.*; 24 | import org.springframework.web.multipart.MultipartFile; 25 | 26 | import javax.annotation.Resource; 27 | import javax.imageio.ImageIO; 28 | import javax.servlet.http.HttpServletRequest; 29 | import javax.servlet.http.HttpServletResponse; 30 | import java.awt.image.BufferedImage; 31 | import java.io.IOException; 32 | import java.io.PrintWriter; 33 | import java.net.URI; 34 | import java.nio.charset.StandardCharsets; 35 | import java.util.*; 36 | import java.util.concurrent.atomic.AtomicReference; 37 | 38 | import static com.doubledimple.mfa.utils.DesktopUtils.isMobileRequest; 39 | 40 | 41 | /** 42 | * @author doubleDimple 43 | * @date 2024:10:05日 01:00 44 | */ 45 | @Controller 46 | @Slf4j 47 | public class OTPController { 48 | 49 | @Resource 50 | private OTPService otpService; 51 | 52 | @Resource 53 | private QRCodeService qrCodeService; 54 | 55 | @Resource 56 | private OTPKeyRepository otpKeyRepository; 57 | 58 | @GetMapping("/api/otpKeys") 59 | @ResponseBody 60 | public List getOtpKeys() { 61 | return otpService.getAllKeys(); 62 | } 63 | 64 | // 显示主页 65 | @GetMapping("/") 66 | public String index(Model model, HttpServletRequest request) { 67 | List otpKeys = otpService.getAllKeys(); 68 | if (otpKeys.size() > 0) { 69 | model.addAttribute("otpKeys", otpKeys); 70 | } 71 | if (isMobileRequest(request)) { 72 | return "mobile/mobile_index"; 73 | } else { 74 | return "index"; 75 | } 76 | } 77 | 78 | // 保存密钥 79 | @PostMapping("/save-secret2") 80 | public String saveSecret2(@RequestParam(value = "keyName", required = false) String keyName, 81 | @RequestParam(value = "secretKey", required = false) String secretKey, 82 | @RequestParam(value = "qrCode", required = false) MultipartFile qrCode) { 83 | AtomicReference finalSecretKey = new AtomicReference<>(secretKey); 84 | List> accounts = new ArrayList<>(); 85 | if (StringUtils.isEmpty(keyName)) { 86 | keyName = System.currentTimeMillis() + ""; 87 | } 88 | try { 89 | // 如果上传了二维码文件,则解析二维码 90 | if (qrCode != null && !qrCode.isEmpty()) { 91 | // 读取图片 92 | BufferedImage image = ImageIO.read(qrCode.getInputStream()); 93 | if (image == null) { 94 | throw new IllegalArgumentException("Invalid image file"); 95 | } 96 | 97 | // 使用ZXing解析二维码 98 | BinaryBitmap binaryBitmap = new BinaryBitmap(new HybridBinarizer( 99 | new BufferedImageLuminanceSource(image))); 100 | Result result = new MultiFormatReader().decode(binaryBitmap); 101 | 102 | // 解析 otpauth:// URI 103 | String qrContent = result.getText(); 104 | log.info("qrCode text is: {}", qrContent); 105 | if (qrContent.startsWith("otpauth://")) { 106 | URI uri = new URI(qrContent); 107 | String query = uri.getQuery(); 108 | // 从查询参数中获取 secret 109 | Arrays.stream(query.split("&")) 110 | .filter(param -> param.startsWith("secret=")) 111 | .findFirst() 112 | .ifPresent(secret -> { 113 | finalSecretKey.set(secret.substring(7));// 去掉 "secret=" 前缀 114 | }); 115 | // 验证 secretKey 不为空 116 | if (finalSecretKey.get() == null || finalSecretKey.get().trim().isEmpty()) { 117 | throw new IllegalArgumentException("Secret key is required"); 118 | } 119 | OTPKey otpKey = new OTPKey(keyName, finalSecretKey.get()); 120 | otpKey.setIssuer("mfa-start"); 121 | OTPKey byKeyName = otpKeyRepository.findBySecretKey(otpKey.getSecretKey()); 122 | if (byKeyName == null) { 123 | otpService.saveKey(otpKey); 124 | } 125 | 126 | } else if (qrContent.startsWith("otpauth-migration://")) { 127 | 128 | List otpParameters = GoogleAuthMigrationParser.parseUri(qrContent); 129 | List otpKeys = new ArrayList<>(); 130 | for (GoogleAuthMigrationParser.OtpParameters account : otpParameters) { 131 | OTPKey otpKey = new OTPKey(); 132 | otpKey.setKeyName(account.getName()); 133 | otpKey.setSecretKey(account.getSecretInBase32()); 134 | if (account.getIssuer() == null) { 135 | otpKey.setIssuer("mfa-start"); 136 | } else { 137 | otpKey.setIssuer(account.getIssuer()); 138 | } 139 | String otpAuthUri = String.format("otpauth://totp/%s:%s?secret=%s&issuer=%s", 140 | otpKey.getIssuer(), otpKey.getKeyName(), otpKey.getSecretKey(), otpKey.getIssuer()); 141 | 142 | String qrCodeNew = qrCodeService.generateQRCodeImage(otpAuthUri); 143 | otpKey.setQrCode(qrCodeNew); 144 | OTPKey byKeyName = otpKeyRepository.findBySecretKey(otpKey.getSecretKey()); 145 | if (byKeyName != null) { 146 | otpKeys.add(byKeyName); 147 | } else { 148 | otpKeys.add(otpKey); 149 | } 150 | 151 | } 152 | otpService.saveListKey(otpKeys); 153 | } 154 | } else { 155 | OTPKey otpKey = new OTPKey(keyName, secretKey); 156 | otpKey.setIssuer("mfa-start"); 157 | OTPKey byKeyName = otpKeyRepository.findByKeyName(keyName); 158 | if (byKeyName == null) { 159 | otpService.saveKey(new OTPKey(keyName, secretKey)); 160 | } 161 | } 162 | log.info("result:{}", accounts); 163 | return "redirect:/"; 164 | } catch (Exception e) { 165 | log.error("Error saving OTP key", e); 166 | return "redirect:/"; 167 | } 168 | } 169 | 170 | 171 | /** 172 | * 统一处理前端提交的密钥和二维码内容。 173 | * * @param keyName 手动输入的账户名 174 | * 175 | * @param secretKey 手动输入的密钥 176 | * @param qrContent 前端扫描后发送的原始二维码字符串 177 | * @param qrCode 前端上传的二维码图片文件 178 | * @return 重定向到首页 179 | */ 180 | @PostMapping("/save-secret") 181 | public String saveSecret(@RequestParam(value = "keyName", required = false) String keyName, 182 | @RequestParam(value = "secretKey", required = false) String secretKey, 183 | @RequestParam(value = "qrContent", required = false) String qrContent, 184 | @RequestParam(value = "qrCode", required = false) MultipartFile qrCode) { 185 | log.info("keyName: {}, secretKey: {}, qrContent: {}, qrCode: {}", keyName, secretKey, qrContent, qrCode); 186 | try { 187 | if (!StringUtils.isEmpty(qrContent)) { 188 | // 情景1: 前端发送了二维码字符串 189 | processQrContent(qrContent); 190 | } else if (qrCode != null && !qrCode.isEmpty()) { 191 | // 情景2: 前端上传了二维码文件 (兼容旧逻辑) 192 | BufferedImage image = ImageIO.read(qrCode.getInputStream()); 193 | if (image == null) { 194 | throw new IllegalArgumentException("Invalid image file"); 195 | } 196 | BinaryBitmap binaryBitmap = new BinaryBitmap(new HybridBinarizer( 197 | new BufferedImageLuminanceSource(image))); 198 | Result result = new MultiFormatReader().decode(binaryBitmap); 199 | processQrContent(result.getText()); 200 | } else if (!StringUtils.isEmpty(secretKey)) { 201 | // 情景3: 前端手动输入了密钥 202 | OTPKey otpKey = new OTPKey(keyName, secretKey); 203 | otpKey.setIssuer("mfa-start"); 204 | OTPKey byKeyName = otpKeyRepository.findByKeyName(keyName); 205 | if (byKeyName == null) { 206 | otpService.saveKey(otpKey); 207 | } 208 | } else { 209 | throw new IllegalArgumentException("No valid key or QR code provided."); 210 | } 211 | 212 | return "redirect:/"; 213 | } catch (Exception e) { 214 | log.error("Error saving OTP key", e); 215 | return "redirect:/"; 216 | } 217 | } 218 | 219 | /** 220 | * 统一处理二维码内容字符串的私有方法 221 | * 222 | * @param qrContent 二维码内容字符串 223 | * @throws Exception 224 | */ 225 | private void processQrContent(String qrContent) throws Exception { 226 | log.info("Processing QR code content: {}", qrContent); 227 | if (qrContent.startsWith("otpauth://")) { 228 | URI uri = new URI(qrContent); 229 | String query = uri.getQuery(); 230 | AtomicReference finalSecretKey = new AtomicReference<>(); 231 | String finalKeyName = uri.getPath().substring(1); 232 | String finalIssuer = null; 233 | 234 | // 解析查询参数 235 | if (query != null) { 236 | for (String param : query.split("&")) { 237 | String[] parts = param.split("="); 238 | if (parts.length == 2) { 239 | if ("secret".equals(parts[0])) { 240 | finalSecretKey.set(parts[1]); 241 | } 242 | if ("issuer".equals(parts[0])) { 243 | finalIssuer = parts[1]; 244 | } 245 | } 246 | } 247 | } 248 | 249 | // 如果账户名包含冒号,按规范解析账户名和发行人 250 | if (finalKeyName.contains(":")) { 251 | String[] parts = finalKeyName.split(":"); 252 | finalIssuer = parts[0]; 253 | finalKeyName = parts[1]; 254 | } 255 | 256 | if (finalSecretKey.get() == null || finalSecretKey.get().trim().isEmpty()) { 257 | throw new IllegalArgumentException("Secret key is required from QR content"); 258 | } 259 | 260 | OTPKey otpKey = new OTPKey(finalKeyName, finalSecretKey.get()); 261 | otpKey.setIssuer(finalIssuer != null ? finalIssuer : "mfa-start"); 262 | 263 | OTPKey bySecretKey = otpKeyRepository.findBySecretKey(otpKey.getSecretKey()); 264 | if (bySecretKey == null) { 265 | otpService.saveKey(otpKey); 266 | } else { 267 | log.warn("OTP key with same secret already exists, skipping save."); 268 | } 269 | 270 | } else if (qrContent.startsWith("otpauth-migration://")) { 271 | List otpParameters = GoogleAuthMigrationParser.parseUri(qrContent); 272 | List otpKeys = new ArrayList<>(); 273 | for (GoogleAuthMigrationParser.OtpParameters account : otpParameters) { 274 | OTPKey otpKey = new OTPKey(); 275 | otpKey.setKeyName(account.getName()); 276 | otpKey.setSecretKey(account.getSecretInBase32()); 277 | otpKey.setIssuer(account.getIssuer() != null ? account.getIssuer() : "mfa-start"); 278 | 279 | OTPKey bySecretKey = otpKeyRepository.findBySecretKey(otpKey.getSecretKey()); 280 | if (bySecretKey == null) { 281 | // 为每个密钥生成二维码并保存 282 | String otpAuthUri = String.format("otpauth://totp/%s:%s?secret=%s&issuer=%s", 283 | otpKey.getIssuer(), otpKey.getKeyName(), otpKey.getSecretKey(), otpKey.getIssuer()); 284 | String qrCodeNew = qrCodeService.generateQRCodeImage(otpAuthUri); 285 | otpKey.setQrCode(qrCodeNew); 286 | otpKeys.add(otpKey); 287 | } else { 288 | otpKeys.add(bySecretKey); 289 | } 290 | } 291 | otpService.saveListKey(otpKeys); 292 | } else { 293 | // 其他未知格式,可以抛出异常或返回错误信息 294 | throw new IllegalArgumentException("Unsupported QR code format."); 295 | } 296 | } 297 | 298 | // 生成 OTP 码 299 | @GetMapping("/generate-otp") 300 | @ResponseBody 301 | public OtpResponse2 generateOtp(@RequestParam("secretKey") String secretKey) { 302 | String otpCode = otpService.generateOtpCode(secretKey); 303 | return new OtpResponse2(otpCode); 304 | } 305 | 306 | // 生成 OTP 码 307 | @PostMapping("/generate-otp-batch") 308 | public ResponseEntity> generateOtpBatch(@RequestBody OtpBatchRequest request) { 309 | try { 310 | List otpResponses = otpService.generateOtpBatch(request.getSecretKeys()); 311 | return ResponseEntity.ok(otpResponses); 312 | } catch (Exception e) { 313 | log.error("Error generating OTP batch", e); 314 | return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); 315 | } 316 | } 317 | 318 | @PostMapping("/delete-key") 319 | @ResponseBody 320 | public OtpResponse2 deleteKey(@RequestBody Map payload) { 321 | String keyName = payload.get("keyName"); 322 | if (keyName == null || keyName.isEmpty()) { 323 | return new OtpResponse2("keyName is null"); 324 | } 325 | otpService.deleteKey(keyName); 326 | return new OtpResponse2("OK"); 327 | } 328 | 329 | 330 | @GetMapping("/export-data") 331 | public void exportToCSV(HttpServletResponse response) throws IOException { 332 | response.setContentType("text/csv"); 333 | response.setHeader("Content-Disposition", "attachment; filename=\"otp_keys.csv\""); 334 | PrintWriter writer = response.getWriter(); 335 | writer.println("Key Name,Issuer,Secret Key,Created Date"); 336 | 337 | List otpKeys = otpService.getAllKeys(); 338 | for (OTPKey otpKey : otpKeys) { 339 | writer.printf("%s,%s,%s,%s%n", otpKey.getKeyName(), otpKey.getIssuer(), otpKey.getSecretKey(), otpKey.getCreateTime()); 340 | } 341 | writer.flush(); 342 | } 343 | 344 | // 导入密钥 345 | @PostMapping("/import-keys") 346 | public String importKeys(@RequestParam("csvFile") MultipartFile csvFile) { 347 | try { 348 | if (csvFile.isEmpty()) { 349 | log.error("CSV file is empty"); 350 | return "redirect:/"; 351 | } 352 | 353 | String content = new String(csvFile.getBytes(), StandardCharsets.UTF_8); 354 | String[] lines = content.split("\n"); 355 | 356 | List otpKeysToImport = new ArrayList<>(); 357 | 358 | // 跳过标题行,从第二行开始处理 359 | for (int i = 1; i < lines.length; i++) { 360 | String line = lines[i].trim(); 361 | if (line.isEmpty()) continue; 362 | 363 | // 解析CSV行,处理可能的引号 364 | String[] parts = parseCSVLine(line); 365 | if (parts.length >= 3) { 366 | String keyName = parts[0].trim(); 367 | String issuer = parts[1].trim(); 368 | String secretKey = parts[2].trim(); 369 | 370 | if (!keyName.isEmpty() && !secretKey.isEmpty()) { 371 | // 检查是否已存在 372 | OTPKey existingKey = otpKeyRepository.findBySecretKey(secretKey); 373 | if (existingKey == null) { 374 | OTPKey otpKey = new OTPKey(keyName, secretKey); 375 | otpKey.setIssuer(issuer.isEmpty() ? "mfa-start" : issuer); 376 | String otpAuthUri = String.format("otpauth://totp/%s:%s?secret=%s&issuer=%s", 377 | otpKey.getIssuer(), otpKey.getKeyName(), otpKey.getSecretKey(), otpKey.getIssuer()); 378 | String qrCodeNew = qrCodeService.generateQRCodeImage(otpAuthUri); 379 | otpKey.setQrCode(qrCodeNew); 380 | otpKeysToImport.add(otpKey); 381 | } 382 | } 383 | } 384 | } 385 | 386 | if (!otpKeysToImport.isEmpty()) { 387 | otpService.saveListKey(otpKeysToImport); 388 | log.info("Successfully imported {} OTP keys", otpKeysToImport.size()); 389 | } 390 | 391 | return "redirect:/"; 392 | } catch (Exception e) { 393 | log.error("Error importing CSV file", e); 394 | return "redirect:/"; 395 | } 396 | } 397 | 398 | // 解析CSV行的辅助方法 399 | private String[] parseCSVLine(String line) { 400 | List result = new ArrayList<>(); 401 | boolean inQuotes = false; 402 | StringBuilder current = new StringBuilder(); 403 | 404 | for (int i = 0; i < line.length(); i++) { 405 | char c = line.charAt(i); 406 | if (c == '"') { 407 | inQuotes = !inQuotes; 408 | } else if (c == ',' && !inQuotes) { 409 | result.add(current.toString()); 410 | current = new StringBuilder(); 411 | } else { 412 | current.append(c); 413 | } 414 | } 415 | result.add(current.toString()); 416 | return result.toArray(new String[0]); 417 | } 418 | } 419 | -------------------------------------------------------------------------------- /src/main/resources/templates/settings.ftl: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | System Settings - OTP Management 7 | 8 | 9 | 275 | 276 | 277 |
278 | 279 | <#include "common/sidebar.ftl"> 280 | 281 |
282 |
283 |

同步设置

284 | 285 | 286 |
287 |
Alist 配置
288 |
289 |
290 | 297 |
298 | 299 |
300 | 301 | 304 | 输入你的Alist服务地址 305 |
306 | 307 |
308 | 309 | 312 | 你的Alist用户名 313 |
314 | 315 |
316 | 317 | 320 | 你的AList登录密码 321 |
322 | 323 |
324 | 325 | 328 | 备份文件存储路径 329 |
330 | 331 |
332 | 333 | 339 |
340 | 341 |
342 | 347 | 352 | 357 |
358 |
359 | 360 |
361 |
362 | 363 | 364 |
365 |
备份历史
366 |
367 | 368 | 369 | 370 | 371 | 372 | 373 | 374 | 375 | 376 | 377 | 378 | 379 |
时间状态详细信息备份大小
380 |
381 |
382 |
383 |
384 |
385 | 386 | 621 | 622 | -------------------------------------------------------------------------------- /src/main/resources/script/mfa-start.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # 配置变量 4 | BASE_DIR="/root/mfa-start" 5 | JAR_PATH="$BASE_DIR/mfa-start-release.jar" 6 | CONFIG_FILE="$BASE_DIR/mfa-start.yml" 7 | PASSWORD_FILE="$BASE_DIR/.mfa-password" 8 | 9 | # 下载链接配置 10 | JAR_DOWNLOAD_URL="https://github.com/doubleDimple/mfa-start/releases/latest/download/mfa-start-release.jar" 11 | CONFIG_DOWNLOAD_URL="https://raw.githubusercontent.com/doubleDimple/shell-tools/master/mfa-start.yml" 12 | 13 | # 创建基础目录 14 | create_base_dir() { 15 | if [ ! -d "$BASE_DIR" ]; then 16 | mkdir -p "$BASE_DIR" 17 | echo "Created directory: $BASE_DIR" 18 | fi 19 | } 20 | 21 | # 生成随机密码 22 | generate_password() { 23 | openssl rand -base64 12 | tr -d "=+/" | cut -c1-12 24 | } 25 | 26 | # 下载JAR包 27 | download_jar() { 28 | echo "Downloading latest JAR package..." 29 | if command -v wget >/dev/null 2>&1; then 30 | wget -O "$JAR_PATH.tmp" "$JAR_DOWNLOAD_URL" 31 | elif command -v curl >/dev/null 2>&1; then 32 | curl -L -o "$JAR_PATH.tmp" "$JAR_DOWNLOAD_URL" 33 | else 34 | echo "Error: Neither wget nor curl is available for downloading" 35 | return 1 36 | fi 37 | 38 | if [ $? -eq 0 ] && [ -f "$JAR_PATH.tmp" ]; then 39 | mv "$JAR_PATH.tmp" "$JAR_PATH" 40 | echo "JAR package downloaded successfully" 41 | return 0 42 | else 43 | echo "Error: Failed to download JAR package" 44 | rm -f "$JAR_PATH.tmp" 45 | return 1 46 | fi 47 | } 48 | 49 | # 下载配置文件模板 50 | download_config_template() { 51 | echo "Downloading configuration template..." 52 | if command -v wget >/dev/null 2>&1; then 53 | wget -O "$CONFIG_FILE.template" "$CONFIG_DOWNLOAD_URL" 54 | elif command -v curl >/dev/null 2>&1; then 55 | curl -L -o "$CONFIG_FILE.template" "$CONFIG_DOWNLOAD_URL" 56 | else 57 | echo "Error: Neither wget nor curl is available for downloading" 58 | return 1 59 | fi 60 | 61 | if [ $? -eq 0 ] && [ -f "$CONFIG_FILE.template" ]; then 62 | echo "Configuration template downloaded successfully" 63 | return 0 64 | else 65 | echo "Error: Failed to download configuration template" 66 | rm -f "$CONFIG_FILE.template" 67 | return 1 68 | fi 69 | } 70 | 71 | 72 | 73 | # 初始化配置文件(仅首次安装) 74 | init_config() { 75 | local username="mfa-start-user" 76 | local password 77 | local is_first_install=false 78 | 79 | # 检查是否是第一次安装(既没有密码文件也没有配置文件) 80 | if [ ! -f "$PASSWORD_FILE" ] && [ ! -f "$CONFIG_FILE" ]; then 81 | is_first_install=true 82 | password=$(generate_password) 83 | echo "$password" > "$PASSWORD_FILE" 84 | chmod 600 "$PASSWORD_FILE" 85 | echo "=========================================" 86 | echo "FIRST INSTALLATION - CREDENTIALS GENERATED" 87 | echo "=========================================" 88 | echo "Username: $username" 89 | echo "Password: $password" 90 | echo "=========================================" 91 | echo "Please save these credentials!" 92 | echo "Password also saved to: $PASSWORD_FILE" 93 | echo "=========================================" 94 | else 95 | # 如果有密码文件,读取现有密码 96 | if [ -f "$PASSWORD_FILE" ]; then 97 | password=$(cat "$PASSWORD_FILE") 98 | username=$(grep -A3 "security:" "$CONFIG_FILE" | grep "name:" | awk '{print $2}') 99 | if [ -z "$username" ]; then 100 | username="mfa-start-user" 101 | fi 102 | echo "Using existing credentials for user: $username" 103 | else 104 | # 如果只有配置文件没有密码文件,从配置文件读取密码 105 | if [ -f "$CONFIG_FILE" ]; then 106 | username=$(grep -A3 "security:" "$CONFIG_FILE" | grep "name:" | awk '{print $2}') 107 | password=$(grep -A4 "security:" "$CONFIG_FILE" | grep "password:" | awk '{print $2}') 108 | if [ -n "$password" ]; then 109 | echo "$password" > "$PASSWORD_FILE" 110 | chmod 600 "$PASSWORD_FILE" 111 | echo "Extracted existing password from config file" 112 | else 113 | echo "Error: Could not extract password from existing config file" 114 | exit 1 115 | fi 116 | fi 117 | fi 118 | fi 119 | 120 | # 创建或更新配置文件 121 | if [ -f "$CONFIG_FILE.template" ]; then 122 | # 读取模板内容并替换占位符 123 | cp "$CONFIG_FILE.template" "$CONFIG_FILE.tmp" 124 | 125 | # 替换用户名和密码占位符 126 | sed -i "s/{{USERNAME}}/$username/g" "$CONFIG_FILE.tmp" 127 | sed -i "s/{{PASSWORD}}/$password/g" "$CONFIG_FILE.tmp" 128 | 129 | # 如果模板中没有占位符,直接修改name和password字段 130 | sed -i "s/name:.*/name: $username/g" "$CONFIG_FILE.tmp" 131 | sed -i "s/password:.*/password: $password/g" "$CONFIG_FILE.tmp" 132 | 133 | mv "$CONFIG_FILE.tmp" "$CONFIG_FILE" 134 | rm -f "$CONFIG_FILE.template" 135 | echo "Configuration file created from template with credentials" 136 | else 137 | # 创建默认配置文件 138 | cat > "$CONFIG_FILE" << EOF 139 | server: 140 | port: 9999 141 | 142 | spring: 143 | security: 144 | user: 145 | name: $username 146 | password: $password 147 | 148 | logging: 149 | level: 150 | root: INFO 151 | EOF 152 | fi 153 | 154 | if [ "$is_first_install" = true ]; then 155 | echo "Configuration file created with generated credentials: $CONFIG_FILE" 156 | else 157 | echo "Configuration file updated: $CONFIG_FILE" 158 | fi 159 | } 160 | 161 | # 更新配置文件(仅更新模板,不修改用户名密码) 162 | update_config() { 163 | if [ ! -f "$CONFIG_FILE" ]; then 164 | echo "Configuration file not found, creating new one..." 165 | init_config 166 | return 167 | fi 168 | 169 | # 保存现有的用户名和密码 170 | local existing_username=$(grep -A3 "security:" "$CONFIG_FILE" | grep "name:" | awk '{print $2}') 171 | local existing_password=$(grep -A4 "security:" "$CONFIG_FILE" | grep "password:" | awk '{print $2}') 172 | 173 | if [ -z "$existing_username" ] || [ -z "$existing_password" ]; then 174 | echo "Warning: Could not extract existing credentials, keeping current config unchanged" 175 | return 176 | fi 177 | 178 | # 如果有新的配置模板,使用现有用户名密码更新 179 | if [ -f "$CONFIG_FILE.template" ]; then 180 | echo "Updating configuration with new template (preserving credentials)..." 181 | 182 | # 创建临时文件 183 | cp "$CONFIG_FILE.template" "$CONFIG_FILE.tmp" 184 | 185 | # 替换占位符或直接修改字段 186 | sed -i "s/{{USERNAME}}/$existing_username/g" "$CONFIG_FILE.tmp" 187 | sed -i "s/{{PASSWORD}}/$existing_password/g" "$CONFIG_FILE.tmp" 188 | sed -i "s/name:.*/name: $existing_username/g" "$CONFIG_FILE.tmp" 189 | sed -i "s/password:.*/password: $existing_password/g" "$CONFIG_FILE.tmp" 190 | 191 | # 替换配置文件 192 | if [ -f "$CONFIG_FILE.tmp" ]; then 193 | mv "$CONFIG_FILE.tmp" "$CONFIG_FILE" 194 | rm -f "$CONFIG_FILE.template" 195 | echo "Configuration updated with new template (credentials preserved)" 196 | 197 | # 更新密码文件 198 | echo "$existing_password" > "$PASSWORD_FILE" 199 | chmod 600 "$PASSWORD_FILE" 200 | else 201 | echo "Failed to create new configuration, keeping existing one" 202 | rm -f "$CONFIG_FILE.template" 203 | fi 204 | else 205 | echo "No configuration template found, keeping existing configuration" 206 | fi 207 | } 208 | 209 | # 检查文件是否存在,不存在则下载 210 | ensure_files() { 211 | create_base_dir 212 | 213 | # 先确保JAR文件存在 214 | if [ ! -f "$JAR_PATH" ]; then 215 | echo "JAR file not found, downloading..." 216 | download_jar || exit 1 217 | fi 218 | 219 | # 再检查配置文件 220 | if [ ! -f "$CONFIG_FILE" ]; then 221 | echo "Configuration file not found, initializing..." 222 | # 尝试下载配置模板 223 | download_config_template 224 | # 初始化配置(会生成用户名密码并写入配置文件) 225 | init_config 226 | else 227 | # 如果配置文件存在但密码文件不存在,从配置文件提取密码 228 | if [ ! -f "$PASSWORD_FILE" ]; then 229 | password=$(grep -A4 "security:" "$CONFIG_FILE" | grep "password:" | awk '{print $2}') 230 | if [ -n "$password" ]; then 231 | echo "$password" > "$PASSWORD_FILE" 232 | chmod 600 "$PASSWORD_FILE" 233 | echo "Password file created from existing config" 234 | fi 235 | fi 236 | fi 237 | } 238 | 239 | # 检查应用是否正在运行 240 | is_running() { 241 | pgrep -f "$JAR_PATH" > /dev/null 2>&1 242 | return $? 243 | } 244 | 245 | # 获取进程PID 246 | get_pid() { 247 | pgrep -f "$JAR_PATH" 2>/dev/null 248 | } 249 | 250 | # 启动应用 251 | start() { 252 | ensure_files 253 | 254 | # 检查应用是否已经在运行 255 | if is_running; then 256 | echo "Application is already running with PID: $(get_pid)" 257 | exit 0 258 | fi 259 | 260 | # 显示当前配置的用户信息 261 | if [ -f "$CONFIG_FILE" ]; then 262 | username=$(grep -A3 "security:" "$CONFIG_FILE" | grep "name:" | awk '{print $2}') 263 | echo "Starting application with user: $username" 264 | if [ -f "$PASSWORD_FILE" ]; then 265 | echo "Password can be found in: $PASSWORD_FILE" 266 | fi 267 | fi 268 | 269 | # 启动JAR包 270 | echo "Starting application..." 271 | nohup java -jar "$JAR_PATH" --spring.config.additional-location="$CONFIG_FILE" > /dev/null 2>&1 & 272 | 273 | # 等待一下检查启动是否成功 274 | sleep 3 275 | if is_running; then 276 | echo "Application started successfully! PID: $(get_pid)" 277 | else 278 | echo "Warning: Application may have failed to start." 279 | fi 280 | } 281 | 282 | # 停止应用 283 | stop() { 284 | if ! is_running; then 285 | echo "Application is not running." 286 | return 1 287 | fi 288 | 289 | local PID=$(get_pid) 290 | echo "Stopping application with PID: $PID" 291 | kill $PID 292 | 293 | # 等待进程优雅关闭 294 | local count=0 295 | while is_running && [ $count -lt 10 ]; do 296 | sleep 1 297 | count=$((count + 1)) 298 | done 299 | 300 | if is_running; then 301 | echo "Application did not stop gracefully, forcing shutdown..." 302 | kill -9 $PID 303 | sleep 1 304 | fi 305 | 306 | echo "Application stopped." 307 | } 308 | 309 | # 重启应用 310 | restart() { 311 | echo "Restarting application..." 312 | stop 313 | sleep 2 314 | start 315 | } 316 | 317 | # 更新JAR包和配置 318 | update() { 319 | echo "Updating application..." 320 | 321 | # 停止应用(如果正在运行) 322 | local was_running=false 323 | if is_running; then 324 | was_running=true 325 | echo "Stopping application for update..." 326 | stop 327 | fi 328 | 329 | # 备份当前JAR文件 330 | if [ -f "$JAR_PATH" ]; then 331 | cp "$JAR_PATH" "$JAR_PATH.backup.$(date +%Y%m%d_%H%M%S)" 332 | echo "Current JAR backed up" 333 | fi 334 | 335 | # 下载新的JAR包 336 | if download_jar; then 337 | echo "JAR package updated successfully" 338 | 339 | # 更新配置文件(保持用户名密码) 340 | download_config_template # 尝试下载新模板 341 | update_config 342 | 343 | # 清理备份文件(更新成功后) 344 | rm -f "$JAR_PATH.backup."* 345 | echo "Backup files cleaned up after successful update" 346 | 347 | # 如果之前在运行,则重新启动 348 | if [ "$was_running" = true ]; then 349 | echo "Restarting application with updated files..." 350 | start 351 | else 352 | echo "Update completed. Use 'start' to run the updated application." 353 | fi 354 | else 355 | echo "Update failed. Restoring backup if available..." 356 | # 恢复备份 357 | for backup_file in "$JAR_PATH.backup."*; do 358 | if [ -f "$backup_file" ]; then 359 | cp "$backup_file" "$JAR_PATH" 360 | echo "Backup restored: $backup_file" 361 | break 362 | fi 363 | done 364 | 365 | if [ "$was_running" = true ]; then 366 | echo "Restarting application with original files..." 367 | start 368 | fi 369 | exit 1 370 | fi 371 | } 372 | 373 | # 查看状态 374 | status() { 375 | if is_running; then 376 | local PID=$(get_pid) 377 | echo "Application is running with PID: $PID" 378 | if [ -f "$CONFIG_FILE" ]; then 379 | username=$(grep -A3 "security:" "$CONFIG_FILE" | grep "name:" | awk '{print $2}') 380 | echo "Username: $username" 381 | fi 382 | else 383 | echo "Application is not running." 384 | fi 385 | } 386 | 387 | # 显示密码 388 | show_password() { 389 | if [ -f "$CONFIG_FILE" ]; then 390 | username=$(grep -A3 "security:" "$CONFIG_FILE" | grep "name:" | awk '{print $2}') 391 | password="" 392 | 393 | # 优先从密码文件读取 394 | if [ -f "$PASSWORD_FILE" ]; then 395 | password=$(cat "$PASSWORD_FILE") 396 | else 397 | # 从配置文件读取 398 | password=$(grep -A4 "security:" "$CONFIG_FILE" | grep "password:" | awk '{print $2}') 399 | fi 400 | 401 | if [ -n "$username" ] && [ -n "$password" ]; then 402 | echo "=========================================" 403 | echo "CURRENT CREDENTIALS" 404 | echo "=========================================" 405 | echo "Username: $username" 406 | echo "Password: $password" 407 | echo "=========================================" 408 | echo "Password file: $PASSWORD_FILE" 409 | echo "=========================================" 410 | else 411 | echo "Error: Could not retrieve credentials from configuration" 412 | fi 413 | else 414 | echo "Error: Configuration file not found. Please run 'start' first." 415 | fi 416 | } 417 | 418 | # 修改用户名和密码 419 | change_credentials() { 420 | local new_username="$1" 421 | local new_password="$2" 422 | 423 | # 检查参数 424 | if [ -z "$new_username" ] && [ -z "$new_password" ]; then 425 | echo "Error: Please provide username and/or password" 426 | echo "Usage:" 427 | echo " $0 passwd - Change both username and password" 428 | echo " $0 passwd - Change only username" 429 | echo " $0 passwd \"\" - Change only password" 430 | exit 1 431 | fi 432 | 433 | # 检查配置文件是否存在 434 | if [ ! -f "$CONFIG_FILE" ]; then 435 | echo "Error: Configuration file not found. Please run 'start' first to initialize." 436 | exit 1 437 | fi 438 | 439 | # 获取当前凭据 440 | local current_username=$(grep -A3 "security:" "$CONFIG_FILE" | grep "name:" | awk '{print $2}') 441 | local current_password=$(grep -A4 "security:" "$CONFIG_FILE" | grep "password:" | awk '{print $2}') 442 | 443 | # 调试信息 444 | echo "Debug: Current username from config: '$current_username'" 445 | echo "Debug: New username parameter: '$new_username'" 446 | echo "Debug: New password parameter: '$new_password'" 447 | 448 | # 确定新的用户名和密码 449 | local final_username 450 | local final_password 451 | 452 | # 处理用户名 453 | if [ "$new_username" = "" ]; then 454 | # 空字符串表示保持当前用户名 455 | final_username="$current_username" 456 | echo "Keeping current username: $final_username" 457 | elif [ -z "$new_username" ]; then 458 | # 真正的空值(没有提供参数) 459 | final_username="$current_username" 460 | else 461 | # 提供了新用户名 462 | final_username="$new_username" 463 | fi 464 | 465 | # 处理密码 466 | if [ -z "$new_password" ]; then 467 | # 没有提供密码参数,保持当前密码 468 | final_password="$current_password" 469 | echo "Keeping current password" 470 | else 471 | # 提供了新密码 472 | final_password="$new_password" 473 | echo "Setting new password" 474 | fi 475 | 476 | # 如果最终密码仍为空,生成新密码 477 | if [ -z "$final_password" ]; then 478 | final_password=$(generate_password) 479 | echo "No password found, generated new password: $final_password" 480 | fi 481 | 482 | echo "" 483 | echo "Updating credentials..." 484 | echo "Old username: $current_username" 485 | echo "New username: $final_username" 486 | echo "Password will be updated" 487 | 488 | # 检查应用是否正在运行 489 | local was_running=false 490 | if is_running; then 491 | was_running=true 492 | echo "Application is running, will restart after credential update..." 493 | stop 494 | fi 495 | 496 | # 创建临时配置文件 497 | cp "$CONFIG_FILE" "$CONFIG_FILE.bak" 498 | 499 | # 使用更精确的sed命令更新配置文件 500 | # 先找到security部分,然后更新name和password 501 | awk -v user="$final_username" -v pass="$final_password" ' 502 | /^spring:/ { spring=1 } 503 | /^ security:/ && spring { security=1 } 504 | /^ user:/ && security { user_section=1 } 505 | /^ name:/ && user_section { 506 | print " name: " user 507 | next 508 | } 509 | /^ password:/ && user_section { 510 | print " password: " pass 511 | next 512 | } 513 | { print } 514 | ' "$CONFIG_FILE.bak" > "$CONFIG_FILE" 515 | 516 | # 验证更新是否成功 517 | local new_config_username=$(grep -A3 "security:" "$CONFIG_FILE" | grep "name:" | awk '{print $2}') 518 | local new_config_password=$(grep -A4 "security:" "$CONFIG_FILE" | grep "password:" | awk '{print $2}') 519 | 520 | if [ "$new_config_username" = "$final_username" ] && [ "$new_config_password" = "$final_password" ]; then 521 | echo "Configuration file updated successfully" 522 | rm -f "$CONFIG_FILE.bak" 523 | 524 | # 更新密码文件 525 | echo "$final_password" > "$PASSWORD_FILE" 526 | chmod 600 "$PASSWORD_FILE" 527 | 528 | echo "=========================================" 529 | echo "CREDENTIALS UPDATED SUCCESSFULLY" 530 | echo "=========================================" 531 | echo "Username: $final_username" 532 | echo "Password: $final_password" 533 | echo "=========================================" 534 | echo "Credentials saved to configuration file" 535 | echo "Password also saved to: $PASSWORD_FILE" 536 | echo "=========================================" 537 | else 538 | echo "Error: Failed to update configuration file" 539 | mv "$CONFIG_FILE.bak" "$CONFIG_FILE" 540 | exit 1 541 | fi 542 | 543 | # 如果之前在运行,重新启动应用 544 | if [ "$was_running" = true ]; then 545 | echo "Restarting application with new credentials..." 546 | start 547 | else 548 | echo "Credential update completed. Use 'start' to run the application." 549 | fi 550 | } 551 | 552 | # 显示帮助信息 553 | show_help() { 554 | echo "Usage: $0 {start|stop|restart|update|status|password|help}" 555 | echo "" 556 | echo "Commands:" 557 | echo " start - Start the application (auto-download if needed)" 558 | echo " stop - Stop the application" 559 | echo " restart - Restart the application" 560 | echo " update - Update JAR package and restart if running" 561 | echo " status - Show application status" 562 | echo " password - Show current username and password" 563 | echo " password - Change both username and password" 564 | echo " password - Change only username (keep current password)" 565 | echo " password \"\" - Change only password (keep current username)" 566 | echo " help - Show this help message" 567 | echo "" 568 | echo "Examples:" 569 | echo " $0 start - Start application" 570 | echo " $0 password - View credentials" 571 | echo " $0 password admin newpass123 - Set username to 'admin' and password to 'newpass123'" 572 | echo " $0 password newuser - Change username to 'newuser', keep current password" 573 | echo " $0 password \"\" secretpass - Keep current username, change password to 'secretpass'" 574 | echo " $0 update - Update to latest version" 575 | echo "" 576 | echo "First Run Features:" 577 | echo " - Create user 'mfa-start-user' with random password" 578 | echo " - Auto-download latest JAR and config files" 579 | echo " - Display generated login credentials" 580 | echo "" 581 | echo "File Locations:" 582 | echo " JAR File: $JAR_PATH" 583 | echo " Config File: $CONFIG_FILE" 584 | echo " Password File: $PASSWORD_FILE" 585 | echo "" 586 | echo "Notes:" 587 | echo " - Credential changes will restart the app (if running)" 588 | echo " - Update function preserves existing username and password" 589 | echo " - Backup files are automatically cleaned up after successful updates" 590 | } 591 | 592 | # 主命令处理 593 | case "$1" in 594 | start) 595 | start 596 | ;; 597 | stop) 598 | stop 599 | ;; 600 | restart) 601 | restart 602 | ;; 603 | update) 604 | update 605 | ;; 606 | status) 607 | status 608 | ;; 609 | password|passwd) 610 | if [ -n "$2" ]; then 611 | # 修改凭据模式 612 | change_credentials "$2" "$3" 613 | else 614 | # 显示凭据模式 615 | show_password 616 | fi 617 | ;; 618 | help) 619 | show_help 620 | ;; 621 | *) 622 | echo "Usage: $0 {start|stop|restart|update|status|password|help}" 623 | echo "" 624 | echo "Commands:" 625 | echo " start - Start the application (auto-download if needed)" 626 | echo " stop - Stop the application" 627 | echo " restart - Restart the application" 628 | echo " update - Update JAR package and restart if running" 629 | echo " status - Show application status" 630 | echo " password - Show current username and password" 631 | echo " password - Change both username and password" 632 | echo " password - Change only username (keep current password)" 633 | echo " password \"\" - Change only password (keep current username)" 634 | echo " help - Show this help message" 635 | echo "" 636 | echo "For more information, use: $0 help" 637 | exit 1 638 | ;; 639 | esac 640 | 641 | exit 0 -------------------------------------------------------------------------------- /src/main/resources/templates/index.ftl: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | OTP Key Management 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 467 | 468 | 469 |
470 | 471 | <#include "common/sidebar.ftl"> 472 | 473 |
474 |
475 |
476 |

密钥管理

477 |
478 | 482 | 486 | 490 |
491 |
492 | 493 | 494 | 495 | 496 | 497 | 498 | 499 | 500 | 501 | 502 | 503 | 504 | 505 | 506 | 507 | 508 | <#if otpKeys?? && (otpKeys?size > 0)> 509 | <#list otpKeys as otpKey> 510 | 511 | 512 | 513 | 514 | 521 | 531 | 532 | 535 | 536 | 537 | <#else> 538 | 539 | 540 | 541 | 542 | 543 |
密钥名称密钥信息密钥二维码验证码创建时间操作
${otpKey.keyName}${otpKey.issuer!'default'}****** 515 | <#if otpKey.qrCode??> 516 | QR Code 517 | <#else> 518 | 无二维码 519 | 520 | 522 | Loading... 523 |
524 |
30
525 | 526 | 527 | 528 | 529 |
530 |
${otpKey.formattedCreateTime!'default'} 533 | 534 |
暂无OTP密钥
544 |
545 |
546 |
547 | 548 | 549 | 590 | 591 | 592 | 623 | 624 | 1063 | 1064 | --------------------------------------------------------------------------------