所需组件
- 核心模块 alg-core-starter
- 安全模块 alg-security-starter
背景
安全模块基于Spring Security和JWT令牌提供如下功能:
- 用户认证与令牌组装发放
- 令牌验证和会话管理
- 后端交易访问控制
当项目加入了安全模块后,框架会自动提供一个用于登录的Controller实现,该Controller会在配置的url(默认是"/signin")上监听用户登录请求
除默认提供的用于登录的url外,其他所有后端访问路径都将进行令牌校验,即检查呼入的http请求的header中的token属性中是否存在合法的JWT令牌.如果未携带合法的JWT令牌,交易将报错返回类似如下信息:
{
"errorCode": "alg.security.insufficientToken",
"errorMsg": "无令牌校验信息,访问拒绝",
"traceId": "39c6474ea93401a8/39c6474ea93401a8"
}
JWT背景介绍
JWT全称JSON Web Token
一个合法的JWT令牌是一个进行编码签名后的字符串,例如:
eyJhbGciOiJIUzUxMiJ9.eyJwYXlsb2FkIjp7InVzZXJuYW1lIjoidGVzdCIsImdlbmRlciI6InRlc3QiLCJhZ2UiOjEyM30sInJvbGVzIjpudWxsLCJleHAiOjE1NTIwNDQ5MzUsInVzZXJuYW1lIjoiam9lIn0.y6h2JvhE5ExPejzlm3-8GU60WY772v2aZAg9FzXXeMOgpJXGN-ljS23X5QowqXqr1l-cwJGCagf58kuKkECg-A
字符串可以通过.分为三部分:
- Header 头部,存放签名算法类型,供验证令牌时使用
- Payload 负载,用于存放扩展性数据
- Signature 签名,使用Header中的算法对Header和Payload的内容进行签名
Header和Payload都可以通过BASE64解码后得到明文,Signature部分对这两部分数据进行了签名,保证了不可篡改性
验证令牌时,通过读取Header头部得到签名算法类型,使用该签名算法和保存的签名密钥字符串对令牌Header和Payload部分进行签名和Signature部分进行比对,比对一致则认为该令牌合法,然后根据Payload部分解码得到的exp字段判断令牌过期时间是否已到,如果没有过期,则验证通过
使用JWT令牌时应注意以下几点: 1. JWT令牌的信息是明文的,因此不应在令牌中存储敏感信息 2. JWT令牌的Payload字段可以扩展保存认证用户信息,合理使用可以减少请求接入时从数据库/缓存读取用户会话信息的开销,对于SCALE-OUT系统设计非常有利,这也是JWT令牌技术最大的竞争力所在 3. JWT令牌的Payload字段不宜过大,绝大部分Web Server对于请求头部的尺寸要求小于8KB,因此JWT令牌的体积需要加以注意 4. 框架默认会要求请求时将令牌放在请求头部的token字段中(可通过配置修改),此时如果是跨域请求,需要在服务端跨域配置中允许HTTP请求头部中包含token字段,可以通过加入跨域模块alg-cors-starter实现,该模块默认允许头部包含token字段
用户认证与令牌组装发放
本功能主要用于判定合法用户的登录行为,验证通过组装发放令牌.
为实现上述功能,应用编写者仅需编写登录判定类对象(接口com.ccb.alg.security.signin.ILoginAuthenticator的实现类)即可.一个典型的ILoginAuthenticator实现举例如下:
@Component
@Slf4j
public class MyAuthenticator implements ILoginAuthenticator<Map<String,String>,UserInfo> {
@Override
public AuthenticateResult<UserInfo> doAuthenticate(final String userId, final Map<String, String> credentialMap) throws LoginAuthenticateFailedException {
final String password = credentialMap.get("password");
final String verificationCode = credentialMap.get("verificationCode");
log.info(String.format("输入的密码是%s,验证码是%s",password,verificationCode));
//credentialMap可以根据输入的请求中的credential对象自行添加字段
//....省略读取数据库/缓存,根据用户userId验证输入的密码/验证码是否正确的逻辑
return new MyAuthenticateResult(userId,"admin,normal",new UserInfo("fooUserName","fooUserAge"));
}
}
框架默认实现了登录Controller,该对象有如下逻辑:
- 默认监听"/signin"地址的post请求
- 如果在Spring上下文中存在接口com.ccb.alg.security.signin.ILoginAuthenticator的实现类(默认不存在),则调用该对象进行用户认证.
- 如果在上下文中找不到上述对象,则通过所有验证,直接发放令牌,但会在日志中打印警告信息
默认Controller接收类似如下的POST登录请求,HTTP BODY内容举例如下:
{
"userId":"joe",
"credential":{"password":"qwer","verificationCode":"1234"}
}
其中userId是登录的用户唯一标识,credential为自定义扩展类型,可以自行添加字段和内容.在默认Controller实现下,会将该字段视为一个Map
ILoginAuthenticator接口为泛型接口,其类声明如下:
public interface ILoginAuthenticator<Credential,Payload>
泛型Credential表示封装了用户验证信息(密码/图形验证码/短信验证码等)的类.
泛型Payload表示封装了用户账户信息的类(例如MyAuthenticator例子中的UserInfo类).例如上例中UserInfo类的内容为:
public class UserInfo {
private String username;
private String age;
//省略getter/setter
}
泛型Payload将会作为JWT令牌Payload部分的一部分参与生成令牌(可通过配置关闭),并作为"/signin"登录请求的返回值返回给调用者.一个典型的"/signin"登录请求返回报文如下:
{
"token": "eyJhbGciOiJIUzUxMiJ9.eyJwYXlsb2FkIjp7InVzZXJuYW1lIjoiZm9vVXNlck5hbWUiLCJhZ2UiOiJmb29Vc2VyQWdlIn0sInJvbGVzIjoiYWRtaW4sbm9ybWFsIiwiZXhwIjoxNTUyNTQ5ODUxLCJ1c2VybmFtZSI6ImpvZSJ9.fJbU--8rr-FbO3kHvtzE4W2Til8GgNrxrtyxLO_ZykyLkE7UeeGFXxfnEbe_nTkvWhcUueA02DQUj9gugPsGDA",
"payload": {
"username": "fooUserName",
"age": "fooUserAge"
}
}
可以看到payload字段中的内容,正是doAuthenticate方法中UserInfo对象的内容
生成后的令牌在验证时将会把上述泛型Payload的内容在令牌验证通过后放到数据交换区的AlgSecurityConsts.JWT_PAYLOAD_KEY_PAYLOAD常量中,因此在处理请求时可以通过类似如下代码获取当前用户信息:
final Map userInfo = DataSwapArea.getValue(AlgSecurityConsts.JWT_PAYLOAD_KEY_PAYLOAD, Map.class);
final String username = (String) userInfo.get("username");
final String age = (String) userInfo.get("age");
上述例子的完整代码如下(使用了lombok):
@Component
@Slf4j
public class MyAuthenticator implements ILoginAuthenticator<Map<String,String>,UserInfo> {
@Override
public AuthenticateResult<UserInfo> doAuthenticate(final String userId, final Map<String, String> credentialMap) throws LoginAuthenticateFailedException {
final String password = credentialMap.get("password");
final String verificationCode = credentialMap.get("verificationCode");
log.info(String.format("输入的密码是%s,验证码是%s",password,verificationCode));
//credentialMap可以根据输入的请求中的credential对象自行添加字段
//....省略读取数据库/缓存,根据用户userId验证输入的密码/验证码是否正确的逻辑
return new MyAuthenticateResult(userId,"ROLE_admin,ROLE_normal",new UserInfo("fooUserName","fooUserAge"));
}
}
@AllArgsConstructor
@NoArgsConstructor
public class MyAuthenticateResult implements AuthenticateResult<UserInfo> {
private String userId;
private String roles;
private UserInfo userInfo;
@Override
public String getUserId() {
return userId;
}
@Override
public String getRoles() {
return roles;
}
@Override
public UserInfo getPayload() {
return userInfo;
}
}
@Data
@AllArgsConstructor
@NoArgsConstructor
public class UserInfo {
private String username;
private String age;
}
后端访问控制
加入安全模块后,除默认的登录路径"/signin"外其他交易都会进行JWT令牌验证,即读取HTTP请求头部的token字段(可通过配置修改),未读到令牌信息或者令牌校验失败都将拒绝访问请求.
安全模块还提供了配置化的基于角色的交易访问控制,可以通过配置文件和用户角色信息拒绝/允许前端请求,实现完整的安全访问逻辑.
具体来说, 在src/resources路径下建立accessRule.properties(如果是通过生成器生成的项目,默认即已建立该文件),可以通过如下语法配置访问控制规则:
| 表达式 | 作用 |
|---|---|
| hasRole([role]) | 有令牌且只有拥有指定角色的用户才能访问 |
| hasAnyRole([role1,role2]) | 有令牌且只有拥有任意角色的用户才能访问 |
| permitAll | 无令牌也可以访问 |
| denyAll | 有令牌也不能访问 |
假设应用有三种角色:admin,normal,superAdmin,同时应用有/url1,/url2,/url3,/url4,四只交易
accessRule.properties文件配置如下:
/url1=permitAll
/url2=hasRole('admin')
/url3=hasAnyRole('admin','normal')
/url4=denyAll
根据这个配置,有如下规则:
- /url1 允许任意访问,无需携带令牌
- /url2 必须携带令牌,且角色是admin的用户才能访问
- /url3 必须携带令牌,且至少拥有admin或者normal之一的角色的用户才能访问
- /url4 即使携带了令牌,也不能访问
假设用户1拥有admin和superAdmin两个角色,那么他可以访问url1,url2,url3;用户2拥有admin,normal两个角色,那么他也可以访问url1,url2,url3
角色信息是在实现ILoginAuthenticator接口的doAuthenticate方法的返回对象AuthenticateResult设置的,框架会通过调用AuthenticationResult.getRoles获得角色信息
注意!! 角色信息是一串已逗号分隔的角色字符串,且每个角色名前必须加上大写的"ROLE_"前缀,而在accessRule.properties的配置中,角色无需添加"ROLE_"前缀
一个适应上述accessRule.properties配置的ILoginAuthenticator的简单实现类举例如下:
@Override
public AuthenticateResult<UserInfo> doAuthenticate(final String userId, final Map<String, String> credentialMap) throws LoginAuthenticateFailedException {
if ("userAdmin".equals(userId)) {
return new MyAuthenticateResult(userId,"ROLE_admin",new UserInfo());
}
if ("userNormal".equals(userId)) {
return new MyAuthenticateResult(userId,"ROLE_normal",new UserInfo());
}
if ("userSuperAdmin".equals(userId)) {
return new MyAuthenticateResult(userId,"ROLE_superAdmin",new UserInfo());
}
if ("userAdminSuperAdmin".equals(userId)) {
return new MyAuthenticateResult(userId,"ROLE_admin,ROLE_superAdmin",new UserInfo());
}
return new MyAuthenticateResult(userId,"",new UserInfo());
}
同时,访问控制的url路径还可以使用通配符:
- ? 匹配单一字符,如/url? 可以设置任意以url开头,后面紧跟一个字符的路径
- * 匹配零个或多个字符,如/url* 可以匹配任意以url开头的路径
- ** 匹配路径中的零个或多个目录路径,如/url/**/123可以匹配/url/123,/url/xxx/123,/url/xxx/yyy/123
可配置项
- alg.security.token-expiration-in-ms 以毫秒为单位的令牌过期时间,默认24小时
- alg.security.secret 用于生成JWT令牌签名的签名密钥
- alg.security.header-token-field-name 验证令牌时读取HTTP请求头部的字段名,默认是token
- alg.security.use-default-login 是否使用默认登录实现,默认true
- alg.security.token-include-payload 生成的令牌是否包含ILoginAuthenticator返回的payload(即前面例子中的UserInfo对象),默认true,包含后在验证令牌时会将该对象读出放到数据交换区,方便用户信息的获取
- alg.security.default-login-url 默认登录实现的url地址,默认是/signin
- alg.security.access-rule-file-location 访问控制规则文件的位置,默认是classpath:accessRules.properties
- alg.security.errorCode* 在令牌验证出错时返回的错误码,可以自行定义错误码以自定义报错信息
加入安全模块后的集成测试配置
加入安全模块后,由于默认将开启令牌验证,将导致通过调用testRestTemplate.exchange发送请求的集成测试由于无令牌信息而报错,解决方法如下:
- 在/src/resources下新建一个accessRules-dev.properties文件,内容为
/**=permitAll
- 在/src/resources下新建一个application-dev.properties文件(如果已有则只需添加内容)
alg.security.access-rule-file-location=classpath:accessRules-dev.properties
- 在集成测试类上加入注解@ActiveProfiles("dev")
@ActiveProfiles("dev")
public class SecuritydemoApplicationTest extends AbstractIntTest {
@Test
public void test1(){
final ResponseEntity<String> ret = testRestTemplate.exchange("/hello", HttpMethod.GET, null, String.class);
//assert
}
}