第一章:项目背景与痛点

我正在负责的是一款居家健康监测 SaaS 平台,面向慢病管理场景。平台的用户角色非常多样:
1:医护人员 / 健康助理

        通过 Web 后台查看患者健康数据、生成健康报告、管理随访记录
2:患者(老人):

        通过 App 端每日上报血压、血糖、血氧等生命体征
3:患者家属:

.        通过 App 端远程查看家人的健康数据


多端、多角色的业务形态,决定了登录需求天然多样化:Web 后台用账号密码登录、App 端老人不便输入密码时用人脸识别登录、年轻人用指纹快速解锁、忘记密码时走短信验证码流程,

多端、多角色的业务形态,决定了登录需求天然多样化:Web 后台用账号密码登录、App 端老人不便输入密码时用人脸识别登录、年轻人用指纹快速解锁、忘记密码时走短信验证码流程

痛点一:登录方式多样,扩展难维护

最传统的实现方式是堆 if-else:

if ("password".equals(grantType)) { /* 密码登录 */ }
else if ("sms".equals(grantType)) { /* 短信登录 */ }
else if ("face".equals(grantType)) { /* 人脸识别 */ }
// ... 每加一种登录方式,改一次

痛点二:交付后的责任归属说不清

项目交付后,线上版本是否原厂交付、是否被第三方修改过,常常难以追溯。因此需要一套"原厂证据链",出问题能一验就知道版本归属。

解决思路

针对这两个痛点,我在项目中做了两件事:
认证层面:用策略模式统一多种登录方式的分发,新增登录方式只需 +1 个类,原有代码零改动
交付层面:用 git 构建指纹 + SHA256 哈希清单 + 数字签名,构建一套"原厂证据链"(补充数字签名可由个人选择,这样当出现公司拿本人的账号生成的也不是代表本人操作),出问题能一验就知道锅该谁背


第二章:认证中心整体架构

独立微服务设计
认证中心作为独立的微服务 xxxx-auth,不依赖任何业务服务,只通过 Feign 调用用户服务(xxxx-user-api)获取账号信息。这样设计的好处:
认证服务可以独立部署、独立扩缩容
业务服务不需要感知登录细节,只信任网关传来的 JWT
认证逻辑的变更不会影响业务服务

整体架构如下:

用户(Web/App)
       ↓
   Spring Cloud Gateway(端口 80)
       ↓ JWT 鉴权
   yining-auth(认证中心)  ←→  Redis(验证码/token黑名单)
       ↓ Feign
   yining-system(用户服务)

技术选型:

组件选型用途
整体框架stringboot3.2基座框架
JWT库jjwt 0.11.2令牌生成与解析
缓存Redis验证码、token 黑名单、登录失败计数
短信服务某短信服务短信验证码
国密算法SM2人脸识别/指纹的生物特征签名验证
对称加密AES手机号脱敏传输
RSA 加密RSA密码前端加密传输
图形验证码图形验证码组件防机器人
对象存储对象存储组件(列如:MinIo等等)文件上传/下载/分片/断点续传/元数据管理
服务发现服务发现组件服务注册 + 配置中心

认证中心模块结构:

xxxxx-auth/src/main/java/org/xxx/auth/
├── granter/          # 核心:6 种登录授权器(策略模式)
├── controller/       # 接口层:AuthController(登录/登出/验证码)
├── config/           # 配置:RSA 公钥、验证码、某云短信
├── utils/            # 工具:TokenUtil、LoginUtil、ValidateCodeUtils
└── properties/       # 配置属性类

第三章:核心设计 — 策略模式分发登录

统一接口:ITokenGranter

所有登录方式都实现同一个接口,对外暴露唯一的 grant 方法:

// xxxx-auth/src/main/java/org/xxxxx/auth/granter/ITokenGranter.java
public interface ITokenGranter {

    /**
     * 获取用户信息
     *
     * @param tokenParameter 授权参数
     * @return UserInfo
     */
    UserInfo grant(TokenParameter tokenParameter) throws Exception;
}

不管密码登录、短信登录还是人脸识别,对上层都是"传入参数,返回用户信息"。上层完全不需要知道底层认证逻辑的差异。

策略池:TokenGranterBuilder

用一个 ConcurrentHashMap 作为策略池,静态初始化块注册所有登录方式:

// xxxx-auth/src/main/java/org/xxxxxx/auth/granter/TokenGranterBuilder.java
@AllArgsConstructor
public class TokenGranterBuilder {

    /**
     * TokenGranter缓存池
     */
    private static final Map<String, ITokenGranter> GRANTER_POOL = new ConcurrentHashMap<>();

    static {
        GRANTER_POOL.put(PasswordTokenGranter.GRANT_TYPE, SpringUtil.getBean(PasswordTokenGranter.class));
        GRANTER_POOL.put(SmsCodeTokenGranter.GRANT_TYPE, SpringUtil.getBean(SmsCodeTokenGranter.class));
        GRANTER_POOL.put(RefreshTokenGranter.GRANT_TYPE, SpringUtil.getBean(RefreshTokenGranter.class));
        GRANTER_POOL.put(FaceRecogTokenGranter.GRANT_TYPE, SpringUtil.getBean(FaceRecogTokenGranter.class));
        GRANTER_POOL.put(FingerprintTokenGranter.GRANT_TYPE, SpringUtil.getBean(FingerprintTokenGranter.class));
    }

    /**
     * 获取TokenGranter
     */
    public static ITokenGranter getGranter(String grantType) {
        ITokenGranter tokenGranter = GRANTER_POOL.get(
            Func.toStr(grantType, PasswordTokenGranter.GRANT_TYPE));
        if (tokenGranter == null) {
            throw new SecureException(ExceptionConstant.NOT_FOUND_GRANTTYPE);
        }
        return tokenGranter;
    }
}

每个 Grantor 都有一个 GRANT_TYPE 常量(如 "password"、"face_recog"、"fingerprint"),作为 Map 的 key。

分发入口:AuthController.login

// xxxx-auth/src/main/java/org/xxxxx/auth/controller/AuthController.java
@PostMapping("login")
public R<AuthInfo> login(@RequestParam(defaultValue = "password") String grant_type,
                         @RequestParam(required = false) String account,
                         @RequestParam(required = false) String password,
                         @RequestParam(required = false) String sms_code,
                         @RequestParam(required = false) String phone_device_id) throws Exception {

    String userType = Func.toStr(WebUtil.getRequest().getHeader(TokenUtil.USER_TYPE_HEADER_KEY),
                                 CommonConstant.DEFAULT_USER_TYPE);

    TokenParameter tokenParameter = new TokenParameter();
    tokenParameter.getArgs()
        .set("account", account)
        .set("smsCode", sms_code)
        .set("password", password)
        .set("userType", userType)
        .set("grantType", grant_type)
        .set("phoneDeviceId", phone_device_id);

    // 查看账号是否在黑名单中
    if (redisUtil.hasKey(CacheNames.ACCOUNT_LOGIN_BLACK + account)) {
        Long remainTime = redisUtil.getExpire(CacheNames.ACCOUNT_LOGIN_BLACK + account) / 60;
        return R.fail("账号失败次数已超过5次,请" + remainTime + "分钟后再试");
    }

    // ★ 核心分发:根据 grant_type 拿到对应的 Grantor
    ITokenGranter granter = TokenGranterBuilder.getGranter(grant_type);
    UserInfo userInfo = granter.grant(tokenParameter);

    // 后续:根据 userType(web/app)走不同的 token 生成逻辑
    // ...
}

整个流程 :

前端传 grant_type 参数("password" / "sms" / "face_recog" / "fingerprint" / "refresh_token")
先查账号黑名单(登录失败 5 次会被锁)
一行代码分发:TokenGranterBuilder.getGranter(grant_type) 拿到对应 Grantor
调用 grant(tokenParameter) 拿到用户信息
后续根据 userType 走 Web 登录或 App 登录的 token 生成逻辑

开闭原则的体现:

新增一种登录方式,只需要做两步:
新建一个类实现 ITokenGranter 接口
在 TokenGranterBuilder 的静态块里注册
原有的 AuthController、其他 Grantor 完全不用改。这正是开闭原则(对扩展开放,对修改关闭)的典型应用。
项目中预留的 SocialTokenGranter(社交登录)就是例子——类已经定义,只是 Builder 注册那行被注释了,未来要启用只需取消注释

第四章:交付完整性基线 — 防篡改 + 防甩锅

1.痛点:版本归属难以追溯

实际项目中,交付后的版本归属常常难以追溯——线上运行的版本是否原厂交付、是否被第三方修改过,往往说不清。因此需要一套"原厂证据链",出问题能一验就知道版本归属。

2.构建期:Git 指纹注入

在根 pom.xml 里加 git-commit-id-plugin 插件,构建期把 git 状态打进每个 jar 的 git.properties

<!-- 根 pom.xml -->
<plugin>
    <groupId>pl.project13.maven</groupId>
    <artifactId>git-commit-id-plugin</artifactId>
    <version>4.9.10</version>
    <executions>
        <execution>
            <id>get-the-git-infos</id>
            <goals><goal>revision</goal></goals>
            <phase>initialize</phase>
        </execution>
    </executions>
    <configuration>
        <skipPoms>true</skipPoms>
        <includeOnlyProperties>
            <includeOnlyProperty>^git.branch$</includeOnlyProperty>
            <includeOnlyProperty>^git.build.time$</includeOnlyProperty>
            <includeOnlyProperty>^git.commit.id.abbrev$</includeOnlyProperty>
            <includeOnlyProperty>^git.dirty$</includeOnlyProperty>
        </includeOnlyProperties>
    </configuration>
</plugin>

构建完成后,每个 jar 的 target/classes/git.properties 内容如下:

#Generated by Git-Commit-Id-Plugin
git.branch=feature/t0
git.build.time=2026-08-24T16\:56\:49+0800
git.commit.id.abbrev=22c3707
git.dirty=true

作用:jar 内任何字节变化(反编译改 class、改配置、加内容后重打)都会导致哈希变化,git.properties 能追溯到具体 commit。
局限性:git.properties 只做版本追溯,防不了 git 账号被冒用——公司申请的 git 账号离职后可被冒充提交/构建,commit author 仍指向原负责人(建议项目负责人补充数字签名,以增强基线的不可伪造性。)。

3.打包期:SHA256 哈希清单

在 deploy/xxx_package.sh(Windows 对应 xxxx_package.bat)末尾加一步:遍历 6 个模块 jar,算 SHA-256 写入 integrity-manifest-{profile}.txt:

# deploy/xxxx_package.sh
function xxxx_generate_integrity_manifest() {
    cd "${project_directory}/target"
    manifest="integrity-manifest-${profile}.txt"
    : > "${manifest}"
    if command -v sha256sum >/dev/null 2>&1; then
        hash_cmd="sha256sum"
    else
        hash_cmd="shasum -a 256"
    fi
    for jar_dir in xxx-auth xxx-gateway xxx-i18n xxx-log xxx-ossfile xxx-system; do
        jar_file="${jar_dir}/${jar_dir}.jar"
        if [ -f "${jar_file}" ]; then
            ${hash_cmd} "${jar_file}" >> "${manifest}"
        fi
    done
    echo "完整性清单已生成: ${project_directory}/target/${manifest}"
}

生成的 integrity-manifest-prod.txt 内容示例:

73A91194D5A311A4872C74EAA86CFA7579A3FACBC5E1F86496E9F3B1C687F2E3  target\xxx-auth\xxx-auth.jar
5908388441EF2659CE9B06145234C7C376A674EA3098593A99F88631C9373E7D  target\xxx-gateway\xxx-gateway.jar
17834376461A25EADB834BBFBA23676B61DED01F6DDAAA65E8F0E68766D61871  target\xxx-i18n\xxx-i18n.jar
B67F06C8642DE504E24DA6F9F715FA7B79CA947F4934503BA89177BE3AF4C861  target\xxx-log\xxx-log.jar
E5414D1ED49065212F72491CB56CB5FFDFADB22BA256E706010DA8B3F01BDE21  target\xxx-ossfile\xxx-ossfile.jar
26FFDE5DE88143F89BCA37D92A07F6E1C253ED7C97DDF0FE6C859F1C1926E7D5  target\xxx-system\xxx-system.jar

4.签名:私钥保护基线真实性

希清单只解决了"jar 字节是否被改",但防不了基线本身被伪造——如果接手方用你的 git 账号重新打包,生成一份新的 manifest,哈希也能对上。
更隐蔽的风险:git 账号是公司申请的,离职后别人可以用你的账号提交/构建,commit author 仍指向你。
解决方案:用私钥数字签名保护基线。

用 openssl 生成 RSA 密钥对(只做一次):

# 生成私钥(4096 位 RSA)
openssl genrsa -out integrity_private.key 4096

# 从私钥导出公钥
openssl rsa -in integrity_private.key -pubout -out integrity_public.key

打包后用私钥签名 manifest:

openssl dgst -sha256 -sign integrity_private.key \
  -out integrity-manifest-prod.txt.sig \
  integrity-manifest-prod.txt

校验时用公钥验签(任何人能做,公钥公开):

openssl dgst -sha256 -verify integrity_public.key \
  -signature integrity-manifest-prod.txt.sig \
  integrity-manifest-prod.txt

预期输出(签名有效):Verified OK;预期输出(签名无效/伪造):Verification Failure。
关键:私钥离线保存(U盘/个人加密盘),绝不提交 git、不放公司可碰的位置。脚本被拿走没用,没有私钥签不出东西

5.校验:外部可信方本地运行

校验脚本建议部署到服务器
如果 integrity_check.sh + 基线文件 + diff 日志都放在服务器上,接手方只要想甩锅,就能:
改完 jar 后跑一遍 baseline 子命令重生成基线覆盖 → 校验永远"一致"
或直接改校验脚本让它恒返回 PASS
或删掉 integrity_diff.log 销毁证据
校验必须从"外部可信方"(你的本地电脑)发起,服务器只被远程读取哈希,不留任何痕迹。
校验脚本通过 ssh 远程执行 docker exec sha256sum /app.jar 取线上 jar 哈希,拉回本地跟基线比对:

# 本地执行(不部署服务器)
./integrity_check.sh prod user@服务器IP

预期输出:

=== 交付完整性校验(本地外部可信方)  ... ===
[1/2] 验证基线签名...
  PASS 基线签名有效(原厂基线,未被伪造)
[2/2] 远程校验容器内 jar...
基线哈希(前16位)        | 线上哈希(前16位)        | 容器          | 结果
--------------------------|--------------------------|---------------|----------
73A91194D5A311A4...      | 73A91194D5A311A4...       | xxx-auth   | PASS
5908388441EF2659...      | 5908388441EF2659...       | xxx-gateway| PASS
...
结论: 全部一致,jar 未被篡改

6.真实价值:防甩锅、防篡改证据链

这套不是"防篡改监控",是"防甩锅、防篡改证据链"——出问题被扣锅时,你手里攥着"原厂签名基线 + 构建指纹",一比对就知道锅该谁背,是否是被客户找第三方改动过

场景一:交付后冒出 10 个 bug 怪原负责人。跑校验,若线上哈希 ≠ 基线 → jar 被接手的人改过,新 bug 是他们改的,不是你的锅。
 

更多推荐