项目GitHub: github.com/Smith-Cruis…
我之前写过两篇关于安全框架的问题,大家可以大致看一看,打下基础。
Shiro+JWT+Spring Boot Restful简易教程
Spring Boot+Spring Security+Thymeleaf 简单教程
在开始前你至少需要了解 Spring Security
的基本配置和 JWT
机制。
一些关于 Maven
的配置和 Controller
的编写这里就不说了,自己看下源码即可。
本项目中 JWT
密钥是使用用户自己的登入密码,这样每一个 token
的密钥都不同,相对比较安全。
平常我们使用 Spring Security
会用到 UsernamePasswordAuthenticationFilter
和 UsernamePasswordAuthenticationToken
这两个类,但这两个类初衷是为了解决表单登入,对 JWT
这类 Token
鉴权的方式并不是很友好。所以我们要开发属于自己的 Filter
和 AuthenticationToken 来替换掉
Spring Security
自带的类。
同时默认的 Spring Security
鉴定用户是使用了 ProviderManager
这个类进行判断,同时 ProviderManager
会调用 AuthenticationUserDetailsService
这个接口中的 UserDetails loadUserDetails(T token) throws UsernameNotFoundException
来从数据库中获取用户信息(这个方法需要用户自己继承实现)。因为考虑到自带的实现方式并不能很好的支持JWT,例如 UsernamePasswordAuthenticationToken
中有 username
和 password
字段进行赋值,但是 JWT
是附带在请求的 header
中,只有一个 token ,何来 username
和 password
这种说法。
所以我对其进行了大换血,例如获取用户的方法并没有在 AuthenticationUserDetailsService
中实现,但这样就可能不能完美的遵守 Spring Security
的官方设计,如果有更好的方法请指正。
Authentication
Authentication
是 Security
官方提供的一个接口,是保存在 SecurityContextHolder
供调用鉴权使用的核心。
这里主要说下三个方法
getCredentials()
原本是用于获取密码,现我们打算用其存放前端传递过来的 token
getPrincipal()
原本用于存放用户信息,现在我们继续保留。比如存储一些用户的 username
, id
等关键信息供 Controller
中使用
getDetails()
原本返回一些客户端 IP
等杂项,但是考虑到这里基本都是 restful
这类无状态请求,这个就显的无关紧要 ,所以就被阉割了:happy:
public interface Authentication extends Principal, Serializable { Collection<? extends GrantedAuthority> getAuthorities(); Object getCredentials(); Object getDetails(); Object getPrincipal(); boolean isAuthenticated(); void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException; } 复制代码
我们编写属于自己的 Authentication
,注意 两个构造方法的不同
。 AbstractAuthenticationToken
是官方实现 Authentication
的一个类。
public class JWTAuthenticationToken extends AbstractAuthenticationToken { private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID; private final Object principal; private final Object credentials; /** * 鉴定token前使用的方法,因为还没有鉴定token是否合法,所以要setAuthenticated(false) * @param token JWT密钥 */ public JWTAuthenticationToken(String token) { super(null); this.principal = null; this.credentials = token; setAuthenticated(false); } /** * 鉴定成功后调用的方法,返回的JWTAuthenticationToken供Controller里面调用。 * 因为已经鉴定成功,所以要setAuthenticated(true) * @param token JWT密钥 * @param userInfo 一些用户的信息,比如username, id等 * @param authorities 所拥有的权限 */ public JWTAuthenticationToken(String token, Object userInfo, Collection<? extends GrantedAuthority> authorities) { super(authorities); this.principal = userInfo; this.credentials = token; setAuthenticated(true); } @Override public Object getCredentials() { return credentials; } @Override public Object getPrincipal() { return principal; } } 复制代码
用于判断用户 token
是否合法
@Component public class JWTAuthenticationManager implements AuthenticationManager { @Autowired private UserService userService; /** * 进行token鉴定 * @param authentication 待鉴定的JWTAuthenticationToken * @return 鉴定完成的JWTAuthenticationToken,供Controller使用 * @throws AuthenticationException 如果鉴定失败,抛出 */ @Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { String token = authentication.getCredentials().toString(); String username = JWTUtil.getUsername(token); UserEntity userEntity = userService.getUser(username); if (userEntity == null) { throw new UsernameNotFoundException("该用户不存在"); } /* * 官方推荐在本方法中必须要处理三种异常, * DisabledException、LockedException、BadCredentialsException * 这里为了方便就只处理了BadCredentialsException,大家可以根据自己业务的需要进行定制 * 详情看AuthenticationManager的JavaDoc */ boolean isAuthenticatedSuccess = JWTUtil.verify(token, username, userEntity.getPassword()); if (! isAuthenticatedSuccess) { throw new BadCredentialsException("用户名或密码错误"); } JWTAuthenticationToken authenticatedAuth = new JWTAuthenticationToken( token, userEntity, AuthorityUtils.commaSeparatedStringToAuthorityList(userEntity.getRole()) ); return authenticatedAuth; } } 复制代码
接下来我们要使用属于自己的过滤器,考虑到 token
是附加在 header
中,这和 BasicAuthentication
认证很像,所以我们继承 BasicAuthenticationFilter
进行重写核心方法改造。
public class JWTAuthenticationFilter extends BasicAuthenticationFilter { /** * 使用我们自己开发的JWTAuthenticationManager * @param authenticationManager 我们自己开发的JWTAuthenticationManager */ public JWTAuthenticationFilter(AuthenticationManager authenticationManager) { super(authenticationManager); } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException { String header = request.getHeader("Authorization"); if (header == null || !header.toLowerCase().startsWith("bearer ")) { chain.doFilter(request, response); return; } try { String token = header.split(" ")[1]; JWTAuthenticationToken JWToken = new JWTAuthenticationToken(token); // 鉴定权限,如果鉴定失败,AuthenticationManager会抛出异常被我们捕获 Authentication authResult = getAuthenticationManager().authenticate(JWToken); // 将鉴定成功后的Authentication写入SecurityContextHolder中供后序使用 SecurityContextHolder.getContext().setAuthentication(authResult); } catch (AuthenticationException failed) { SecurityContextHolder.clearContext(); // 返回鉴权失败 response.sendError(HttpServletResponse.SC_UNAUTHORIZED, failed.getMessage()); return; } chain.doFilter(request, response); } } 复制代码
// 开启方法注解功能 @EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true) @Configuration public class SecurityConfig extends WebSecurityConfigurerAdapter { @Autowired private JWTAuthenticationManager jwtAuthenticationManager; @Override protected void configure(HttpSecurity http) throws Exception { // restful具有先天的防范csrf攻击,所以关闭这功能 http.csrf().disable() // 默认允许所有的请求通过,后序我们通过方法注解的方式来粒度化控制权限 .authorizeRequests().anyRequest().permitAll() .and() // 添加属于我们自己的过滤器,注意因为我们没有开启formLogin(),所以UsernamePasswordAuthenticationFilter根本不会被调用 .addFilterAt(new JWTAuthenticationFilter(jwtAuthenticationManager), UsernamePasswordAuthenticationFilter.class) // 前后端分离本身就是无状态的,所以我们不需要cookie和session这类东西。所有的信息都保存在一个token之中。 .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS); } } 复制代码
关于 方法注解鉴权 这块有很多奇淫巧技,可以看看 Spring Boot+Spring Security+Thymeleaf 简单教程 这篇文章
一个 restful
最后的异常抛出肯定是要格式统一的,这样才方便前端的调用。
我们平常会使用 RestControllerAdvice
来统一异常,但是他只能管理我们自己抛出的异常,而管不住框架本身的异常,比如404啥的,所以我们还要改造 ErrorController
@RestControllerAdvice public class ExceptionController { // 捕捉控制器里面自己抛出的所有异常 @ExceptionHandler(Exception.class) public ResponseEntity<ResponseBean> globalException(Exception ex) { return new ResponseEntity<>( new ResponseBean( HttpStatus.INTERNAL_SERVER_ERROR.value(), ex.getMessage(), null), HttpStatus.INTERNAL_SERVER_ERROR ); } } 复制代码
如果直接去实现 ErrorController
这个接口,有很多现成方法都没有,不好用,所以我们选择 AbstractErrorController
@RestController public class CustomErrorController extends AbstractErrorController { // 异常路径网址 private final String PATH = "/error"; public CustomErrorController(ErrorAttributes errorAttributes) { super(errorAttributes); } @RequestMapping("/error") public ResponseEntity<ResponseBean> error(HttpServletRequest request) { // 获取request中的异常信息,里面有好多,比如时间、路径啥的,大家可以自行遍历map查看 Map<String, Object> attributes = getErrorAttributes(request, true); // 这里只选择返回message字段 return new ResponseEntity<>( new ResponseBean( getStatus(request).value() , (String) attributes.get("message"), null), getStatus(request) ); } @Override public String getErrorPath() { return PATH; } } 复制代码
写个控制器试试,大家也可以参考我控制器里面获取用户信息的方式,推荐使用 @AuthenticationPrincipal
这个方法!!!
@RestController public class MainController { @Autowired private UserService userService; // 登入,获取token @PostMapping("login") public ResponseEntity<ResponseBean> login(@RequestParam String username, @RequestParam String password) { UserEntity userEntity = userService.getUser(username); if (userEntity==null || !userEntity.getPassword().equals(password)) { return new ResponseEntity<>(new ResponseBean(HttpStatus.BAD_REQUEST.value(), "login fail", null), HttpStatus.BAD_REQUEST); } // JWT签名 String token = JWTUtil.sign(username, password); return new ResponseEntity<>(new ResponseBean(HttpStatus.OK.value(), "login success", token), HttpStatus.OK); } // 任何人都可以访问,在方法中判断用户是否合法 @GetMapping("everyone") public ResponseEntity<ResponseBean> everyone() { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); if (authentication.isAuthenticated()) { // 登入用户 return new ResponseEntity<>(new ResponseBean(HttpStatus.OK.value(), "You are already login", authentication.getPrincipal()), HttpStatus.OK); } else { return new ResponseEntity<>(new ResponseBean(HttpStatus.OK.value(), "You are anonymous", null), HttpStatus.OK); } } @GetMapping("user") @PreAuthorize("hasAuthority('ROLE_USER')") public ResponseEntity<ResponseBean> user(@AuthenticationPrincipal UserEntity userEntity) { return new ResponseEntity<>(new ResponseBean(HttpStatus.OK.value(), "You are user", userEntity), HttpStatus.OK); } @GetMapping("admin") @PreAuthorize("hasAuthority('ROLE_ADMIN')") public ResponseEntity<ResponseBean> admin(@AuthenticationPrincipal UserEntity userEntity) { return new ResponseEntity<>(new ResponseBean(HttpStatus.OK.value(), "You are admin", userEntity), HttpStatus.OK); } } 复制代码
这里简单解答下一些常见问题。
我们不可能每一次鉴定都去数据库拿一次数据来判断 token
是否合法,这样非常浪费资源还影响效率。
我们可以在 JWTAuthenticationManager
使用缓存。
当用户第一次访问,我们查询数据库判断 token
是否合法,如果合法将其放入缓存(缓存过期时间和token过期时间一致),此后每个请求先去缓存中寻找,如果存在则跳过请求数据库环节,直接当做该 token
合法。