Spring Boot 2 + Spring Security 5 + JWT 的单页应用Restful解决方案

准备

项目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 会用到 UsernamePasswordAuthenticationFilterUsernamePasswordAuthenticationToken 这两个类,但这两个类初衷是为了解决表单登入,对 JWT 这类 Token 鉴权的方式并非很友好。因此咱们要开发属于本身的 FilterAuthenticationToken 来替换掉 Spring Security 自带的类。缓存

同时默认的 Spring Security 鉴定用户是使用了 ProviderManager 这个类进行判断,同时 ProviderManager 会调用 AuthenticationUserDetailsService 这个接口中的 UserDetails loadUserDetails(T token) throws UsernameNotFoundException 来从数据库中获取用户信息(这个方法须要用户本身继承实现)。由于考虑到自带的实现方式并不能很好的支持JWT,例如 UsernamePasswordAuthenticationToken 中有 usernamepassword 字段进行赋值,可是 JWT 是附带在请求的 header 中,只有一个 token ,何来 usernamepassword 这种说法。安全

因此我对其进行了大换血,例如获取用户的方法并无在 AuthenticationUserDetailsService 中实现,但这样就可能不能完美的遵照 Spring Security 的官方设计,若是有更好的方法请指正。restful

改造

改造 Authentication

AuthenticationSecurity 官方提供的一个接口,是保存在 SecurityContextHolder 供调用鉴权使用的核心。

这里主要说下三个方法

getCredentials() 本来是用于获取密码,现咱们打算用其存放前端传递过来的 token

getPrincipal() 本来用于存放用户信息,如今咱们继续保留。好比存储一些用户的 usernameid 等关键信息供 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;
    }
}
复制代码

改造 AuthenticationManager

用于判断用户 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;
    }
}
复制代码

开发属于本身的 Filter

接下来咱们要使用属于本身的过滤器,考虑到 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是否合法是每次请求数据库过于耗费资源

咱们不可能每一次鉴定都去数据库拿一次数据来判断 token 是否合法,这样很是浪费资源还影响效率。

咱们能够在 JWTAuthenticationManager 使用缓存。

当用户第一次访问,咱们查询数据库判断 token 是否合法,若是合法将其放入缓存(缓存过时时间和token过时时间一致),此后每一个请求先去缓存中寻找,若是存在则跳过请求数据库环节,直接当作该 token 合法。

如何解决JWT过时问题

JWTAuthenticationManager 中编写方法,当 token 即将过时时抛出一个特定的异常,例如 ReAuthenticateException,而后咱们在 JWTAuthenticationFilter 中单独捕获这个异常,返回一个特定的 http 状态码,而后前端去单独另外访问 GET /re_authentication 获取一个新的token来替代掉本来的,同时从缓存中删除老的 token

相关文章
相关标签/搜索