SpringBoot + SpringSecurity + JWT 实现后端校验

SpringBoot + SpringSecurity + JWT 实现后端校验,第1张

SpringBoot + SpringSecurity + JWT 实现后端校验

集成了SpringSecurity后,又花了一天时间集成了JWT,记录一下。
完整代码在我的Gitee仓库:FreeFancy

0、一些背景

HTTP是无状态的,我们不能确定两次请求是不是一个用户发出的,就必须要每次都进行认证。传统方法解决这个问题是采用session和cookie机制,虽然解决了问题,但有一些缺点:

  1. session通常是保存在内存中的,用户数量如果较多,服务器的压力大;
  2. 因为session是保存在最初认证的那台服务器上,换了一台服务器又需要认证,不能很好的适应分布式的环境;
  3. 有CSRF的风险。因为是基于cookie来进行用户识别的, cookie如果被截获,用户就会很容易受到跨站请求伪造的攻击。

后来人们提出了基于token的鉴权机制,它不需要服务器去记录用户的认证或会话信息,而是将token交给客户端保存,每次的请求的时候携带,服务器只要鉴定即可,这样做有一些优点:

  1. 服务器开销小;
  2. 不需要去考虑用户在哪一台服务器登录了,这就为应用的扩展提供了便利;
  3. 因为不依赖于session,就杜绝了CSRF的风险。

基于Token的鉴权机制应用最广的还是JWT(JSON WEB TOKEN)。这里我们不多解释JWT,相关文档很多请自动搜索。

1、实战 前置工作

这里是在SpringBoot已经简单整合了SpringSecurity的情况下开始整合JWT,这里前置工作有:
因为是前后端分离的情况,我们统一返回JSON,所以实现了

  • 登录成功的自定义处理;
  • 登录失败的自定义处理;
  • 未登录被拦截的自定义处理。

我们的用户信息是要在数据库中取的,所以我们要实现UserDetailsService接口,返回一个保存了用户信息的UserDetails对象,并在配置中注册。

部分配置文件

@Override
    protected void configure(HttpSecurity http) throws Exception {
        // 未登录拦截处理
        http.exceptionHandling()
                .authenticationEntryPoint(customizeAuthenticationEntryPoint);
        // 登录
        http.formLogin()
                //登录成功处理逻辑
                .successHandler(customizeAuthenticationSuccessHandler)
                //登录失败处理逻辑
                .failureHandler(customizeAuthenticationFailureHandler);
        // 登出
        http.logout()
                //登出成功处理逻辑
                .logoutSuccessHandler(customizeLogoutSuccessHandler);
        // 认证
        http.authorizeRequests()
                // 公共接口放行
                .antMatchers("/login", "/register").anonymous()
                // swagger静态资源放行
                .antMatchers(SWAGGER_SOURCE_PERMIT_ALL.split(",")).permitAll()
                .anyRequest().authenticated();
    }

这里贴出其中一个自定义处理,其他都是类似的,返回相应结果的JSON信息。

//登录被拦截自定义的处理
@Component
public class CustomizeAuthenticationEntryPoint implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException {
        ResponseJson responseJson = ResponseJson.failure(ResponseCode.USER_NOT_LOGGED_IN);
        httpServletResponse.setContentType("text/json;charset=utf-8");
        httpServletResponse.getWriter().write(JSON.toJSONString(responseJson));
    }
}
导包

    io.jsonwebtoken
    jjwt
    0.9.1

思路

看了很多别人的博客,思路并不清晰,开始还以为这东西搞起来很复杂,最后弄下其实还是不难的,需要配置的东西并不多。

  1. 对于新登录的用户,我们只要在登录成功后将TOKEN返回!,原本的逻辑并不需要修改,只要在上面我们自定义登录成功的处理中返回生成好的TOKEN。
  2. 对于登录过的用户发出的携带TOKEN的请求,我们要给它放行。
  3. 对于没有携带TOKEN的用户请求(要拦截的路径),给它打回去。
  4. JWT TOKEN的生成和解析。

思路是很自然的,得益于SpringSecurity实现起来也不难。

1、登录成功后返回TOKEN

首先,/login路径的请求是不需要携带TOKEN就可以访问的。

http.authorizeRequests()
                // 公共接口放行
                .antMatchers("/login", "/register").anonymous()

如果是表单登录,我们可以直接使用SpringSecurity自带的登录处理,请求参数中携带username,password,请求发给/login就可以了。
还记得我们的登录成功自定义处理嘛?我们在里面把token生成然后返回就可以了。

// 登录
        http.formLogin()
                //登录成功处理逻辑
                .successHandler(customizeAuthenticationSuccessHandler)
                //登录失败处理逻辑
                .failureHandler(customizeAuthenticationFailureHandler);
@Component
public class CustomizeAuthenticationSuccessHandler implements AuthenticationSuccessHandler {

    @Override
    public void onAuthenticationSuccess(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Authentication authentication) throws IOException {
    	// 这里是生成TOKEN返回,有些方法还没讲,先理思路,知道是生成TOKEN即可!
    	JwtUser jwtUser = (JwtUser) authentication.getPrincipal();
        Collection authorities = jwtUser.getAuthorities();
        StringBuilder authoritiesStr = new StringBuilder();
        for (GrantedAuthority authority : authorities) {
            authoritiesStr.append(authority.toString());
            authoritiesStr.append(JwtTokenUtils.SPLIT_CHAR);
        }
        String token = JwtTokenUtils.createToken(jwtUser.getId(), jwtUser.getUsername(), authoritiesStr.toString());
        //返回json数据
        HashMap hashMap = new HashMap<>(1);
        hashMap.put("Authorization", token);
        ResponseJson responseJson = ResponseJson.success(hashMap);
        //处理编码方式,防止中文乱码的情况
        httpServletResponse.setContentType("text/json;charset=utf-8");
        //塞到HttpServletResponse中返回给前台
        httpServletResponse.getWriter().write(JSON.toJSONString(responseJson));
    }
}

这样我们就完成了第一步。

2、携带TOKEN的请求,进行放行

SpringSecurity本质是一个拦截器连(Filter Chain),我们需要自定义一个拦截器取处理请求中的TOKEN,并为请求放行。

public class JwtAuthenticationFilter extends BasicAuthenticationFilter {

    public JwtAuthenticationFilter(AuthenticationManager authenticationManager) {
        super(authenticationManager);
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
        String headerToken = request.getHeader(JwtTokenUtils.TOKEN_HEADER);
        //如果请求头中没有token直接放行了,交给后续的拦截器处理。
        if(headerToken == null || !headerToken.startsWith(JwtTokenUtils.TOKEN_PREFIX)){
            chain.doFilter(request, response);
            return;
        }
        //请求头中有token且没过期
        if(!JwtTokenUtils.isExpiration(headerToken.replace(JwtTokenUtils.TOKEN_PREFIX,""))){
            //设置上下文,将认证的信息放进去,告诉后面的Filter不用再鉴权了!
            CustomizeUsernamePasswordAuthenticationToken authentication = getAuthentication(headerToken);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }
        super.doFilterInternal(request, response, chain);
    }
	//该方法就是从TOKNE中解析出用户的认证和鉴权信息
    private CustomizeUsernamePasswordAuthenticationToken getAuthentication(String headerToken){
        String token = headerToken.replace(JwtTokenUtils.TOKEN_PREFIX, "");
        Long userId = JwtTokenUtils.getUserId(token);
        String username = JwtTokenUtils.getUserName(token);
        // 设置权限
        String authoritiesStr = JwtTokenUtils.getUserAuthority(token);
        List authorities = new ArrayList<>();
        for (String s : authoritiesStr.split(JwtTokenUtils.SPLIT_CHAR)) {
            if (s != null && !"".equals(s)) {
                authorities.add(new SimpleGrantedAuthority(s));
            }
        }
        if(username != null){
            return new CustomizeUsernamePasswordAuthenticationToken(userId, username, null, authorities);
        }
        return null;
    }
}

我们要将这个Filter注册进SpringSecurity里,框架留有接口,很方便,但我们应该放在哪里呢?

// JWT
http.addFilterBefore(new JwtAuthenticationFilter(authenticationManager()), UsernamePasswordAuthenticationFilter.class);

UsernamePasswordAuthenticationFilter是用来登录认证的,我们放在它前面,将TOKEN解析出的用户认证信息写进SecurityContext里,后续就可以放行了。
另外这里提一嘴,因为基于TOKEN的认证鉴权不依赖于session,所以我们可以把session禁用了,当然也可以大胆的把csrf防护关闭。

// 禁用session
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);

http.csrf().disable();
3、拦截对于未携带TOKEN的请求

不是登录请求,又未携带TOKEN信息,会被后续的拦截器拦截,肯定是进不到系统的,后续拦截器报错,我们只要处理异常即可。
这里甚至不用特意处理,因为这和之前未登录拦截处理是一样的。

// 未登录拦截处理
        http.exceptionHandling()
                .authenticationEntryPoint(customizeAuthenticationEntryPoint);
@Component
public class CustomizeAuthenticationEntryPoint implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException {
        ResponseJson responseJson = ResponseJson.failure(ResponseCode.USER_NOT_LOGGED_IN);
        httpServletResponse.setContentType("text/json;charset=utf-8");
        httpServletResponse.getWriter().write(JSON.toJSONString(responseJson));
    }
}
4、生成和解析TOKEN

生成是在成功登录后返回,解析是在处理携带TOKEN的请求,解析出用户的认证信息。
那哪些信息要放在TOKEN里呢?username和authorities(权限信息)以及你想要的信息。
我们对UserDetailsService的实现要求loadUserByUsername()返回一个UserDetails对象。

public interface UserDetailsService {
	UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;

}

自带的对象就可以,它是UserDetails的实现类。

org.springframework.security.core.userdetails.User

但它不能保存用户的ID,我就想把ID保存进TOKEN里,下次携带的时候可以解析出来,那我就自己写一个实现类。

public class JwtUser implements UserDetails {
    private final Long id;
    private String password;
    private final String username;
    private final Set authorities;

    public JwtUser(Long id, String username, Set authorities){
        this.id = id;
        this.username = username;
        this.authorities = authorities;
    }

    public JwtUser(Long id, String username, String password, Set authorities){
        this.id = id;
        this.username = username;
        this.password = password;
        this.authorities = authorities;
    }

    public Long getId(){
        return id;
    }

    @Override
    public Collection 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 true;
    }
}

这个对象里的信息,就是我们要保存在TOKEN里的信息。
然后我们需要生成和解析TOKEN的一个工具类,这里直接给出我的。

public class JwtTokenUtils {
    public static final String TOKEN_HEADER = "Authorization";
    public static final String TOKEN_PREFIX = "Bearer ";
    public static final String SECRET = "[email protected]";
    public static final String SPLIT_CHAR = ";";

    
    private static final Long EXPIRATION = 60 * 60 * 3L;

    private static final String AUTHORITY = "authority";
    private static final String ID = "id";

    public static String createToken(Long id, String username, String authority){
        HashMap map = new HashMap<>(2);
        map.put(ID, id);
        map.put(AUTHORITY, authority);
        return TOKEN_PREFIX + Jwts.builder()
                .signWith(SignatureAlgorithm.HS512, SECRET)
                .setClaims(map)
                .setSubject(username)
                .setIssuedAt(new Date())
                .setExpiration(new Date(System.currentTimeMillis() + EXPIRATION * 1000))
                .compact();
    }

    
    public static Long getUserId(String token){
        Long id;
        try {
            id = (Long) getTokenBody(token).get(ID);
        } catch (Exception e){
            id = null;
        }
        return id;
    }

    
    public static String getUserName(String token){
        String username;
        try {
            username = getTokenBody(token).getSubject();
        } catch (Exception e){
            username = null;
        }
        return username;
    }

    public static String getUserAuthority(String token){
        return (String) getTokenBody(token).get(AUTHORITY);
    }

    private static Claims getTokenBody(String token){
        return Jwts.parser().setSigningKey(SECRET).parseClaimsJws(token).getBody();
    }

    
    public static boolean isExpiration(String token){
        return getTokenBody(token).getExpiration().before(new Date());
    }
}

生成TOKEN用在:

@Component
public class CustomizeAuthenticationSuccessHandler implements AuthenticationSuccessHandler {

    @Override
    public void onAuthenticationSuccess(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Authentication authentication) throws IOException {
        JwtUser jwtUser = (JwtUser) authentication.getPrincipal();
        Collection authorities = jwtUser.getAuthorities();
        StringBuilder authoritiesStr = new StringBuilder();
        for (GrantedAuthority authority : authorities) {
            authoritiesStr.append(authority.toString());
            authoritiesStr.append(JwtTokenUtils.SPLIT_CHAR);
        }
        String token = JwtTokenUtils.createToken(jwtUser.getId(), jwtUser.getUsername(), authoritiesStr.toString());
        //返回json数据
        HashMap hashMap = new HashMap<>(1);
        hashMap.put("Authorization", token);
        ResponseJson responseJson = ResponseJson.success(hashMap);
        //处理编码方式,防止中文乱码的情况
        httpServletResponse.setContentType("text/json;charset=utf-8");
        //塞到HttpServletResponse中返回给前台
        httpServletResponse.getWriter().write(JSON.toJSONString(responseJson));
    }
}

解析TOKEN用在:

public class JwtAuthenticationFilter extends BasicAuthenticationFilter {

    public JwtAuthenticationFilter(AuthenticationManager authenticationManager) {
        super(authenticationManager);
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
        String headerToken = request.getHeader(JwtTokenUtils.TOKEN_HEADER);
        //如果请求头中没有token直接放行了
        if(headerToken == null || !headerToken.startsWith(JwtTokenUtils.TOKEN_PREFIX)){
            chain.doFilter(request, response);
            return;
        }
        //请求头中有token且没过期
        if(!JwtTokenUtils.isExpiration(headerToken.replace(JwtTokenUtils.TOKEN_PREFIX,""))){
            //设置上下文
            CustomizeUsernamePasswordAuthenticationToken authentication = getAuthentication(headerToken);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }
        super.doFilterInternal(request, response, chain);
    }

    private CustomizeUsernamePasswordAuthenticationToken getAuthentication(String headerToken){
        String token = headerToken.replace(JwtTokenUtils.TOKEN_PREFIX, "");
        Long userId = JwtTokenUtils.getUserId(token);
        String username = JwtTokenUtils.getUserName(token);
        // 设置权限
        String authoritiesStr = JwtTokenUtils.getUserAuthority(token);
        List authorities = new ArrayList<>();
        for (String s : authoritiesStr.split(JwtTokenUtils.SPLIT_CHAR)) {
            if (s != null && !"".equals(s)) {
                authorities.add(new SimpleGrantedAuthority(s));
            }
        }
        if(username != null){
            return new CustomizeUsernamePasswordAuthenticationToken(userId, username, null, authorities);
        }
        return null;
    }
}

因为SecurityContextHolder.getContext().setAuthentication();要求传入Authentication的实现了,但框架自带的并不能携带我们要求携带的userId,所以自定义一个实现类。

public class CustomizeUsernamePasswordAuthenticationToken extends AbstractAuthenticationToken {

    private final Long userId;

    private final Object principal;

    private Object credentials;

    public CustomizeUsernamePasswordAuthenticationToken(Long userId, Object principal, Object credentials) {
        super(null);
        this.userId = userId;
        this.principal = principal;
        this.credentials = credentials;
        setAuthenticated(false);
    }

    public CustomizeUsernamePasswordAuthenticationToken(Long userId, Object principal, Object credentials,
                                               Collection authorities) {
        super(authorities);
        this.userId = userId;
        this.principal = principal;
        this.credentials = credentials;
        super.setAuthenticated(true);
    }

    public Long getUserId() {
        return this.userId;
    }

    @Override
    public Object getCredentials() {
        return this.credentials;
    }

    @Override
    public Object getPrincipal() {
        return this.principal;
    }

    @Override
    public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {
        Assert.isTrue(!isAuthenticated,
                "Cannot set this token to trusted - use constructor which takes a GrantedAuthority list instead");
        super.setAuthenticated(false);
    }

    @Override
    public void eraseCredentials() {
        super.eraseCredentials();
        this.credentials = null;
    }
}

5、使用SecurityContext中的信息

每次请求,SecurityContext中保有我们放入的一小段信息,这份信息可以在该次请求的时候拿出来。

public class SecurityUtils {
    public static Long getUserId(){
        Object token = SecurityContextHolder.getContext().getAuthentication();
        if (token instanceof CustomizeUsernamePasswordAuthenticationToken) {
            return ((CustomizeUsernamePasswordAuthenticationToken) token).getUserId();
        }
        return null;
    }
}

到这里就完成了集成,捋清了思路其实还是很简单的,可以根据业务调整的地方有很多,可以在理解原理后按需修改。
如有错误,恳请指出!

欢迎分享,转载请注明来源:内存溢出

原文地址: https://outofmemory.cn/zaji/5562988.html

(0)
打赏 微信扫一扫 微信扫一扫 支付宝扫一扫 支付宝扫一扫
上一篇 2022-12-14
下一篇 2022-12-14

发表评论

登录后才能评论

评论列表(0条)

保存