目录
一、添加依赖
二、配置
(一)JWT
(二)Security
(三)异常处理
三、总结
Spring Security是后台开发中经常使用的身份认证和访问权限控制框架,集成起来十分简单,对Restful接口的支持也比较完备,至于更多的介绍,可以参考 Spring Security 参考手册,在pom.xml中添加依赖如下:
org.springframework.boot
spring-boot-starter-security
JWT(Json Web Token)定义了一种简洁的,自包含的信息传递规范,在目前前后端分离的架构环境下使用十分频繁,但JWT也存在一定的局限性,在具体的业务场景下通常无法直接代替通常意义上的session,带着学习的目的,我们可以尝试一下简单的使用,详细介绍可以参考 JWT介绍,在pom.xml中添加依赖如下:
io.jsonwebtoken
jjwt
0.9.0
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验证过滤器
*
* 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等,这里就不详细说了。
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 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 getRoles() {
return roles;
}
public void setRoles(List roles) {
this.roles = roles;
}
@Override
@JsonIgnore
public Collection extends GrantedAuthority> getAuthorities() {
if (roles == null) {
return null;
}
// 将自定义的Role转换为Security的GrantedAuthority
List 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 implements UserDetailsService {
public List 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}
*
* 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
*
* 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路径的请求,将所有异常的返回值进行统一处理
*
* 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 attributesMap = getErrorAttributes(request, true);
return new Response.Builder().setStatus(500).setMessage(attributesMap.get("message").toString()).build();
}
protected Map 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/tree/sample-3
最后,安利一下自己写的一个Java代码生成工具,能够方便的生成Spring、SpringMVC、Mybatis架构下的Java代码,希望能对大家有所帮助,地址:Java代码生成器:Generator