SpringBoot学习笔记【三】整合 Security + JWT + 异常处理

目录

一、添加依赖

二、配置

(一)JWT

(二)Security

(三)异常处理

三、总结


一、添加依赖

Spring Security是后台开发中经常使用的身份认证和访问权限控制框架,集成起来十分简单,对Restful接口的支持也比较完备,至于更多的介绍,可以参考 Spring Security 参考手册,在pom.xml中添加依赖如下:

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

JWT(Json Web Token)定义了一种简洁的,自包含的信息传递规范,在目前前后端分离的架构环境下使用十分频繁,但JWT也存在一定的局限性,在具体的业务场景下通常无法直接代替通常意义上的session,带着学习的目的,我们可以尝试一下简单的使用,详细介绍可以参考 JWT介绍,在pom.xml中添加依赖如下:

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

二、配置

(一)JWT

JWT的配置相对来讲是比较简单的,主要包括两个部分:1. 定义Token生成和解析的方法;2. 定义Token验证过滤器,话不多说,直接贴代码:

1. 定义Token的生成和解析

/**
 * Author GreedyStar
 * Date   2018/7/18
 */
public class JwtUtil {

    /**
     * 解析Token
     *
     * @param jsonWebToken   Token String
     * @param base64Security Base64Security Key
     * @return
     */
    public static Claims parseToken(String jsonWebToken, String base64Security) {
        try {
            Claims claims = Jwts.parser().setSigningKey(base64Security).parseClaimsJws(jsonWebToken).getBody();
            return claims;
        } catch (Exception ex) {
            return null;
        }
    }

    /**
     * 生成Token
     *
     * @param username 用户名
     * @param property 自定义的jwt公共属性(包括超时时长、签发者、base64Security key)
     * @return
     */
    public static String createToken(String username, JwtProperty property) {
        Calendar calendar = Calendar.getInstance();
        JwtBuilder builder = Jwts.builder()
                .setHeaderParam("typ", "JWT").setHeaderParam("alg", "HS256")
                .claim("username", username)
                .setIssuer(property.getIssuer())
                .signWith(SignatureAlgorithm.HS256, property.getBase64Security())
                .setExpiration(new Date(calendar.getTimeInMillis() + property.getExpiry())).setNotBefore(calendar.getTime());
        return builder.compact();
    }
}

2. 定义Token验证过滤器

/**
 * Token验证过滤器
 * <p>
 * Author GreedyStar
 * Date   2018/7/20
 */
public class JwtAuthenticationFilter extends OncePerRequestFilter{
    private JwtProperty jwtProperty;

    public JwtAuthenticationFilter(JwtProperty jwtProperty) {
        this.jwtProperty = jwtProperty;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, FilterChain filterChain) throws ServletException, IOException {
        httpServletResponse.setContentType("application/json");
        String authorization = httpServletRequest.getHeader("Authorization");
        // 放行GET请求
        if (httpServletRequest.getMethod().equals(String.valueOf(RequestMethod.GET))) {
            filterChain.doFilter(httpServletRequest, httpServletResponse);
            return;
        }
        if (StringUtils.isEmpty(authorization)) { // 未提供Token
            httpServletResponse.getWriter().write(JSON.toJSONString(new Response.Builder().setStatus(403).setMessage("Token not provided").build()));
            return;
        }
        if (!authorization.startsWith("bearer ")) { // Token格式错误
            httpServletResponse.getWriter().write(JSON.toJSONString(new Response.Builder().setStatus(403).setMessage("Token format error").build()));
            return;
        }
        authorization = authorization.replace("bearer ", "");
        Claims claims = JwtUtil.parseToken(authorization, jwtProperty.getBase64Security());
        if (null == claims) { // Token不可解码
            httpServletResponse.getWriter().write(JSON.toJSONString(new Response.Builder().setStatus(403).setMessage("Can't parse token").build()));
            return;
        }
        if (claims.getExpiration().getTime() >= new Date().getTime()) { // Token超时
            httpServletResponse.getWriter().write(JSON.toJSONString(new Response.Builder().setStatus(403).setMessage("Token expired").build()));
            return;
        }
        // 再进行一些必要的验证
        if (StringUtils.isEmpty(claims.get("username"))) {
            httpServletResponse.getWriter().write(JSON.toJSONString(new Response.Builder().setStatus(403).setMessage("Invalid token").build()));
            return;
        }
        filterChain.doFilter(httpServletRequest, httpServletResponse);
    }
}

OK,JWT的简单配置就完成了,这里只是对JWT的简单使用,在通常的开发中还需要更复杂的处理逻辑,比如通常的访问Token,刷新Token等,这里就不详细说了。

(二)Security

1. 修改数据库

首先,修改一下数据库表结构,修改之后共有用户表、角色表、用户角色关系表,ER图如下:

角色表里共有两条数据,这两个角色是我们后续进行访问权限控制的基础,如下所示:

2. 修改User类,实现UserDetails接口

UserDetails是Security提供的一个接口,其中定义了一系列用于判断User状态和权限的方法,User实体如下所示:

public class User extends BaseEntity implements UserDetails {
    private static final long serialVersionUID = 1L;
    private String username;
    private String password;
    private List<Role> roles; // 从数据库查询出的Role

    public void setUsername(String username) {
        this.username = username;
    }

    @Override
    public String getUsername() {
        return this.username;
    }

    public void setPassword(String password) {
        this.password = password;
    }

    @Override
    @JsonIgnore
    public String getPassword() {
        return this.password;
    }

    public List<Role> getRoles() {
        return roles;
    }

    public void setRoles(List<Role> roles) {
        this.roles = roles;
    }

    @Override
    @JsonIgnore
    public Collection<? extends GrantedAuthority> getAuthorities() {
        if (roles == null) {
            return null;
        }
        // 将自定义的Role转换为Security的GrantedAuthority
        List<SimpleGrantedAuthority> authorities = new ArrayList<>();
        for (Role role : roles) {
            authorities.add(new SimpleGrantedAuthority(role.getName()));
        }
        return authorities;
    }

    @Override
    @JsonIgnore
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    @JsonIgnore
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    @JsonIgnore
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    @JsonIgnore
    public boolean isEnabled() {
        return true;
    }
}

3. 修改UserService类,实现UserDetailsService接口

这里需要实现UserDetailsService中的loadUserByUsername方法,在这个方法中根据Username查询用户,然后交由Security去匹配用户名和密码(如果需要复杂的用户验证逻辑,可以重写UsernamePasswordAuthenticationFilter,然后将重写的过滤器添加到Security的过滤器链中),UserService代码如下所示:

@Service
@Transactional(readOnly = true)
public class UserService extends BaseService<UserDao, User> implements UserDetailsService {

    public List<User> findUserList(User user) {
        return dao.findUserList(user);
    }

    public User getUserByName(String username) {
        return dao.getByUsername(username);
    }

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = dao.getByUsername(username);
        if (user == null) {
            throw new UsernameNotFoundException(username);
        }
        return user;
    }
}

4. 定义Handler和EntryPoint

因为我们的种子项目是以Restful接口形式提供服务的,所以我们不需要进行页面跳转,而是需要定义一系列的处理器,共包括登录成功、登录失败、注销成功、权限认证这四个处理器,这里就不把代码全部贴出来了。

需要值得注意的是:Security是通过一系列过滤器组成的过滤器链来进行权限控制的,当未登录的用户访问了受权限保护的资源时,会抛出AuthenticationException,默认由LoginUrlAuthenticationEntryPoint处理,也就是默认跳转至登录页面,显然我们需要的是为用户返回一个合理的提示,那么就需要自定义一个处理器处理AuthenticationException,代码如下:

/**
 * 供 {@link ExceptionTranslationFilter} 使用,处理AuthenticationException异常,即:未登录状态下访问受保护资源
 * Security默认实现 {@link org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint}
 * <p>
 * Author GreedyStar
 * Date   2018/7/23
 */
@Component
public class AuthEntryPoint implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().write(JSON.toJSONString(new Response.Builder().setStatus(401).setMessage("Please login").build()));
    }
}

下面为登录失败的handler代码,在这里只简单返回了错误提示:

/**
 * 登录失败Handler
 * <p>
 * Author GreedyStar
 * Date   2018/7/20
 */
@Component
public class LoginFailureHandler implements AuthenticationFailureHandler {
    @Override
    public void onAuthenticationFailure(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException {
        httpServletResponse.setContentType("application/json;charset=UTF-8");
        httpServletResponse.getWriter().write(JSON.toJSONString(new Response.Builder().setStatus(401).setMessage("Incorrect username or password").build()));
    }
}

5. 自定义加密方式

通常用户密码是要加密存储的,因此我们需要告知Security我们使用了何种加密方式,我们可以通过实现PasswordEncoder接口来实现加密和认证,本例采用简单的MD5加密,如下所示:

public class CustomPasswordEncoder implements PasswordEncoder {

    @Override
    public String encode(CharSequence charSequence) {
        StringBuffer buf = new StringBuffer("");
        try {
            MessageDigest md = MessageDigest.getInstance("MD5");
            md.update(charSequence.toString().getBytes());
            byte b[] = md.digest();
            int i;
            for (int offset = 0; offset < b.length; offset++) {
                i = b[offset];
                if (i < 0)
                    i += 256;
                if (i < 16)
                    buf.append("0");
                buf.append(Integer.toHexString(i));
            }
            String str = buf.toString();
            return (str.substring(10, str.length()) + str.substring(0, 10));
        } catch (NoSuchAlgorithmException e) {
            e.printStackTrace();
        }
        return buf.toString();
    }

    @Override
    public boolean matches(CharSequence charSequence, String s) {
        return encode(charSequence).equals(s);
    }
}

6. Security配置类

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    private LoginSuccessHandler loginSuccessHandler; // 登录成功处理器
    @Autowired
    private LoginFailureHandler loginFailureHandler; // 登录失败处理器
    @Autowired
    private LogoutSuccessHandler logoutSuccessHandler; // 注销成功处理器
    @Autowired
    private AuthEntryPoint authEntryPoint; // 权限认证异常处理器
    @Autowired
    private UserService userService;
    @Autowired
    private JwtProperty jwtProperty; // jwt属性

    @Bean
    public PasswordEncoder passwordEncoder() {
        // 密码加密,这里采用了简单的MD5加密,可以根据需要自行配置
        return new CustomPasswordEncoder();
    }

    @Autowired
    public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
        // 配置UserService和密码加密服务
        auth.userDetailsService(userService).passwordEncoder(passwordEncoder());
    }

    @Override
    protected void configure(HttpSecurity httpSecurity) throws Exception {
        httpSecurity
                .csrf().disable()
                .authorizeRequests()
                /*
                这里对URL添加访问权限控制时需要注意:
                1. hasAuthority要以权限的全称标识,如ROLE_ADMIN,可以自定义权限标识
                2. hasRole要以ROLE_开头,且配置权限控制时要省略ROLE_前缀
                */
//                .antMatchers("/admin/**").hasAuthority("ROLE_ADMIN")
//                .antMatchers("/user/**").hasAnyAuthority("ROLE_ADMIN", "ROLE_USER")
                .antMatchers("/admin/**").hasRole("ADMIN")
                .antMatchers("/user/**").hasAnyRole("ADMIN", "USER")
                .anyRequest().fullyAuthenticated()
                .and()
                .formLogin().loginProcessingUrl("/user/login").successHandler(loginSuccessHandler).failureHandler(loginFailureHandler)
                .and()
                .logout().logoutUrl("/user/logout").logoutSuccessHandler(logoutSuccessHandler)
                .and()
                .exceptionHandling().authenticationEntryPoint(authEntryPoint);
        // 配置jwt验证过滤器,位于用户名密码验证过滤器之后
        httpSecurity.addFilterAfter(new JwtAuthenticationFilter(jwtProperty), UsernamePasswordAuthenticationFilter.class);
    }

    @Override
    public void configure(WebSecurity web) throws Exception {
        /* 在这里配置security放行的请求 */
        // 统一静态资源
        web.ignoring().antMatchers("/**/*.gif", "/**/*.png", "/**/*.jpg", "/**/*.html", "/**/*.js", "/**/*.css", "/**/*.ico", "/webjars/**");
        // Druid监控平台
        web.ignoring().antMatchers("/druid/**");
        // swagger2
        web.ignoring().antMatchers("/swagger-ui.html*/**");
        web.ignoring().mvcMatchers("/v2/api-docs", "/configuration/security", "swagger-resources");
        // 注册请求
        web.ignoring().mvcMatchers("/user/signup");
    }
}

到这里,Security就配置完了,虽然看起来配置很多,但其实使用起来是非常灵活的。

(三)异常处理

良好的错误提醒能够极大地提高用户体验,在前后端分离的架构下,前端和后端通常需要确定一套错误提示方案,这时就需要对异常进行统一的处理。

异常处理按我的理解可以分为两种:其一,业务异常处理;其二,全局异常处理。

1. 业务异常处理

这里所说的业务异常处理是指处理在业务处理过程中抛出的异常,比如我们在Controller中抛出异常。对于这些异常,Spring为我们提供了很好的处理方式,我们可以通过@ControllerAdvice注解定义异常处理类,配合@EceptionHandler注解定义异常处理方法,来捕获在Controller中抛出的异常,代码如下:

/**
 * Author GreedyStar
 * Date   2018/7/23
 */
@RestControllerAdvice
public class CustomExceptionHandler {

    @ExceptionHandler(CustomException.class)
    public Response handleException(CustomException exception) {
        return new Response.Builder().setStatus(500).setMessage(exception.getMessage()).build();
    }

}

在这里我们根据业务需要配置不同的异常处理方法。

2. 全局异常处理

除了在Controller中抛出的异常,我们通常还会遇到404等异常,SpringBoot为我们提供了一个默认的异常处理方式:即将错误映射至/error路径,想进一步了解可以参考org.springframework.boot.autoconfigure.web.servlet.error.BasicErrorController。

/**
 * 全局错误处理
 * SpringBoot默认会将异常映射到/error路径,从而根据请求方式返回html或json
 * 在这个控制器中处理/error路径的请求,将所有异常的返回值进行统一处理
 * <p>
 * Author GreedyStar
 * Date   2018/7/19
 */
@RestController
public class GlobalErrorController implements ErrorController {
    private final String PATH = "/error";
    @Autowired
    private ErrorAttributes errorAttributes;

    @Override
    public String getErrorPath() {
        return PATH;
    }

    @RequestMapping(value = PATH, produces = {MediaType.APPLICATION_JSON_VALUE})
    public Response handleError(HttpServletRequest request) {
        Map<String, Object> attributesMap = getErrorAttributes(request, true);
        return new Response.Builder().setStatus(500).setMessage(attributesMap.get("message").toString()).build();
    }

    protected Map<String, Object> getErrorAttributes(HttpServletRequest request, boolean includeStackTrace) {
        WebRequest webRequest = new ServletWebRequest(request);
        return this.errorAttributes.getErrorAttributes(webRequest, includeStackTrace);
    }
}

三、总结

经过以上的配置,我们就完成了简单的访问权限控制和Token认证了,并添加了简单的异常处理,让我们的应用具有更好、更完善的错误提示和更安全的访问控制。

最近在查看JWT资料的时候发现了一篇驳斥JWT自包含、无状态的文章,感觉写的很有道理,推荐给大家 讲真,别再使用JWT了!

虽然JWT在分布式应用和客户端程序中有很大的便利条件,但也确实存在一些问题使它无法绕过后台缓存状态这一问题,当然,具体的业务场景对应不同的使用方式,最终还是取决于是否适用于业务场景。

CSDN抽风了不能上传图片,测试结果图就不传了。

源码地址:https://github.com/GreedyStar/SpringBootDemo

猜你喜欢

转载自blog.csdn.net/greedystar/article/details/81220070