一、关于 SpringSecurity
在 Spring Boot 出现之前,SpringSecurity 的使用场景是被另外一个安全管理框架 Shiro 牢牢霸占的,因为相对于 SpringSecurity 来说,SSM 中整合 Shiro 更加轻量级。Spring Boot 出现后,使这一情况情况大有改观。
这是因为 Spring Boot 为 SpringSecurity 提供了自动化配置,大大降低了 SpringSecurity 的学习成本。另外,SpringSecurity 的功能也比 Shiro 更加强大。
JWT,目前最流行的一个跨域认证解决方案:客户端发起用户登录请求,服务器端接收并认证成功后,生成一个 JSON 对象(如下所示),然后将其返回给客户端。
从本质上来说,JWT 就像是一种生成加密用户身份信息的 Token,更安全也更灵活。
三、整合步骤
第一步,给需要登录认证的模块添加 codingmore-security 依赖:
<!-- 后端管理模块需要登录认证-->
<dependency>
<groupId>top.codingmore</groupId>
<artifactId>codingmore-security</artifactId>
<version>1.0-SNAPSHOT</version>
比如说 codingmore-admin 后端管理模块需要登录认证,就在 codingmore-admin/pom.xml 文件中添加 codingmore-security 依赖。
第二步,在需要登录认证的模块里添加 CodingmoreSecurityConfig 类,继承自 codingmore-security 模块中的 SecurityConfig 类。
Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class CodingmoreSecurityConfig extends CustomSecurityConfig {
@Autowired
private IUsersService usersService;
@Autowired
private IResourceService resourceService;
@Bean
public UserDetailsService userDetailsService() {
//获取登录用户信息
return username -> usersService.loadUserByUsername(username);
}
UserDetailsService 这个类主要是用来加载用户信息的,包括用户名、密码、权限、角色集合....其中有一个方法如下:
UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
认证逻辑中,SpringSecurity 会调用这个方法根据客户端传入的用户名加载该用户的详细信息,包括判断:
- 密码是否一致
- 通过后获取权限和角色
public UserDetails loadUserByUsername(String username) {
// 根据用户名查询用户
Users admin = getAdminByUsername(username);
if (admin != null) {
List<Resource> resourceList = getResourceList(admin.getId());
return new AdminUserDetails(admin,resourceList);
}
throw new UsernameNotFoundException("用户名或密码错误");
}
getAdminByUsername 负责根据用户名从数据库中查询出密码、角色、权限等。
@Override
public Users getAdminByUsername(String username) {
// QueryWrapper<Users> queryWrapper = new QueryWrapper<>();
//user_login为数据库字段
// queryWrapper.eq("user_login", username);
//List<Users> usersList = baseMapper.selectList(queryWrapper);
LambdaQueryWrapper<Users> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(Users::getUserLogin , username);
List<Users> usersList = baseMapper.selectList(queryWrapper);
if (usersList != null && usersList.size() > 0) {
return usersList.get(0);
}
// 用户名错误,提前抛出异常
throw new UsernameNotFoundException("用户名错误");
}
第三步,在 application.yml 中配置下不需要安全保护的资源路径:
secure:
ignored:
urls: #安全路径白名单
- /doc.html
- /swagger-ui/**
- /swagger/**
- /swagger-resources/**
- /**/v3/api-docs
- /**/*.js
- /**/*.css
- /**/*.png
- /**/*.ico
- /webjars/springfox-swagger-ui/**
- /actuator/**
- /druid/**
- /users/login
- /users/register
- /users/info
- /users/logout
第四步,在登录接口中添加登录和刷新 token 的方法:
@Controller
@Api(tags = "用户")
@RequestMapping("/users")
public class UsersController {
@Autowired
private IUsersService usersService;
@Value("${jwt.tokenHeader}")
private String tokenHeader;
@Value("${jwt.tokenHead}")
private String tokenHead;
@ApiOperation(value = "登录以后返回token")
@RequestMapping(value = "/login", method = RequestMethod.POST)
@ResponseBody
public ResultObject login(@Validated UsersLoginParam users, BindingResult result) {
String token = usersService.login(users.getUserLogin(), users.getUserPass());
if (token == null) {
return ResultObject.validateFailed("用户名或密码错误");
}
// 将 JWT 传递回客户端
Map<String, String> tokenMap = new HashMap<>();
tokenMap.put("token", token);
tokenMap.put("tokenHead", tokenHead);
return ResultObject.success(tokenMap);
}
@ApiOperation(value = "刷新token")
@RequestMapping(value = "/refreshToken", method = RequestMethod.GET)
@ResponseBody
public ResultObject refreshToken(HttpServletRequest request) {
String token = request.getHeader(tokenHeader);
String refreshToken = usersService.refreshToken(token);
if (refreshToken == null) {
return ResultObject.failed("token已经过期!");
}
Map<String, String> tokenMap = new HashMap<>();
tokenMap.put("token", refreshToken);
tokenMap.put("tokenHead", tokenHead);
return ResultObject.success(tokenMap);
}
}
使用 Apipost 来测试一下,首先是文章获取接口,在没有登录的情况下会提示暂未登录或者 token 已过期。
四、实现原理
仅用四步就实现了登录认证,主要是因为将 SpringSecurity+JWT 的代码封装成了通用模块,我们来看看 codingmore-security 的目录结构。
codingmore-security
├── component
| ├── JwtAuthenticationTokenFilter -- JWT登录授权过滤器
| ├── RestAuthenticationEntryPoint
| └── RestfulAccessDeniedHandler
├── config
| ├── IgnoreUrlsConfig
| └── SecurityConfig
└── util
└── JwtTokenUtil -- JWT的token处理工具类
JwtAuthenticationTokenFilter 和 JwtTokenUtil 补充一下,
客户端的请求头里携带了 token,服务端肯定是需要针对每次请求解析校验 token 的,所以必须得定义一个过滤器,也就是 JwtAuthenticationTokenFilter:
- 从请求头中获取 token
- 对 token 进行解析、验签、校验过期时间
- 校验成功,将验证结果放到 ThreadLocal 中,供下次请求使用
重点来看其他四个类。第一个 RestAuthenticationEntryPoint(自定义返回结果:未登录或登录过期):
public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
response.setHeader("Access-Control-Allow-Origin", "*");
response.setHeader("Cache-Control","no-cache");
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json");
response.getWriter().println(JSONUtil.parse(ResultObject.unauthorized(authException.getMessage())));
response.getWriter().flush();
}
}
可以通过 debug 的方式看一下返回的信息正是之前用户未登录状态下访问文章页的错误信息。
具体的信息是在 ResultCode 类中定义的。
public enum ResultCode implements IErrorCode {
SUCCESS(0, "操作成功"),
FAILED(500, "操作失败"),
VALIDATE_FAILED(506, "参数检验失败"),
UNAUTHORIZED(401, "暂未登录或token已经过期"),
FORBIDDEN(403, "没有相关权限");
private long code;
private String message;
private ResultCode(long code, String message) {
this.code = code;
this.message = message;
}
}
第二个 RestfulAccessDeniedHandler(自定义返回结果:没有权限访问时):
public class RestfulAccessDeniedHandler implements AccessDeniedHandler{
@Override
public void handle(HttpServletRequest request,
HttpServletResponse response,
AccessDeniedException e) throws IOException, ServletException {
response.setHeader("Access-Control-Allow-Origin", "*");
response.setHeader("Cache-Control","no-cache");
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json");
response.getWriter().println(JSONUtil.parse(ResultObject.forbidden(e.getMessage())));
response.getWriter().flush();
}
}
第三个IgnoreUrlsConfig(用于配置不需要安全保护的资源路径):
@Getter
@Setter
@ConfigurationProperties(prefix = "secure.ignored")
public class IgnoreUrlsConfig {
private List<String> urls = new ArrayList<>();
}
通过 lombok 注解的方式直接将配置文件中不需要权限校验的路径放开,比如说 Knife4j 的接口文档页面。如果不放开的话,就被 SpringSecurity 拦截了,没办法访问到了。
第四个SecurityConfig(SpringSecurity通用配置):
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired(required = false)
private DynamicSecurityService dynamicSecurityService;
@Override
protected void configure(HttpSecurity httpSecurity) throws Exception {
ExpressionUrlAuthorizationConfigurer<HttpSecurity>.ExpressionInterceptUrlRegistry registry = httpSecurity
.authorizeRequests();
//不需要保护的资源路径允许访问
for (String url : ignoreUrlsConfig().getUrls()) {
registry.antMatchers(url).permitAll();
}
// 任何请求需要身份认证
registry.and()
.authorizeRequests()
.anyRequest()
.authenticated()
// 关闭跨站请求防护及不使用session
.and()
.csrf()
.disable()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
// 自定义权限拒绝处理类
.and()
.exceptionHandling()
.accessDeniedHandler(restfulAccessDeniedHandler())
.authenticationEntryPoint(restAuthenticationEntryPoint())
// 自定义权限拦截器JWT过滤器
.and()
.addFilterBefore(jwtAuthenticationTokenFilter(), UsernamePasswordAuthenticationFilter.class);
//有动态权限配置时添加动态权限校验过滤器
if(dynamicSecurityService!=null){
registry.and().addFilterBefore(dynamicSecurityFilter(), FilterSecurityInterceptor.class);
}
}
}
这个类的主要作用就是告诉 SpringSecurity 那些路径不需要拦截,除此之外的,都要进行 RestfulAccessDeniedHandler(登录校验)、RestAuthenticationEntryPoint(权限校验)和 JwtAuthenticationTokenFilter(JWT 过滤)。
并且将 JwtAuthenticationTokenFilter 过滤器添加到 UsernamePasswordAuthenticationFilter 过滤器之前。
五、测试
第一步,测试登录接口,Apipost 直接访问 http://localhost:9002/users/login
,可以看到 token 正常返回。
第二步,不带 token 直接访问文章接口,可以看到进入了 RestAuthenticationEntryPoint 这个处理器
第三步,携带 token,这次我们改用 Knife4j 来测试,发现可以正常访问: