所需组件

背景

安全模块基于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都可以通过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,该对象有如下逻辑:

默认Controller接收类似如下的POST登录请求,HTTP BODY内容举例如下:

{
    "userId":"joe",
    "credential":{"password":"qwer","verificationCode":"1234"}
}

其中userId是登录的用户唯一标识,credential为自定义扩展类型,可以自行添加字段和内容.在默认Controller实现下,会将该字段视为一个Map对象传入用户定义的ILoginAuthenticator实现中(上述MyAuthenticator类中的方法形参credentialMap),在ILoginAuthenticator的用户验证方法doAuthenticate中,可以读取credential的内容,获得如密码/验证码.

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

根据这个配置,有如下规则:

假设用户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路径还可以使用通配符:

可配置项

加入安全模块后的集成测试配置

加入安全模块后,由于默认将开启令牌验证,将导致通过调用testRestTemplate.exchange发送请求的集成测试由于无令牌信息而报错,解决方法如下:

  1. 在/src/resources下新建一个accessRules-dev.properties文件,内容为
/**=permitAll
  1. 在/src/resources下新建一个application-dev.properties文件(如果已有则只需添加内容)
alg.security.access-rule-file-location=classpath:accessRules-dev.properties
  1. 在集成测试类上加入注解@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
    }
}