Spring Security + JWT实现权限管理

1 写在之前 

本博客主要使用Spring Boot 整合Spring Security + JWT实现权限管理,利用JWT工具生成token,返回给登录接口。在访问其他接口时,采用Bearer Token的方式携带登录时获取的token进行验证,token验证通过,到达ctrl层的对应接口,验证失败,返回401错误。登录与验证的流程如下。

 登录流程

接口验证流程 

接下来介绍最核心的继承WebSecurityConfigurerAdapter类的配置类SysSecurityConfig,然后我会根据登录流程,接口验证流程,登出流程依次讲解本系统部分代码,整个系统代码请到github获取。

2.系统总体概览

2.1 代码层次结构

 上图展示了本项目的全部核心代码类, 除了经典的ctrl, service, model, dao层以外,项目还加入一些自定义的异常(PermissionDeniedException),封装的返回结果(BaseResponse<T>),自定义返回的状态码(ResultCode)以及各种handler处理器。

1.2 代码依赖配置

<parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.3.1.RELEASE</version>
    </parent>

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
        <java.version>1.8</java.version>
        <org.springframework.boot.version>2.3.1.RELEASE</org.springframework.boot.version>
        <org.apache.common.lang3.version>3.12.0</org.apache.common.lang3.version>
        <org.apache.common.beanutils.version>1.9.4</org.apache.common.beanutils.version>
        <com.baomidou.mybatis-plus.version>3.4.3</com.baomidou.mybatis-plus.version>
        <mysql.connector.java.version>8.0.12</mysql.connector.java.version>
        <jjwt-version>0.9.0</jjwt-version>
        <com.alibaba.fastjson.version>1.2.76</com.alibaba.fastjson.version>

        <!--Lombok-->
        <lombok.version>1.18.10</lombok.version>
        <commons-io.version>2.6</commons-io.version>
        <javadoc.version>3.0.0</javadoc.version>
        <maven-release-plugin.version>2.5.3</maven-release-plugin.version>
    </properties>

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-web</artifactId>
                <version>${org.springframework.boot.version}</version>
            </dependency>
            <dependency>
                <groupId>org.projectlombok</groupId>
                <artifactId>lombok</artifactId>
                <version>${lombok.version}</version>
            </dependency>
            <dependency>
                <groupId>org.apache.commons</groupId>
                <artifactId>commons-lang3</artifactId>
                <version>${org.apache.common.lang3.version}</version>
            </dependency>

            <dependency>
                <groupId>commons-beanutils</groupId>
                <artifactId>commons-beanutils</artifactId>
                <version>${org.apache.common.beanutils.version}</version>
            </dependency>
            <dependency>
                <groupId>com.baomidou</groupId>
                <artifactId>mybatis-plus-boot-starter</artifactId>
                <version>${com.baomidou.mybatis-plus.version}</version>
            </dependency>

            <!-- https://mvnrepository.com/artifact/mysql/mysql-connector-java -->
            <dependency>
                <groupId>mysql</groupId>
                <artifactId>mysql-connector-java</artifactId>
                <version>${mysql.connector.java.version}</version>
            </dependency>
            <dependency>
                <groupId>io.jsonwebtoken</groupId>
                <artifactId>jjwt</artifactId>
                <version>${jjwt-version}</version>
            </dependency>

            <dependency>
                <groupId>com.alibaba</groupId>
                <artifactId>fastjson</artifactId>
                <version>${com.alibaba.fastjson.version}</version>
            </dependency>
        </dependencies>

    </dependencyManagement>

    <dependencies>
        <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.projectlombok</groupId>
            <artifactId>lombok</artifactId>
        </dependency>
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-lang3</artifactId>
        </dependency>
        <dependency>
            <groupId>commons-beanutils</groupId>
            <artifactId>commons-beanutils</artifactId>
        </dependency>
        //关于数据库的连接以及持久层框架本项目实际并没有用到
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
        </dependency>
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
        </dependency>
        //jwt工具库
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt</artifactId>
        </dependency>
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>fastjson</artifactId>
        </dependency>
    </dependencies>

2.系统最核心配置代码讲解

2.1 Spring Security 权限控制核心配置类

/**
 * @program: authority-management-sys
 * @author: zgr
 * @create: 2021-07-25 20:52
 **/
@Configuration
@EnableWebSecurity
public class SysSecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private final MyAuthenticationEntryPoint unauthorizedHandler;

    @Autowired
    private final AccessDeniedHandler accessDeniedHandler;

    @Autowired
    private final AuthenticationTokenFilter authenticationTokenFilter;

    @Autowired
    private final SysLogoutHandler sysLogoutHandler;

    @Autowired
    private final SysLogoutSuccessHandler sysLogoutSuccessHandler;
    @Autowired
    private SysUserService sysUserService;

    @Autowired
    public SysSecurityConfig(MyAuthenticationEntryPoint unauthorizedHandler,
                             @Qualifier("RestAuthenticationAccessDeniedHandler") AccessDeniedHandler accessDeniedHandler, AuthenticationTokenFilter authenticationTokenFilter, SysLogoutHandler sysLogoutHandler, SysLogoutSuccessHandler sysLogoutSuccessHandler) {
        this.unauthorizedHandler = unauthorizedHandler;
        this.accessDeniedHandler = accessDeniedHandler;
        this.authenticationTokenFilter = authenticationTokenFilter;
        this.sysLogoutHandler = sysLogoutHandler;
        this.sysLogoutSuccessHandler = sysLogoutSuccessHandler;
    }

    /**
     * 解决 无法直接注入 AuthenticationManager
     *
     * @return
     * @throws Exception
     */
    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    /**
     * 这里是对认证管理器的添加配置,添加自定义的用户查询服务
     *
     * @param auth
     * @throws Exception
     */
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(sysUserService).passwordEncoder(new BCryptPasswordEncoder());
        ;
    }

    /**
     * 配置不需要安全验证的接口地址
     *
     * @param web
     */
    @Override
    public void configure(WebSecurity web) {
        //配置允许匿名访问的接口,比如swagger地址,系统文档地址
        web.ignoring().antMatchers("/success/logout-page");
    }


    /**
     * 安全请求配置,这里配置的是security的部分,这里配置全部通过,安全拦截在资源服务的配置文件中配置,
     * 要不然访问未验证的接口将重定向到登录页面,前后端分离的情况下这样并不友好,无权访问接口返回相关错误信息即可
     *
     * @param http
     * @return void
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .logout().addLogoutHandler(sysLogoutHandler).logoutSuccessHandler(sysLogoutSuccessHandler)
                .and()
                // 由于使用的是JWT,我们这里不需要csrf
                .csrf().disable()
                // 权限不足处理类
                .exceptionHandling().accessDeniedHandler(accessDeniedHandler).and()
                // 认证失败处理类
                .exceptionHandling().authenticationEntryPoint(unauthorizedHandler).and()
                // 基于token,所以不需要session
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
                .authorizeRequests()
                // 对于登录login要允许匿名访问
                .antMatchers(HttpMethod.POST, "/login").permitAll()
                // 访问接口的测试 需要拥有admin权限,实际环境中,可以配置在filter中进行权限的验证
                .antMatchers("/test/page").hasAuthority("admin")
                // 除上面外的所有请求全部需要鉴权认证
                .anyRequest().authenticated();
        // 禁用缓存
        http.headers().cacheControl();
        http.addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
    }
}

2.2 jwt工具类

**
 * @program: authority-management-sys
 * @author: zgr
 * @create: 2021-07-25 21:06
 **/
@Component
@Slf4j
public class JwtTokenUtil {

    private Map<String, String> tokenMap = new ConcurrentHashMap<>(32);

    private Date generateExpirationDate(Long expiration) {
        return new Date(System.currentTimeMillis() + expiration);
    }

    /**
     * 生成令牌
     *
     * @param userDetail 用户
     * @return 令牌
     */
    public String generateAccessToken(SysUserDetails userDetail) {
        Map<String, Object> claims = generateClaims(userDetail);
        return generateAccessToken(userDetail.getUsername(), claims);
    }

    public boolean checkToken(String userName, String token) {
        /*1.token对比,是否存在
         *2.token是否过期,过期应该重新登录
         **/
        Long expirationTime = getExpirationTime(token);
        return userName != null
                && tokenMap.containsKey(userName)
                && tokenMap.get(userName).equals(token)
                && expirationTime != null
                && expirationTime > System.currentTimeMillis();
    }

    public void putToken(String userName, String token) {
        tokenMap.put(userName, token);
    }

    public void deleteToken(String userName) {
        tokenMap.remove(userName);
    }


    /**
     * 生成token
     *
     * @param subject 用户名
     * @param claims
     * @return
     */
    private String generateAccessToken(String subject, Map<String, Object> claims) {
        return generateToken(subject, claims);
    }

    /**
     * 根据token 获取用户信息
     *
     * @param token
     * @return
     */
    public SysUserDetails getUserDetails(String token) {
        SysUserDetails userDetail;
        try {
            final Claims claims = getClaims(token);
            Integer userId = Integer.parseInt(claims.get(Constant.CLAIM_KEY_USER_ID).toString());
            String username = getUsername(token);
            String roleName = claims.get(Constant.CLAIM_KEY_AUTHORITIES).toString();
            Role role = Role.builder().name(roleName).build();
            userDetail = new SysUserDetails(userId, username, null, role);
            log.info("user details {}", userDetail.toString());
        } catch (Exception e) {
            log.error("获取用户详情出错", e);
            userDetail = null;
        }
        return userDetail;
    }

    /**
     * 生成token
     *
     * @param subject 用户名
     * @param claims  声明
     * @return
     */
    private String generateToken(String subject, Map<String, Object> claims) {
        return Jwts.builder()
                .setClaims(claims)
                // sub(Subject):代表这个JWT的主体,即它的所有人,这个是一个json格式的字符串,可以存放什么userid,roldid之类的,作为什么用户的唯一标志。
                .setSubject(subject)
                // 设置jti(JWT ID):是JWT的唯一标识,根据业务需要,这个可以设置为一个不重复的值,主要用来作为一次性token,从而回避重放攻击。
                .setId(UUID.randomUUID().toString())
                // iat: jwt的签发时间
                .setIssuedAt(new Date())
                //有效时长
                .setExpiration(generateExpirationDate(Constant.ACCESS_TOKEN_EXPIRATION))
                //压缩格式
                .compressWith(CompressionCodecs.DEFLATE)
                //secret在实际使用中可以做成配置项
                .signWith(Constant.SIGNATURE_ALGORITHM, Constant.JWT_SECRET)
                .compact();
    }

    /**
     * 根据token 获取用户名
     *
     * @param token
     * @return
     */
    public String getUsername(String token) {
        String username;
        try {
            final Claims claims = getClaims(token);
            username = claims.getSubject();
        } catch (Exception e) {
            log.error("获取用户名出错", e);
            username = null;
        }
        return username;
    }

    public Long getExpirationTime(String token) {
        Long expirationTime;
        try {
            final Claims claims = getClaims(token);
            expirationTime = claims.getExpiration().getTime();
        } catch (Exception e) {
            expirationTime = null;
        }
        return expirationTime;
    }

    /**
     * 根据token 获取用户ID
     *
     * @param token
     * @return
     */
    private Integer getUserId(String token) {
        Integer userId;
        try {
            final Claims claims = getClaims(token);
            userId = Integer.parseInt((String) claims.get(Constant.CLAIM_KEY_USER_ID));
        } catch (Exception e) {
            userId = null;
        }
        return userId;
    }

    /***
     * 解析token 信息
     * @param token
     * @return
     */
    private Claims getClaims(String token) {
        Claims claims;
        try {
            claims = Jwts.parser()
                    .setSigningKey(Constant.JWT_SECRET)
                    .parseClaimsJws(token)
                    .getBody();
        } catch (Exception e) {
            claims = null;
        }
        return claims;
    }

    private Map<String, Object> generateClaims(SysUserDetails userDetail) {
        Map<String, Object> userDetails = new HashMap<>(16);
        userDetails.put(Constant.CLAIM_KEY_USER_ID, userDetail.getId());
        userDetails.put(Constant.CLAIM_KEY_AUTHORITIES, authoritiesToArray(userDetail.getAuthorities()).get(0));
        return userDetails;
    }


    private List authoritiesToArray(Collection<? extends GrantedAuthority> authorities) {
        List<String> list = new ArrayList<>();
        for (GrantedAuthority ga : authorities) {
            list.add(ga.getAuthority());
        }
        return list;
    }

}

3.登录流程代码详解

登录流程文字描述:开启登录--->进入AuthenticationTokenFilter(直接跳过)--->进入ctrl层的LoginController中的login方法--->进入service逻辑层,依据用户名查找用户,组装用户详情,生成对应token--->接口返回token。ctrl层的没有任何逻辑,所以本节只展示比较重要的AuthenticationTokenFilter以及逻辑层的代码。

3.1 过滤器AuthenticationTokenFilter

此处说一下个人理解,AuthenticationTokenFilter是所有的访问接口的请求都会经过的,一般的接口,filter过滤通过,不能生成对应的Authentication放入SecurityContextHolder.getContext().setAuthentication()中,且在2系统最核心配置代码讲解中没有允许其匿名访问,那么接口就会返回权限不足的信息。

/**
 * @program: authority-management-sys
 * @author: zgr
 * @create: 2021-07-26 12:02
 **/


@Slf4j
@Component
public class AuthenticationTokenFilter extends OncePerRequestFilter {

    @Autowired
    private JwtTokenUtil jwtTokenUtil;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
            throws ServletException, IOException {
        if (!request.getRequestURL().toString().contains(Constant.LOGIN_URL)) {
            //取出token
            String token = request.getHeader(Constant.TOKEN_HEADER);
            if (StringUtils.isNotEmpty(token) && token.startsWith(Constant.TOKEN_STARTER)) {
                token = token.substring(Constant.TOKEN_STARTER.length()).trim();
            } else {
                token = null;
            }

            //关于token形式验证通过,验证token的内容
            String username = jwtTokenUtil.getUsername(token);
            if (username != null && jwtTokenUtil.checkToken(username, token) && SecurityContextHolder.getContext().getAuthentication() == null) {
                log.info("{} access the {} API", username, request.getRequestURL());
                SysUserDetails userDetails = jwtTokenUtil.getUserDetails(token);
                UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
                authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                log.info(String.format("Authenticated userDetail %s, setting security context", username));
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        }
        chain.doFilter(request, response);
    }
}

  3.2 service查找用户生成token

/**
 * @program: authority-management-sys
 * @author: zgr
 * @create: 2021-07-26 10:20
 **/

@Service
@Slf4j
public class LoginServiceImpl implements LoginService {

    @Autowired
    private AuthenticationManager authenticationManager;

    @Autowired
    private JwtTokenUtil jwtTokenUtil;

    @Override
    public String loginSys(LoginUserEntity loginUser) {

        final Authentication authentication = authenticate(loginUser.getUsername(), loginUser.getPassword());
        //存储认证信息
        SecurityContextHolder.getContext().setAuthentication(authentication);
        //生成token
        log.info("{} 登录,生成token", loginUser.getUsername());
        final SysUserDetails userDetail = (SysUserDetails) authentication.getPrincipal();
        final String token = jwtTokenUtil.generateAccessToken(userDetail);
        //存储token
        jwtTokenUtil.putToken(loginUser.getUsername(), token);
        return token;
    }


    private Authentication authenticate(String username, String password) {
        try {
            // 该方法会去调用userDetailsService.loadUserByUsername()去验证用户名和密码,
            // 如果正确,则存储该用户名密码到security 的 context中
            return authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(username, password));
        } catch (DisabledException | BadCredentialsException e) {
            throw new PermissionDeniedException("用户名或密码错误,请重新登录");
        }
    }
}

3.3 依据用户名查找用户,生成对应UserDetails

@Service
@Slf4j
public class SysUserServiceImpl implements SysUserService {
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        log.info("根据用户名{}查询用户信息", username);
        //此处实际可以从数据库中查找对应的用户名
        User user;
        if (username.equals("admin")) {
            user = User.builder()
                    .id(1)
                    .username("admin")
                    .password("$2a$10$blrIf6.vDYUAGbq.8fk2heScZYVgMl8lFAUWvPi1aZ9aiCar3pALe")
                    .test("this is test")
                    .build();
        }else {
            throw new UsernameNotFoundException(username + "不存在");
        }
        Role role = Role.builder()
                .id(1)
                .name("admin")
                .build();


        //这里权限列表,这个为方便直接下(实际开发中查询用户时连表查询出权限)
        return new SysUserDetails(user.getId(), user.getUsername(), user.getPassword(), role);
    }
}

3.4 登录结果展示

3.4.1 正确登录结果展示

 3.4.2 用户名错误登录结果展示

3.4.3 密码错误登录结果展示

4.接口权限验证 

 访问接口--->进入AuthenticationTokenFilter,1.验证token的有效性,2.从token中取出信息组装成Authentication放入SecurityContextHolder.getContext().setAuthentication()中,验证通过--->访问对应ctrl层中的接口。

4.1 ctrl层中的接口

 @GetMapping("/test/page")
    public BaseResponse<String> testPage() {
        return BaseResponse.success("test page");
    }

4.2 验证结果展示

4.2.1 正确验证结果展示

4.2.2 不带token验证结果展示

4.2.3 token错误验证结果展示

5 退出系统

通过Spring Security 自带的LogoutFilter来执行,通过自定义的继承LogoutHandler类SysLogoutHandler处理登出逻辑,通过自定义的继承LogoutSuccessHandler类SysLogoutSuccessHandler实现登出成功后的逻辑。

5.1继承LogoutHandler类SysLogoutHandler

/**
 * @program: authority-management-sys
 * @author: zgr
 * @create: 2021-07-29 10:46
 **/
@Configuration
@Slf4j
public class SysLogoutHandler implements LogoutHandler {

    @Autowired
    private JwtTokenUtil jwtTokenUtil;

    @Override
    public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
        //是用户登出具体逻辑的实现,可以记录用户下线的时间,ip,以下为删除token的逻辑
        String token = request.getHeader(Constant.TOKEN_HEADER).substring(Constant.TOKEN_STARTER.length());
        String username = jwtTokenUtil.getUsername(token);
        if (username == null) {
            throw new PermissionDeniedException(ResultCode.UN_AUTHORIZED);
        }
        jwtTokenUtil.deleteToken(username);

    }
}

5.2 继承LogoutSuccessHandler类SysLogoutSuccessHandler

/**
 * @program: authority-management-sys
 * @author: zgr
 * @create: 2021-07-29 11:08
 **/
@Configuration
@Slf4j
public class SysLogoutSuccessHandler implements LogoutSuccessHandler {
    @Override
    public void onLogoutSuccess(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Authentication authentication) throws IOException, ServletException {
        //登出成功的执行的逻辑
        ResultUtil.writeResponse(httpServletResponse, ResultCode.SUCCESS, ResultCode.SUCCESS.getMsg());
    }
}

5.3 登出结果展示

登录系统获取token--->访问验证接口--->退出系统--->再次访问验证接口

5.3.1 登录系统获取token结果展示

5.3.2 访问验证接口结果展示 

5.3.3 登出系统结果展示

 5.3.4 再次访问验证接口展示

6 写在最后

以上只是对系统关键代码的一个展示,有很多引用的工具类代码,最关键的token的生成验证以及从token中获取用户信息没有展示,想要代码可以找到最后的GitHub地址进行代码的clone。

代码目前还是实验阶段,离生产应用还有一段距离,比如用户查找没有与当下流行的基于角色的权限管理系统(RBAC)结合起来。在token的存储上,后期可以放在redis中进行缓存,微服务系统在进行token权限验证的时候直接访问redis做验证。

本文是对自己学习经验的一个总结,总觉得能写出来的东西才是自己掌握的东西,有很多不对或遗漏之处请指出,不尽感激。

7 引用

1.Spring Security(一)--Architecture Overview

2.Spring Security(二)--Guides

3.Spring Security(三)--核心配置解读

4.Spring Security(四)--核心过滤器源码分析

5.Spring Security(五)--动手实现一个IP_Login

6.Spring Boot整合实战Spring Security JWT权限鉴权系统 

8 本文对应的GitHub地址

GitHub - airhonor/authority-management-sys: 基于spring-security和jwt实现基于角色的权限管理系统

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值