GitHub:shpunishment/spring-security-jwt-demo
1. 前言
在这之前,可以先了解下Spring Security 使用 和 RBAC 权限控制及结合 Spring Security 部分实现。
本文使用的数据库模型都来自RBAC 权限控制及结合 Spring Security 部分实现中的RBAC0章节。不同的是,上文中使用的是Cookie和Session;本文使用JWT Token。
Json Web Token (JWT),是为了在网络应用环境间传递声明而执行的一种基于JSON的开放标准(RFC 7519)。RFC 7519 定义了一种简洁的,自包含的方法用于通信双方之间以JSON对象的形式安全的传递信息。因为数字签名的存在,这些信息是可信的,JWT可以使用HMAC算法或者是RSA的公私秘钥对进行签名。
2. Spring Security + JWT
表结构
表 | 字段 |
---|---|
用户表 user | id,nickname,username,password,enable |
角色表 role | id,role_name |
菜单表 menu | id,menu_name,url,permission |
用户角色关联表 user_role | id,user_id,role_id |
角色菜单关联表 role_menu | id,role_id,menu_id |
添加依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.1.2</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>5.1.47</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.60</version>
</dependency>
配置Spring Security
@Configuration
@EnableWebSecurity
// 开启方法级别保护
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private UserDetailsService userDetailsService;
@Autowired
private AuthenticationEntryPoint jwtAuthenticationEntryPoint;
@Autowired
private AccessDeniedHandler jwtAccessDeniedHandler;
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth
.userDetailsService(userDetailsService)
.passwordEncoder(passwordEncoder());
}
@Override
public void configure(WebSecurity web) throws Exception {
web
.ignoring()
.antMatchers("/js/**");
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
// /login 为登录url
.antMatchers(HttpMethod.POST, "/login").permitAll()
// /api /page 需要任何身份验证
.antMatchers("/api/**","/page/**").authenticated()
// 其他请求通过
.anyRequest().permitAll()
.and()
// 添加JWT认证过滤器和JWT登录认证过滤器,并且关闭session
.addFilter(new JwtAuthenticationFilter(authenticationManager(), userDetailsService))
.addFilter(new JwtLoginAuthenticationFilter(authenticationManager()))
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
// 异常处理,认证与授权
.exceptionHandling()
.authenticationEntryPoint(jwtAuthenticationEntryPoint)
.accessDeniedHandler(jwtAccessDeniedHandler)
.and()
.cors().and()
.csrf().disable();
}
@Bean
public static BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
用数据库存储,实现 UserDetailsService 接口。
获取用户信息,先通过用户名获取用户,再通过用户id获取权限值,再设值
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
private UserService userService;
@Autowired
private MenuService menuService;
@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
User user = userService.getByUsername(s);
if (user != null) {
List<String> authorities = menuService.getPermissionByUserId(user.getId());
return new SecurityUserDetails(user.getUsername(), user.getPassword(), user.getEnable(), authorities);
}
return null;
}
}
存储用户信息,实现 UserDetails 接口,UserDetails 是提供用户信息的核心接口,但仅存储用户信息,需要将用户信息封装到认证对象 Authentication 中。
使用 SimpleGrantedAuthority ,GrantedAuthority 的基本实现来保存权限值。
public class SecurityUserDetails implements UserDetails {
private String username;
private String password;
private Integer enable;
private List<GrantedAuthority> authorities;
public SecurityUserDetails (String username, String password, Integer enable, List<String> authorities) {
this.username = username;
this.password = password;
this.enable = enable;
// 权限值,在这里就是菜单的权限值
List<GrantedAuthority> authorityList = new ArrayList<>();
if (!authorities.isEmpty()) {
for (String authority : authorities) {
authorityList.add(new SimpleGrantedAuthority(authority));
}
}
this.authorities = authorityList;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}
@Override
public String getPassword() {
return password;
}
@Override
public String getUsername() {
return username;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return this.enable == 1;
}
@Override
public boolean equals(Object o) {
return this.toString().equals(o.toString());
}
@Override
public int hashCode() {
return username.hashCode();
}
@Override
public String toString() {
return this.username;
}
public void setPassword(String password) {
this.password = password;
}
}
UsernamePasswordAuthenticationFilter 原来的作用就是对用户名和密码进行校验,这里继承并进行重写,也可以使其不止验证用户名和密码。
JWT登录认证过滤器,认证信息并颁发Token。
public class JwtLoginAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
private final AuthenticationManager authenticationManager;
public JwtLoginAuthenticationFilter(AuthenticationManager authenticationManager) {
// 设置拦截的url,即登录url
super.setFilterProcessesUrl(Const.LOGIN_URL);
this.authenticationManager = authenticationManager;
}
/**
* 获取请求中的用户名和密码,再封装成token并校验
* @param request
* @param response
* @return
* @throws AuthenticationException
*/
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
String username = this.obtainUsername(request);
String password = this.obtainPassword(request);
if (username == null) {
username = "";
}
if (password == null) {
password = "";
}
username = username.trim();
UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password);
return this.authenticationManager.authenticate(authRequest);
}
/**
* 登录成功创建token并写在头部Authorization中
* @param request
* @param response
* @param chain
* @param authResult
* @throws IOException
* @throws ServletException
*/
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
UserDetails userDetails = (SecurityUserDetails) authResult.getPrincipal();
List<String> authorities = userDetails.getAuthorities()
.stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toList());
String token = JwtTokenUtils.createJwtToken(userDetails.getUsername(), authorities);
response.setHeader(JwtTokenUtils.TOKEN_HEADER, token);
}
/**
* 登录失败
* @param request
* @param response
* @param failed
* @throws IOException
* @throws ServletException
*/
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
Map<String, Object> map = new HashMap<>(2);
map.put("code", 401);
map.put("msg", "未认证通过,请重新登录!");
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(JSON.toJSONString(map));
}
}
JWT前置认证过滤器,所有请求都会先通过该过滤器,从请求头部中获取Token,并解析获取用户名再进行校验,最后保存到SecurityContext上下文中,抛出异常就会调用 AuthenticationEntryPoint 的方法。
public class JwtAuthenticationFilter extends BasicAuthenticationFilter {
private final UserDetailsService userDetailsService;
public JwtAuthenticationFilter(AuthenticationManager authenticationManager, UserDetailsService userDetailsService) {
super(authenticationManager);
this.userDetailsService = userDetailsService;
}
/**
* 从头部中获取token,并解析获取用户名再进行校验,最后保存到SecurityContext上下文中
* @param request
* @param response
* @param chain
* @throws IOException
* @throws ServletException
*/
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
try {
String token = request.getHeader(JwtTokenUtils.TOKEN_HEADER);
if (token != null && token.startsWith(JwtTokenUtils.TOKEN_PREFIX)) {
String jwtToken = token.replace(JwtTokenUtils.TOKEN_PREFIX, "");
String username = JwtTokenUtils.getUsernameByToken(jwtToken);
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);
if (JwtTokenUtils.validateToken(jwtToken, userDetails)) {
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
}
} catch (Exception e) {
SecurityContextHolder.clearContext();
}
chain.doFilter(request, response);
}
}
实现 AuthenticationEntryPoint 接口,当没有Token或者Token失效访问接口时,自定义返回结果。
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException {
Map<String, Object> map = new HashMap<>(2);
map.put("code", 401);
map.put("msg", "未认证通过,请登录!");
httpServletResponse.setContentType("application/json;charset=UTF-8");
httpServletResponse.getWriter().write(JSON.toJSONString(map));
}
}
实现 AccessDeniedHandler 接口,当用户无权限访问该资源时,自定义返回结果。
@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AccessDeniedException e) throws IOException, ServletException {
Map<String, Object> map = new HashMap<>(2);
map.put("code", 403);
map.put("msg", "无权限访问");
httpServletResponse.setContentType("application/json;charset=UTF-8");
httpServletResponse.getWriter().write(JSON.toJSONString(map));
}
}
JWT工具类
public class JwtTokenUtils {
/**
* Base64加密后的字符串,即私钥
*/
public static final String SECRET_KEY = "shpun";
/**
* token认证的类型
*/
public static final String TOKEN_TYPE = "JWT";
/**
* token认证的头部名
*/
public static final String TOKEN_HEADER = "Authorization";
/**
* token认证的前缀
*/
public static final String TOKEN_PREFIX = "Bearer ";
/**
* token的超时时间,这里是30分钟
*/
public static final long EXPIRY_TIME = 30 * 60L;
public static final String AUTHORITY_CLAIMS = "au";
/**
* 创建JwtToken
* @param username
* @return
*/
public static String createJwtToken(String username, List<String> authorities) {
final Date createdDate = new Date();
final Date expirationDate = new Date(createdDate.getTime() + EXPIRY_TIME * 1000);
String jwtToken = Jwts.builder()
.setHeaderParam("type", TOKEN_TYPE)
.signWith(SignatureAlgorithm.HS256, SECRET_KEY)
.claim(AUTHORITY_CLAIMS, String.join(",", authorities))
.setIssuer("shpun")
.setIssuedAt(createdDate)
.setSubject(username)
.setExpiration(expirationDate)
.compact();
return TOKEN_PREFIX + jwtToken;
}
/**
* 判断token是否过期
* @param token
* @return
*/
public static boolean isTokenExpired(String token) {
Date expiredDate = getTokenBody(token).getExpiration();
return expiredDate.before(new Date());
}
/**
* 获取token中的username
* @param token
* @return
*/
public static String getUsernameByToken(String token) {
return getTokenBody(token).getSubject();
}
/**
* 获取tokenBody
* @param token
* @return
*/
private static Claims getTokenBody(String token) {
return Jwts.parser()
.setSigningKey(SECRET_KEY)
.parseClaimsJws(token)
.getBody();
}
/**
* 验证token是否还有效
* @param token
* @param userDetails
* @return
*/
public static boolean validateToken(String token, UserDetails userDetails) {
String username = getUsernameByToken(token);
return username.equals(userDetails.getUsername()) && !isTokenExpired(token);
}
}
以上省略model,mapper,service等
page1~page6的权限控制
需要再Spring Security的配置中添加注解 @EnableGlobalMethodSecurity(prePostEnabled = true)
,开启方法级别保护
hasAuthority中是权限值,有相应的权限值才能访问该接口。
@RequestMapping("/page")
@RestController
public class PageController {
@PreAuthorize("hasAuthority('PageController:page1')")
@RequestMapping("/page1")
public String page1() {
return "page1";
}
@PreAuthorize("hasAuthority('PageController:page2')")
@RequestMapping("/page2")
public String page2() {
return "page2";
}
@PreAuthorize("hasAuthority('PageController:page3')")
@RequestMapping("/page3")
public String page3() {
return "page3";
}
@PreAuthorize("hasAuthority('PageController:page4')")
@RequestMapping("/page4")
public String page4() {
return "page4";
}
@PreAuthorize("hasAuthority('PageController:page5')")
@RequestMapping("/page5")
public String page5() {
return "page5";
}
@PreAuthorize("hasAuthority('PageController:page6')")
@RequestMapping("/page6")
public String page6() {
return "page6";
}
}
测试
添加用户:管理员,张三,李四
添加角色:管理员,测试员1,测试员2
添加菜单:page1~6
添加用户角色关联:
管理员 - 管理员
张三 - 测试员1
李四 - 测试员2
添加角色菜单关联:
管理员 page1~6
测试员1 page1,page2
测试员2 page1,page3
未认证可以直接访问不受保护的链接 /test/**
访问受保护的链接 /api/** ,未认证返回 JwtAuthenticationEntryPoint 中的自定义结果
用户名和密码错误登录失败
使用张三的用户名密码正确登录成功,在返回的头部可看到JWT Token
JWT 解码可看到保存的信息
使用用户张三的JWT Token访问page1和page2都可访问成功
访问page3~page6就失败,因为张三没有该权限,返回 JwtAccessDeniedHandler 中的自定义结果
3. 整合Swagger
修改 SwaggerConfig,实现调用接口自带Authorization头,这样就可以访问认证的接口。
@Configuration
@EnableSwagger2
public class SwaggerConfig {
@Bean
public Docket createRestApi(){
return new Docket(DocumentationType.SWAGGER_2)
.groupName("Jwt")
.apiInfo(apiInfo())
.select()
.apis(RequestHandlerSelectors.basePackage("com.shpun.controller"))
.paths(PathSelectors.any())
.build()
// 添加登录认证
.securitySchemes(securitySchemes())
.securityContexts(securityContexts());
}
private ApiInfo apiInfo() {
return new ApiInfoBuilder()
.title("Spring-Security-Jwt-test")
.version("1.0")
.build();
}
/**
* 设置请求头信息
* @return
*/
private List<ApiKey> securitySchemes() {
List<ApiKey> result = new ArrayList<>();
ApiKey apiKey = new ApiKey("Authorization", "Authorization", "header");
result.add(apiKey);
return result;
}
/**
* 设置需要登录认证的路径
* @return
*/
private List<SecurityContext> securityContexts() {
List<SecurityContext> result = new ArrayList<>();
result.add(getContextByPath("/api/.*"));
result.add(getContextByPath("/page/.*"));
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;
}
}
修改 WebSecurityConfig,允许对于网站静态资源的无授权访问
@Override
public void configure(WebSecurity web) throws Exception {
web
.ignoring()
.antMatchers("/",
"/*.html",
"/favicon.ico",
"/**/*.html",
"/**/*.css",
"/**/*.js",
"/swagger-resources/**",
"/v2/api-docs/**");
}
启动,打开 http://localhost:8123/jwt/swagger-ui.html
,右边有个Authorize的按钮
设置了需要认证的接口,会有锁的图标
填入后,请求就会在头部带上JWT
参考:
Spring security集成JWT实现token认证Demo
spring-security-jwt-guide
mall整合SpringSecurity和JWT实现认证和授权(一)
mall整合SpringSecurity和JWT实现认证和授权(二)
Spring Security做JWT认证和授权