项目最初的安全方案:使用cookie实现用户登录权限管理

最初,BLER项目主要使用了Cookie进行鉴权。首先,在用户登录时使用用户的信息创建Cookie:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
public int loginUser(String userAccount, String password,HttpServletResponse rsp) {
String md5Password=DigestUtils.md5DigestAsHex(password.getBytes());
Admin admin=adminMapper.getOneById(userAccount);
/*先判断是否为管理员账户,如果是则优先作为管理员登录*/
if(admin!=null){
if(admin.getAPwd().equals(md5Password)){
/*密码正确*/

/*为登录的用户设置token*/
String token = UUID.randomUUID().toString();
redisService.set(token,userAccount);
/*添加token,记录token*/
Cookie tokenCookie=new Cookie("token",token);
Cookie idCookie=new Cookie("id",userAccount);
rsp.addCookie(tokenCookie);
rsp.addCookie(idCookie);
return ReturnValues.ADMIN;
}else{
/*密码错误*/
return ReturnValues.FAILED;
}
}else{
/*不是管理员,判断是否为普通用户*/
User user=userMapper.getOneById(userAccount);
if(user!=null){
/*账户存在*/
if(user.getUPwd().equals(md5Password)){
/*密码正确*/

/*设置登录token*/
String token = UUID.randomUUID().toString();
redisService.set(token,userAccount);
/*添加token,记录token*/
Cookie tokenCookie=new Cookie("token",token);
Cookie idCookie=new Cookie("id",userAccount);
rsp.addCookie(tokenCookie);
rsp.addCookie(idCookie);
return ReturnValues.USER;
}else{
/*密码错误*/
return ReturnValues.FAILED;
}
}else {
/*账户不存在*/
return ReturnValues.ACCOUNT_ERROR;
}
}
}

对于每个用户,登陆时产生两个cookie:tokenid,存储到用户的浏览器中。

前端请求中包含两个cookie的信息是因为浏览器在发送HTTP请求时,会自动将当前域下所有相关的cookie信息添加到请求头的Cookie字段中。这意味着只要用户在浏览器上存储了多个与当前网站域名匹配的cookie,浏览器在向该域名发送请求时,就会将这些cookie一起发送给服务器。

所以对于这种鉴权方式,我们不需要额外在前端进行手动设置,而是直接进行后端的验证处理。可以直接在req中获取cookie的信息,作为用户权限的凭证。

1
2
3
4
5
6
7
8
const token = req.cookies.token;
const userId = req.cookies.id;

// 验证token和userId的有效性
if (!validateToken(token)) {
// token无效,返回错误信息或重定向到登录页面
return res.status(401).send('Unauthorized');
}

Cookie方案的缺陷

然而,这种方案存在很多缺陷:

  • 安全性能低

    这种方案将token与用户id这些敏感信息直接存储在了用户本地浏览器的cookie中,增加了数据泄露的风险。

  • 缺乏令牌刷新机制

    为了提高安全性,通常我们不希望令token长期有效,而上面我使用的方案没有定义token定期更新的机制,因此存在安全问题。

    另一方面,假如我试图定义这样的机制,则它会比较复杂,并且可能需要用户频繁重新登录,影响用户的体验。

  • 不够标准化,扩展性差

    在有Spring Security等完善成熟的机制的情况下,使用cookie的鉴权方式过于简陋,并且对任何新的需求都需要开发者重新考虑。

所以,在重构项目时,我尝试使用Spring Secutiry+JWT这种相对成熟和规范的方式重新实现项目的权限管理。


整合SpringSecurity和JWT实现认证和授权

什么是JWT

JWT(JSON WEB TOKEN)是基于 RFC 7519 标准定义的一种可以安全传输的的JSON对象,由于使用了数字签名,所以是可信任和安全的。

JWT token的格式如下:

1
header.payload.signature
  • header中用于存放签名的生成算法;
  • payload中用于存放用户名、token的生成时间和过期时间;
  • signature为以header和payload生成的签名,一旦header和payload被篡改,验证将失败。

使用JWT实现认证和授权的原理

  • 用户调用登录接口,登录成功后获取到JWT的token
  • 之后用户每次调用接口都在http的header中添加一个叫Authorization的头,值为JWT的token
  • 后台程序通过对Authorization头中信息的解码及数字签名校验来获取其中的用户信息,从而实现认证和授权。

具体实现

  1. **pom.xml**中添加相关依赖;
1
2
3
4
5
6
7
8
9
10
11
12
13
<dependencies>
<!--SpringSecurity依赖配置-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!--JWT(Json Web Token)登录支持-->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>${jjwt.version}</version>
</dependency>
</dependencies>
  1. 修改配置文件**application.yml**,添加JWT相关配置;
1
2
3
4
5
jwt:
tokenHeader: Authorization #JWT存储的请求头
secret: key #JWT加解密使用的密钥
expiration: 604800 #JWT的超期限时间(60*60*24s,也就是一天)
tokenHead: Bearer #JWT负载中拼接到开头
  1. 添加JWT token的工具类,用于生成和解析JWT token的工具类;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
public class JwtTokenUtil {

private static final Logger LOGGER = LoggerFactory.getLogger(JwtTokenUtil.class);
private static final String CLAIM_KEY_USERNAME = "sub";
private static final String CLAIM_KEY_CREATED = "created";
//从配置文件中读取信息
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.expiration}")
private Long expiration;

private String generateToken(Map<String, Object> claims) {
// 创建JWT Builder实例,用于构建JWT
return Jwts.builder()
// 设置JWT的载荷部分,即将传入的claims作为JWT的有效载荷内容
.setClaims(claims)
// 设置JWT的过期时间,通过调用generateExpirationDate()方法获取一个未来的时间点
.setExpiration(generateExpirationDate())
// 使用HS512算法以及预定义的秘密密钥(secret)对JWT进行签名
.signWith(SignatureAlgorithm.HS512, secret)
// 完成JWT的构建并将其转换为紧凑格式的字符串表示
.compact();
}

/**
* 从token中获取JWT中的负载
*/
private Claims getClaimsFromToken(String token) {
Claims claims = null;
try {
claims = Jwts.parser()
.setSigningKey(secret)
.parseClaimsJws(token)
.getBody();
} catch (Exception e) {
LOGGER.info("JWT格式验证失败:{}",token);
}
return claims;
}

/**
* 生成token的过期时间
*/
private Date generateExpirationDate() {
return new Date(System.currentTimeMillis() + expiration * 1000);
}

/**
* 从token中获取登录用户名
*/
public String getUserNameFromToken(String token) {
String username;
try {
Claims claims = getClaimsFromToken(token);
username = claims.getSubject();
} catch (Exception e) {
username = null;
}
return username;
}

/**
* 验证token是否还有效
*
* @param token 客户端传入的token
* @param userDetails 从数据库中查询出来的用户信息
*/
public boolean validateToken(String token, UserDetails userDetails) {
String username = getUserNameFromToken(token);
return username.equals(userDetails.getUsername()) && !isTokenExpired(token);
}

/**
* 判断token是否已经失效
*/
private boolean isTokenExpired(String token) {
Date expiredDate = getExpiredDateFromToken(token);
return expiredDate.before(new Date());
}

/**
* 从token中获取过期时间
*/
private Date getExpiredDateFromToken(String token) {
Claims claims = getClaimsFromToken(token);
return claims.getExpiration();
}

/**
* 根据用户信息生成token
*/
public String generateToken(UserDetails userDetails) {
Map<String, Object> claims = new HashMap<>();
claims.put(CLAIM_KEY_USERNAME, userDetails.getUsername());
claims.put(CLAIM_KEY_CREATED, new Date());
return generateToken(claims);
}

/**
* 判断token是否可以被刷新
*/
public boolean canRefresh(String token) {
return !isTokenExpired(token);
}

/**
* 刷新token
*/
public String refreshToken(String token) {
Claims claims = getClaimsFromToken(token);
claims.put(CLAIM_KEY_CREATED, new Date());
return generateToken(claims);
}
}
  1. 添加Spring Security的配置类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled=true)
public class SecurityConfig {

//当访问接口没有权限时,自定义的返回结果
@Autowired
private RestfulAccessDeniedHandler restfulAccessDeniedHandler;

//当未登录或者token失效访问接口时,自定义的返回结果
@Autowired
private RestAuthenticationEntryPoint restAuthenticationEntryPoint;

//白名单列表
@Autowired
private IgnoreUrlsConfig ignoreUrlsConfig;

@Bean
SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
ExpressionUrlAuthorizationConfigurer<HttpSecurity>.ExpressionInterceptUrlRegistry registry = httpSecurity
.authorizeRequests();
//不需要保护的资源路径,允许任何访问,参见application.yml中
for (String url : ignoreUrlsConfig.getUrls()) {
registry.antMatchers(url).permitAll();
}
//允许跨域请求的OPTIONS请求
registry.antMatchers(HttpMethod.OPTIONS)
.permitAll();
httpSecurity.csrf()// 由于使用的是JWT,我们这里不需要csrf
.disable()
.sessionManagement()// 基于token,所以不需要session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
.anyRequest()// 除上面外的所有请求全部需要鉴权认证
.authenticated();

// 禁用缓存
httpSecurity.headers().cacheControl();

// 添加JWT filter
httpSecurity.addFilterBefore(jwtAuthenticationTokenFilter(), UsernamePasswordAuthenticationFilter.class);

//添加自定义未授权和未登录结果返回
httpSecurity.exceptionHandling()
.accessDeniedHandler(restfulAccessDeniedHandler)
.authenticationEntryPoint(restAuthenticationEntryPoint);
return httpSecurity.build();
}

@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}

@Bean
public JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter(){
return new JwtAuthenticationTokenFilter();
}

}

其中的RestfulAccessDeniedHandler,访问接口没有权限时,自定义的返回配置:

1
2
3
4
5
6
7
8
9
10
11
12
@Component
public class RestfulAccessDeniedHandler implements AccessDeniedHandler{
@Override
public void handle(HttpServletRequest request,
HttpServletResponse response,
AccessDeniedException e) throws IOException, ServletException {
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json");
response.getWriter().println(JSONUtil.parse(CommonResult.forbidden(e.getMessage())));
response.getWriter().flush();
}
}

其中的RestAuthenticationEntryPoint未登录或者token失效访问接口时,自定义的返回结果:

1
2
3
4
5
6
7
8
9
10
@Component
public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json");
response.getWriter().println(JSONUtil.parse(CommonResult.unauthorized(authException.getMessage())));
response.getWriter().flush();
}
}

其中的IgnoreUrlsConfig白名单配置:

修改application.yml文件,添加如下路径配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
secure:
ignored:
urls: #安全路径白名单
- /swagger-ui/
- /swagger-resources/**
- /**/v2/api-docs
- /**/*.html
- /**/*.js
- /**/*.css
- /**/*.png
- /favicon.ico
- /actuator/**
- /druid/**
- /admin/**
1
2
3
4
5
6
7
8
9
@Getter
@Setter
@Configuration
@ConfigurationProperties(prefix = "secure.ignored")//从配置文件中读取白名单中的url
public class IgnoreUrlsConfig {

private List<String> urls = new ArrayList<>();

}
  1. 添加自定义权限配置,配置好获取用户信息的服务。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Configuration
public class BlerSecurityConfig {

@Autowired
private AdminService adminService;

@Bean
public UserDetailsService userDetailsService() {
//获取登录用户信息
return username -> {
AdminUserDetails admin = adminService.getAdminByUsername(username);
if (admin != null) {
return admin;
}
throw new UsernameNotFoundException("用户名或密码错误");
};
}
}
  1. 让Swagger发送认证请求头,对其配置进行调整
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
@Configuration
public class Swagger2Config {
@Bean
public Docket createRestApi() {
return new Docket(DocumentationType.SWAGGER_2)
.apiInfo(apiInfo())
.select()
.apis(RequestHandlerSelectors.basePackage("com.qdu.bler.controller"))
.paths(PathSelectors.any())
.build()
//添加登录认证
.securitySchemes(securitySchemes())
.securityContexts(securityContexts());
}

private List<SecurityScheme> securitySchemes() {
//设置请求头信息
List<SecurityScheme> result = new ArrayList<>();
ApiKey apiKey = new ApiKey("Authorization", "Authorization", "header");
result.add(apiKey);
return result;
}

private List<SecurityContext> securityContexts() {
//设置需要登录认证的路径,admin路径下都需要认证
List<SecurityContext> result = new ArrayList<>();
result.add(getContextByPath("/admin/.*"));
return result;
}

private SecurityContext getContextByPath(String pathRegex) {
return SecurityContext.builder()
.securityReferences(defaultAuth())
.forPaths(PathSelectors.regex(pathRegex))
.build();
}

private List<SecurityReference> defaultAuth() {
List<SecurityReference> result = new ArrayList<>();
AuthorizationScope authorizationScope = new AuthorizationScope("global", "accessEverything");
AuthorizationScope[] authorizationScopes = new AuthorizationScope[1];
authorizationScopes[0] = authorizationScope;
result.add(new SecurityReference("Authorization", authorizationScopes));
return result;
}
}
  1. 具体配置接口权限
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* @auther macrozheng
* @description 品牌管理Controller
*/
@Controller
@Api(tags = "AdminController")
@Tag(name = "AdminController", description = "管理员模块")
@RequestMapping("/admin")
public class PmsBrandController {
@ApiOperation("分页查询岗位列表")
@RequestMapping(value = "/admin", method = RequestMethod.GET)
@ResponseBody
@PreAuthorize("hasAuthority('admin:listjob')")
public CommonResult<CommonPage<PmsBrand>> listJob(@RequestParam(value = "pageNum", defaultValue = "1")
@ApiParam("页码") Integer pageNum,
@RequestParam(value = "pageSize", defaultValue = "3")
@ApiParam("每页数量") Integer pageSize) {
List<Job> jobs = jobService.listBrand(pageNum, pageSize);
return CommonResult.success(CommonPage.restPage(brandList));
}
}

其中,**@PreAuthorize("hasAuthority('brand:list')")**是Spring Security框架中的注解,用于在运行时对方法调用执行安全检查。

具体来说,@PreAuthorize会在方法执行之前进行权限验证。这里表达式hasAuthority('admin:listjob')表示当前认证的用户必须拥有名为’admin:listjob’的权限才能访问该方法。

这种权限字符串本身是自定义的,可以在应用程序代码逻辑中直接使用,可以根据业务需要进行定义,比如可以定义另一个权限为’admin:listcompany’,这有助于实现细粒度的基于角色或权限的安全控制。

遇到的问题,白名单的设置

在初步完成Spring Security的配置后,发现我的swagger API信息连接:

http://localhost:8088/swagger-ui/

无法被正确访问了。检查浏览器console发现,错误为403,本页面被Spring Security拦截了。因此才想到去配置白名单,参见上文内容。

此外,使用JWT时,生成的token并不存储在服务端,也不在客户端。JWT的设计理念是无状态的,这意味着服务器在生成JWT后,会将其作为响应的一部分发送给客户端。相比于直接将Cookie存储在客户端的权限管理方式,这无疑提高了安全性。