Spring Security + JWT 使用

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

表结构

字段
用户表 userid,nickname,username,password,enable
角色表 roleid,role_name
菜单表 menuid,menu_name,url,permission
用户角色关联表 user_roleid,user_id,role_id
角色菜单关联表 role_menuid,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 解码
使用用户张三的JWT Token访问page1和page2都可访问成功
page1
page2
访问page3~page6就失败,因为张三没有该权限,返回 JwtAccessDeniedHandler 中的自定义结果
page3~page6无权访问

3. 整合Swagger

可先了解Spring Boot + 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的按钮
swagger
设置了需要认证的接口,会有锁的图标
认证的接口
填入后,请求就会在头部带上JWT
填入后,请求就会在头部带上JWT

参考:
Spring security集成JWT实现token认证Demo
spring-security-jwt-guide
mall整合SpringSecurity和JWT实现认证和授权(一)
mall整合SpringSecurity和JWT实现认证和授权(二)
Spring Security做JWT认证和授权

  • 2
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值