springboot+springsecurity+jwt实现基于token认证(可能有点详细)

准备工作

1、新建一个springboot项目并导入下面依赖

<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-security</artifactId>
</dependency>

<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>0.9.0</version>
</dependency>

<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>fastjson</artifactId>
    <version>1.2.60</version>
</dependency>

2、建一个测试数据库并建立user表

CREATE TABLE`user`(
`id` BIGINT NOT NULL AUTO_INCREMENT,
`user_name` VARCHAR(50),
`password` VARCHAR(200),
PRIMARY KEY ( `id` )
)ENGINE=InnoDB DEFAULT CHARSET=utf8;

3、建实体类user,跟表对应。以及相关的dao、service

4、新建一个类实现UserDetails类,并重写所有的方法:

public class LoginUser implements UserDetails {

    private User user; //我们自己的user实体类

    public User getUser() {
        return user;
    }

    public void setUser(User user) {
        this.user = user;
    }

    //返回该账号下所有的权限信息
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        List<GrantedAuthority> authorities = new ArrayList<>();
        //权限标识前面要有 ROLE_
        for (Role role : user.getRoles()) {
            authorities.add(new SimpleGrantedAuthority("ROLE_"+role.getName()));
        }
        return authorities;
    }

    //获取账号密码
    @Override
    public String getPassword() {
        return user.getPassword();
    }

    //获取账号名(登录凭证)
    @Override
    public String getUsername() {
        return user.getUserName();
    }

    //账户没有过期(视情况而定,默认返回true)
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    //账户没被锁定(视情况而定,默认返回true)
    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    //密码没有过期(视情况而定,默认返回true)
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    //账号可用(视情况而定,默认返回true)
    @Override
    public boolean isEnabled() {
        return true;
    }

开始搭建

因为springsecurity默认开启的是httpBasic()的认证方式,在我们不做任何配置的情况下,启动带有springsecurity依赖的项目时候,会进入一个默认登录页面。
在这里插入图片描述
默认用户名是user,密码是一串随机码。会在启动项目的控制台中打印出来。
在这里插入图片描述
为了方便,我们可以在yml文件中自定义httpBasic()认证的用户名密码

spring:
  security:
    user:
      name: yxj
      password: 123456

当然,这种配置只是暂时的,后面我们要将账号密码改为从数据库查询并认证。

接下来就要对springsecurity做一些必要的配置以及说明。

1、首先我们要新建一个配置类,继承WebSecurityConfigurerAdapter类。并重写两个方法:

@EnableWebSecurity
//注解别忘了
public class SercurityConfig extends WebSecurityConfigurerAdapter {
    
    //实现了UserDetailsService的类
    @Autowired
    private UserDetailsServiceImpl userDetailsService;
    //平常的service层
    @Autowired
    private UserService userService;
    //生成、解析token的工具类
    @Autowired
    private TokenUtils tokenUtils;
    //数据源,配置进 记住我 功能里。
    @Autowired
    private DataSource dataSource;

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


    public PersistentTokenRepository persistentTokenRepository(){
        JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl();
        jdbcTokenRepository.setDataSource(dataSource);
        return jdbcTokenRepository;
    }

@Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
  //springsecurity通过userDetailsService的loadUserByUsername方法
  //去数据库里查询用户并认证
        auth.userDetailsService(userDetailsService)
           //设置密码加密方式,默认为BCryptPasswordEncoder,也是springsecurity默认的密码加密方式
                //这个必须要
                .passwordEncoder(passwordEncoder());
    }
    
 @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                //(认证后)控制访问 /user 路径需要user或者admin权限
                .antMatchers("/user").hasAnyRole("user","admin")
                //对login路径放行,不需要认证。
                .antMatchers("/login/**").permitAll()
                //除上面配置的路径外,所有的请求都需要进行认证后才能访问
                .anyRequest().authenticated()
                /* 连接 */
                .and()
                //开启formLogin方式认证,另外一种认证方式是httpBasic()
                .formLogin()
                //假如请求没认证,则会转发到 /login 请求中
                //前后端分离项目 /login 可以是返回一个json字符串
                //前后端不分离项目 /login 一般返回的是一个页面
                //(登录页面或是提示用户未登录的页面)
                .loginPage("/login")
                //处理登录请求的url
                .loginProcessingUrl("/login/doLogin")
                //认证通过后的事件处理(在这里返回token)
                .successHandler(successHandler())
                //认证失败后的事件处理
                .failureHandler(failureHandler())
                .and()
       //设置登录注销url,这个无需我们开发,springsecurity已帮我们做好
                .logout().logoutUrl("/logout").permitAll()
                .and()
                //设置访问被拒绝后的事件(用来处理权限不足时的返回)
                .exceptionHandling()
                .accessDeniedHandler(accessDeniedHandler())
                .and()
                //配置 记住我 功能,
                .rememberMe()
                //后面会说明这个persistentTokenRepository
                .tokenRepository(persistentTokenRepository())
                //设置 记住我 的过期时间。
                .tokenValiditySeconds(60*60*24)
                //记住我 功能的用户校验(后面会提到)
                .userDetailsService(userDetailsService)
                .and()
                //关闭跨域请求访问,防止跨域请求伪造
                .csrf().disable();
                //关闭默认的httpBasic()的认证方式
             //到这里,我们上面配的springsecurity账号密码yml就不起作用了
        http.httpBasic().disable();
    }

    public AuthenticationSuccessHandler successHandler(){
        return new AuthenticationSuccessHandler() {
            @Override
            public void onAuthenticationSuccess(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Authentication authentication) throws IOException, ServletException {
                httpServletResponse.setCharacterEncoding("utf-8");
                httpServletResponse.setContentType("application/json;charset=utf-8");
                String userName = SecurityContextUtils.getPrincipal();
                User user = userService.queryUserByName(userName);
                Map<String,Object> claims = new HashMap<>();
                claims.put("userId",user.getId().toString());
                String token = tokenUtils.createToken(claims);
                PrintWriter writer = httpServletResponse.getWriter();
                Object ok = JSON.toJSON(AjaxResult.success("OK", token));
                writer.println(ok);
                writer.flush();
            }
        };
    }

    public AuthenticationFailureHandler failureHandler(){
        return new AuthenticationFailureHandler() {
            @Override
            public void onAuthenticationFailure(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException {
                httpServletResponse.setCharacterEncoding("utf-8");
                httpServletResponse.setContentType("application/json;charset=utf-8");
                PrintWriter writer = httpServletResponse.getWriter();
                Object ok = JSON.toJSON(AjaxResult.error("认证失败!"));
                writer.println(ok);
                writer.flush();
            }
        };
    }

    public AccessDeniedHandler accessDeniedHandler(){
        return new AccessDeniedHandler() {
            @Override
            public void handle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AccessDeniedException e) throws IOException, ServletException {
                httpServletResponse.setCharacterEncoding("utf-8");
                httpServletResponse.setContentType("application/json;charset=utf-8");
                PrintWriter writer = httpServletResponse.getWriter();
                Object ok = JSON.toJSON(AjaxResult.error("权限不足!"));
                writer.println(ok);
                writer.flush();
            }
        };
    }
}

2、SpringSecurity配置完后,接下来说明下如何生成token。这里我们用的是JWT(Json + Web +Token)
生成token之前,我们需要在yml配置一下(具体使用关联JWT用法)

  # token配置
token:
  # 令牌自定义标识,可以是任何字符,要你的客户端请求的时候带上做验证
  header: Authorization
  # 令牌密钥,用来生成token
  secret: yxj-key
  # 令牌有效期(默认30分钟)
  expireTime: 30
public AuthenticationSuccessHandler successHandler(){
        return new AuthenticationSuccessHandler() {
            @Override
            public void onAuthenticationSuccess(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Authentication authentication) throws IOException, ServletException {
                httpServletResponse.setCharacterEncoding("utf-8");
                httpServletResponse.setContentType("application/json;charset=utf-8");
               //从这里开始
                String userName = SecurityContextUtils.getPrincipal();
               //上面这一段其实就是:
//SecurityContextHolder.getContext().getAuthentication().getPrincipal();
//从springsecurity上下文中取出你的认证信息。
                User user = userService.queryUserByName(userName);
                //一个普通的根据账号查询。
                Map<String,Object> claims = new HashMap<>();
      //要放到token结构里去的自定义数据(一般会放userId、用户权限等)
                claims.put("userId",user.getId().toString());
                String token = tokenUtils.createToken(claims);
          //生成token的方法,下面会贴出tokenUtils工具类的代码
                PrintWriter writer = httpServletResponse.getWriter();
                Object ok = JSON.toJSON(AjaxResult.success("OK", token));
                writer.println(ok);
                writer.flush();
            }
        };
    }

看这一段代码。在用户认证成功后,从上下文中取出用户认证信息并查询出用户id,放进token结构体里并返回给客户端

3、token生成并返回给客户端后,根据使用场景,下次客户端再次请求的时候就需要带上我们返回的token。这个时候我们要去校验下token:

@Component
public class JwtTokenFilter extends OncePerRequestFilter {
//这里继承了OncePerRequestFilter过滤器
    private Logger logger = LoggerFactory.getLogger(JwtTokenFilter.class);
    @Autowired
    private TokenUtils tokenUtils;//工具类
    @Autowired
    private UserService userService;//普通的service

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
        String token = request.getHeader(Const.AUTH);//上面配置的自定义标识 Authorization
        if (token != null && token.startsWith(Const.TOKEN_PREFIX)) {//Bearer (有个空格)标识
            //生成的token中带有Bearer 标识,去掉标识后就剩纯粹的token了。
            String substring = token.substring(Const.TOKEN_PREFIX.length());
            //解析token拿到我们生成token的时候存进去的userId
            Claims claims = tokenUtils.parseToken(substring);
            Object userId = claims.get("userId");
            User user = userService.queryUser(Long.parseLong(userId.toString()));
            if (user != null){
            //将查询到的用户信息取其账号(登录凭证)以及密码去生成一个Authentication对象
            UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(user.getUserName(),user.getPassword());
            //将Authentication对象放进springsecurity上下文中(进行认证操作)
            SecurityContextHolder.getContext().setAuthentication(authenticationToken);
            } 
        }
        //走下一条过滤器
        chain.doFilter(request,response);
    }
}

这样一来,我们的验证token的过滤器也配置好了。这里过滤器继承OncePerRequestFilter 是因为OncePerRequestFilter对每个请求只会执行一次,这样就保证每个请求都一定会先经过这里校验一下再去访问具体资源。

5、这样一来,从springsecurity配置到认证成功后生成token再到请求时校验token。这一主流程就走完了。下面就是一些细节补充

  • 先是token工具类:
@Component
@Lazy  //懒加载
public class TokenUtils implements Serializable {

    private static final long serialVersionUID = -5625635588908941275L;

    // 令牌自定义标识
    @Value("${token.header}")
    private String header;

    // 令牌秘钥
    @Value("${token.secret}")
    private String secret;

    // 令牌有效期(默认30分钟)
    @Value("${token.expireTime}")
    private int expireTime;

    /**
     * 获取token
     * @param claims
     * @return
     */
    public String createToken(Map<String, Object> claims){
        long now = System.currentTimeMillis()+(expireTime * 60 * 1000);
        return Jwts.builder()
                .setIssuer("yxj")
                .addClaims(claims)
                .setExpiration(new Date(now))
                .signWith(SignatureAlgorithm.HS256, secret).compact();
    }

    /**
     * 解析token
     * @param token
     * @return
     */
    public Claims parseToken(String token){
        JwtParser jwtParser = Jwts.parser().setSigningKey(secret);
        Jws<Claims> claimsJws = jwtParser.parseClaimsJws(token);
        Claims body = claimsJws.getBody();
        return body;
    }
}
  • 再是我们自定义springsecurity配置类里的UserDetailsServiceImpl
@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    @Autowired
    private UserService userService; //普通service

    @Override 
    //实现UserDetailsService 并重写loadUserByUsername方法,返回我们自定义的LoginUser对象
    //在你进行一个登录或者认证操作的时候,srpingsecurity就是从这里查询出你的用户信息进行校验。
    public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
        User user = userService.queryUserByName(s);
        if (user == null) {
            return null;
        }
        LoginUser loginUser = new LoginUser();
        loginUser.setUser(user);
        return loginUser;
    }
}
  • 最后是 记住我 功能:

    HttpSecurity.rememberMe()
                .tokenRepository(persistentTokenRepository())
                .tokenValiditySeconds(60*60*24)
                //这个userDetailsService就是刚刚补充的UserDetailsServiceImpl,因为你记住我以后,下次点击进来,springsecurity也还是会去校验一下你的认证信息。
                .userDetailsService(userDetailsService)
   //摘自上面springsecurity配置 记住我 功能          
      
 public PersistentTokenRepository persistentTokenRepository(){
        JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl();
        jdbcTokenRepository.setDataSource(dataSource);
        return jdbcTokenRepository;
    }

开启记住我功能,你必须的先在数据库里建一张表 persistent_logins。因为spingsecurity用的是数据库存储的方式实现记住我功能。表结构如下:
在这里插入图片描述
在你认证的时候springsecrity会自动在这个表保存token信息。同样你重启项目或者下次进来的时候,springsecurity会在这个表里取出username,然后经过UserDetailsServiceImpl去校验。

然后你前台表单登录的时候也必须带上 remeber-me

<input name="remember-me" type="checkbox"> 下次自动登录

或者 remeber-me 为true(postman测试)
在这里插入图片描述
最后提一点,SecurityContextHolder上下文里存取的认证信息依然还是存在session中的。如果认证服务要集群部署的话,可以集成SpringSession,把认证信息存进redis里。

<dependency>
	<groupId>org.springframework.session</groupId>
	<artifactId>spring-session-data-redis</artifactId>
</dependency>
@Configuration
@EnableRedisHttpSession(redisNamespace = "yxj:auth")
public class SessionConfig {
}

这样我们不管是SecurityContextHolder.getContext().setAuthentication(authenticationToken);
还是
SecurityContextHolder.getContext().getAuthentication().getPrincipal();
就都是从redis里面存取,这样就方便集群部署。解决session问题


版权声明:本文为qq_42394044原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接和本声明。