项目GitHub:github.com/Smith-Cruis…前端
我以前写过两篇关于安全框架的问题,你们能够大体看一看,打下基础。java
Shiro+JWT+Spring Boot Restful简易教程git
Spring Boot+Spring Security+Thymeleaf 简单教程github
在开始前你至少须要了解 Spring Security
的基本配置和 JWT
机制。spring
一些关于 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
的官方设计,若是有更好的方法请指正。restful
Authentication
Authentication
是 Security
官方提供的一个接口,是保存在 SecurityContextHolder
供调用鉴权使用的核心。
这里主要说下三个方法
getCredentials()
本来是用于获取密码,现咱们打算用其存放前端传递过来的 token
getPrincipal()
本来用于存放用户信息,如今咱们继续保留。好比存储一些用户的 username
,id
等关键信息供 Controller
中使用
getDetails()
本来返回一些客户端 IP
等杂项,可是考虑到这里基本都是 restful
这类无状态请求,这个就显的可有可无 ,因此就被阉割了:happy:
默认提供的Authentication接口
public interface Authentication extends Principal, Serializable {
Collection<? extends GrantedAuthority> getAuthorities();
Object getCredentials();
Object getDetails();
Object getPrincipal();
boolean isAuthenticated();
void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException;
}
复制代码
JWTAuthenticationToken
咱们编写属于本身的 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
是否合法
JWTAuthenticationManager
@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
进行重写核心方法改造。
JWTAuthenticationFilter
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);
}
}
复制代码
SecurityConfig
// 开启方法注解功能
@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
ExceptionController
@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
);
}
}
复制代码
CustomErrorController
若是直接去实现 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
合法。
在 JWTAuthenticationManager
中编写方法,当 token
即将过时时抛出一个特定的异常,例如 ReAuthenticateException
,而后咱们在 JWTAuthenticationFilter
中单独捕获这个异常,返回一个特定的 http
状态码,而后前端去单独另外访问 GET /re_authentication
获取一个新的token来替代掉本来的,同时从缓存中删除老的 token
。