鉴权模块-登录鉴权设计

场景:用户的角色不同,有不同的接口访问权限。

支持多种登录方式(用户名登录、手机号登录、ldap登录)

支持密码散列存储

支持黑名单

防暴力破解

api限流

支持高并发

需求分析:

  • 接口权限

    用户的角色属于用户属性 1对多 –> 数据库存储

    角色的权限属于角色的属性 1对多 –> 数据库存储

    权限对应的接口调用属于业务范围 1对多 –> 项目中进行配置

  • 多登录方式意味着多数据源

    用户名登录 需要存储用户名密码 –> 关系型数据库

    手机号登录 需要短信服务 –>付费开通短信服务

    Ldap登录 需要接入Ldap数据库

  • 黑名单:指定用户不予登录,

    黑名单的存储(缓存)

    黑名单有效期

  • 防暴力破解

    登录验证失败超过一定次数进入黑名单

  • api限流

    在单位时间内同一个用户调用同一个接口的次数是有限的

    要避免单位时间交界处的超频访问

技术选型:

  1. 关系型数据库及orm框架 -> mysql + mybatis
  2. 黑名单缓存使用redis -> 可配置自动过期
  3. 鉴权框架–> apache shiro ,轻量,支持多数据源
  4. 网关 netflix.zuul

方案设计:

登录设计

1、 首次访问需要登陆,客户端需提供用户名密码,由shiro进行用户名/密码认证,shiro获取当前用户信息,并生成token令牌设置到response的cookie中返回给客户端(具体实现上走controller),客户端保存cookie

2、 token令牌中携带uid信息,并进行数字签名防止被篡改,在TCP连接keeplive期间再次请求,服务器能从携带的token中获取到uid,不再需要登录,token需要设置有效期。

3、TCP断开后,再次访问同域名下的API,客户端会带上之前发放的Token,shiro会进行token认证,此次认证不需要客户端提供用户名密码,自动登录。认证成功,shiro会获取到当前的用户信息(token中有用户名)–具体实现上走Filter

鉴权设计

传统的三表结构

User表 用户有1到多种role

Role表 role有1到多个permission

Permission表

具体方案

关于用户系统

对于一个简单的用户系统(不考虑复杂的权限控制,只考虑最单一的“合法用户”的鉴定),其功能其实可以被拆的很简单:注册、登录、鉴权。

  • 注册:用户将用户名和密码交给服务器,并由服务器存储的过程。
  • 登录:用户将用户名和密码交给服务器,服务器鉴定是否正确的过程(在 Token鉴权系统中,这一步如果通过,会生成并返回 Token)。
  • 鉴权:用户将 Token 发送给服务器,服务器校验该 Token 是否合法的过程(不考虑复杂鉴权)。

安全问题

流程清楚了,我们就来分析一下问题。不考虑前端可能出现的网络抓包等问题,仅从服务器角度考虑,我们可能遇到的安全问题有以下几个:

  • 密码泄漏
  • 生成 Token 的 Secret Key(Salt)泄漏
  • Token 泄漏 / 伪造

归纳一下:我们要解决的最重要的安全问题,就是用户最机密的安全信息被泄漏或伪造。

鉴权设计实践

在我最近完成的产品上,为了规避这些问题,我们在关键步骤上进行了一些处理。整个鉴权系统依赖 Apache Shiro 框架;同时,在密码处理,Token 认证上,我们结合了一些自己的解决方案。

整个流程大致是这样的:(流程图软件到期了 TAT)

注册

img

登录

img

鉴权

img

关于密码加密存储与验证

密码是一定要进行加密存储的。用户系统最核心的数据表,就是包含用户名(ID)、加密后的密码、Salt 的表。Salt 的生成,我们使用了 Shiro 提供的随机字符串生成工具,与用户名连接后,再进行 MD5。然后使用 Salt 加密密码,然后同时保存 Salt 和加密后的密码。
当用户登录时,我们使用 Salt 对用户输入的密码进行加密,再尝试与存储的密码进行匹配。

关于 Token 方案(JWT Token)

使用 JWT Token 作为我们的 Token 方案。

1. JWT Token

JWT Token 的全称是 JSON Web Token。一个 JWT Token 由三部分构成:Header,Payload,Signature。Header 规定了 Token 使用的加密方式与 Token 的类型,Payload 是 Token 中包含的用户信息(用户名,过期时间等),Signature 是 Header 的 Base64 值 + Payload 的 Base64 值 + Secret Key 生成的字符串,再对该字符串使用 Header 中规定的散列方式(HS256 或 RS256)取散列值后得到的字符串。一个典型的 JWT Token 是这个样子的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ
// HEADER
{
"alg": "HS256",
"typ": "JWT"
}
// PAYLOAD
{
"sub": "1234567890",
"name": "John Doe",
"admin": true
}
// SIGNATURE
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret
)

Header、Payload 和 Signature 用 . 分隔。

验证 Token 的时候,我们只需要将前两段(即 Header 和 Payload 的 Base64)加上 Secret Key,然后按照 Header 规定的加密方式进行加密,将生成的字符串与第三段(Signature)比对即可。当然,Token 验证的实践上,不同的项目存在一些分歧:有些人会将生成的 Token 直接存在数据库(比如 Redis)里,然后通过 Query 的方式验证是否合法。这一点我们随后讨论。

一个 JWT Token 唯一不可见的部分,就是 Secret Key。它是保证这个 Token 合法且安全的唯一字段。拿不到 Secret Key ,就无法生成 Token,也无法验证 Token。这种 Token 机制很常见(HTTPS 的握手过程就类似这样,SSH 连接也是 - 私钥只有一方持有),难点在于,如何生成并管理 Secret Key。

2. Secret Key

首先,所有用户使用相同的 Secret Key 一定是不合理的。所以我们要解决的第一个问题是,如何为每一个用户生成唯一的 Secret Key ?

还记得刚才的 Salt 么?每个用户的 Salt 都是唯一的,我们使用 Salt ,但不直接使用 Salt 作为 Secret Key。我们使用 Salt + 加密后的密码,再取 MD5 值作为该用户的 Secret Key。每次鉴权前,我们通过这个方式生成 Secret Key,再使用 Secret Key 进行鉴权。

3. 在项目中使用

1
2
3
4
5
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>

安全性分析

整套系统的安全之处在于,我们没有将任何敏感信息本地化。假设一种最坏的情况:攻击者拿到了我们数据库的全部数据,他能做什么?

  • 获取密码:密码被加密了,而且每个用户使用不同的 Salt 进行加密,加密方法是自定义的,不知道加密方法的话难以破解。
  • 获取 Token:我们没有保存任何的 Token。
  • 获取 Secret Key:Secret Key 是算出来的,即便拿到了 Salt,不知道算法也无法直接得到 Secret Key。

我们避免了直接保存任何安全信息。攻击者拿到的数据,都无法被直接利用。即便尝试破解,代价也是巨大的。

关于 Redis

在我看到的一些实践中,有些项目喜欢使用 Redis 存储生成的 Token,从而简化鉴权流程,提升鉴权效率。这样做可以吗?

我咨询了一位业界专家,同时查阅了相关资料,我给出的答案是:可以,但是不合理,不推荐。

避免用 Redis 直接存储 Token

还记得我们安全性分析的前提么:如果攻击者拿到了我们数据库的全部数据,他能做什么?

将 Token 保存在 Redis 中,一定是有风险的。如果服务器被攻破,用户 Token 泄漏的话,在规定的过期时间内,这些被泄漏的 Token 将会使用户账户变得非常危险。

当然,如果系统运行在内网环境,或者系统本身对用户安全的要求不高,这种方案从某种程度上讲,确实可以提升鉴权效率,简化鉴权流程。但是鉴于其可能存在的安全问题,不推荐。

可以用 Redis 缓存 Salt

在我们的产品设计中,我们使用 Salt 计算 Secret Key,然后再进行 Token 认证。我们可以在用户登录时把 Salt 缓存到 Redis 中以提升查询效率。

进一步优化

使用 Payload 生成 Secret Key

现在,整个系统的安全性基本可靠了。但是,仔细分析系统的设计,还是有一点问题:每次鉴权都需要去查询 Salt,I/O 开销比较大。这恰恰也是有些人使用 Redis 的原因之一 —— 提升查询速度。

仔细分析一下,我们用 Salt 当做了生成 Secret Key 的 Seed ,目的在于保证 Secret Key 唯一,同时不直接存储 Secret Key 。但其实,保持 Secret Key 唯一的方式有很多,不一定要通过 Salt 。实际上只有登录操作必须依赖 Salt,鉴权操作完全可以使用别的机制。

我们可以使用 JWT 的 Payload 中的某些字段,通过特定算法生成 Secret Key。比如:有效期时间戳 + 用户名,再取 SHA256 散列值(当然可以更复杂,不过要注意性能开销)。因为生成 Secret Key 的算法是不透明的,所以 Secret Key 也是相对安全的。

如果对把生成 Token 的信息放在 Payload 中心存顾虑的话,我们可以在服务器上通过静态配置文件的方式设置固定的 Secret Salt ,配合 Payload 生成 Secret Key。

通过这样的方式,我们可以避免在鉴权阶段对数据库进行访问,提升响应效率。我们也可以利用 Secret Salt 进行细粒度的权限角色划分,在此就不赘述了。

更标准的密码加密模式

关于密码加密等方式,我的老师给了我一些建议:可以使用 Blowfish 算法进行对称加密。这样的加密更标准,更安全。

JWT Token 与前端

JWT Token 应该放在哪

官方建议使用 Bearer 的模式,即:

1
Authorization: Bearer <token>

合理使用 Payload,避免 Token 过长

JWT Token 是有 Payload 的,这从一定程度上会造成 Payload 滥用。我在 Chrome 上遇到一个奇怪的 Bug:如果 Authorization 过长,Chrome 传递这个字段的时候会发生截断。我们的产品刚开始研发的时候,过度依赖 JWT 的 Payload 传递用户基本信息(用户名、所属用户组、邮箱等),造成 Token 长度非常长。后来对 Token 进行了几次瘦身,才避免了 Chrome 上的 Bug。

前端真的需要依赖 Payload 吗?

答案是否定的。前端并不关心,也不应该关心 Token 的 Payload 是什么,真正使用 Payload 的应该是后端。前端获取用户信息的方式,应当是在用户登录的时候,由服务器作为 HTTP Response 回传,并使用 Cookie / Local Storage / Session Storage 进行持久化存储,而不是通过解析 Token 的 Payload 获得。

开发遇到的问题

问题描述:

在网关中实现鉴权,考虑到分布式环境可能不止部署一台网关服务器,因此服务器不记录会话信息(为了实现高可用)。因此首次登录之后发放accessToken,再次请求网关会携带这个token

问题是,再次请求网关时,网关并不知道该请求的客户端已经登录了,因此无法获取权限信息,通过url过滤实现鉴权时,会直接跳转到登录页面

这个问题的主要原因是一个url不能经过两个过滤器,然后我将token登录过滤器的逻辑集成到鉴权过滤器中,在鉴权之前先使用token登录

新问题:token登录之后principal关联到accessTokenRealm,鉴权时,shiro还是会去sqlRealm中拿权限信息,然后当然拿不到,报异常。

异常原因:sqlRealm中获取user的语句是这样写的:

1
User user = (User) principals.fromRealm(this.getClass().getName()).iterator().next();

fromRealm方法返回一个集合,当集合为空时,获取迭代器执行next操作就会报错,编码不严谨造成的低级错误,正确的做法是应该先对集合判空

1
2
3
4
5
6
7
User user;
Collection users = principalCollection.fromRealm(this.getClass().getName());
if(users.isEmpty()){
return null ;
} else{
user = (User)users.iterator().next();
}

在作出以上两点修改之后,分布式网关的鉴权功能基本实现了,接下来进行优化。

shrio默认使用的是PermissionsAuthorizationFilter来进行鉴权,我上面的做法是

1
2
3
4
5
6
7
8
9
10
11
12
public class AccessTokenAuthorizedFilter extends PermissionsAuthorizationFilter {
private String[] perms;
private final AccessTokenLoginFilter accessTokenLoginFilter = new AccessTokenLoginFilter();
public AccessTokenAuthorizedFilter(String[] perms){
this.perms = perms;
}
@Override
public boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) throws IOException {
accessTokenLoginFilter.executeLogin(request,response);
return super.isAccessAllowed(request,response,perms);
}
}

这样带来的缺点是不能像PermissionsAuthorizationFilter那样优雅的配置过滤器了。

1
2
3
4
5
6
7
8
9
10
ShiroFilterFactoryBean factoryBean = new ShiroFilterFactoryBean();
Map<String, Filter> filterMap = new HashMap<>(16);
filterMap.put("permedit",new AccessTokenAuthorizedFilter(new String[]{"edit"}));
factoryBean.setFilters(filterMap);
factoryBean.setSecurityManager(manager);
LinkedHashMap<String,String> filterRuleMap = new LinkedHashMap<String, String>();
//拥有edit权限
filterRuleMap.put("/user-provider/user/edit","permedit");
factoryBean.setFilterChainDefinitionMap(filterRuleMap);
return factoryBean;

接下来的优化就是看能不能有更好的设计方式,能像原生的shiro那样优雅的配置

1
filterRuleMap.put("/user-provider/user/edit","perms[edit]");

参考链接

鉴权模块-登录鉴权设计 | 黑风雅过吟 (zzkenyon.github.io)