大家一起学——Spring Security(二)

455 阅读8分钟

在上篇中我们一起学习了SpringSecurity的架构和实现原理,并分析了一个Demo项目的认证流程。接下来我们使用学到的新知识,改造我们的Demo项目,使他具有JWT认证的功能。接下来的实践需要对上篇充分的理解!如果你还没有看上篇的话,强烈建议你先去看上篇!

继续旅程

我们继续使用上篇编写的Demo项目,对其进行改造。

让我们先思考下,如果我们需要添加一种认证策略,最好的解决方式是什么?回想上篇中我们学习的Security的认证流程和结构,一个请求穿过层层由Security提供的Filter子链寻找可以提供认证的Filter,完成认证后保存Authentication到SecurityContext,带着Authentication完成整个请求。显而易见,我们要想添加一种认证策略,最好的办法一定是向Security的Filter链中配置一个我们自定义的Filter处理认证。

修改Demo

在编写自定义的Filter之前,还有一个问题。许多Security的默认Filter和功能是我们不想要的,我们得配置关闭那些功能,以免浪费系统资源。在使用JWT的情况下,首先最不需要的肯定是Session,因为使用Session的JWT毫无意义。其次,我们其实也不需要跨站请求伪造保护,因为在微服务或前后端分离的项目中,大部分情况发出请求的客户端和处理请求的服务并不是同一域名或同一服务,互相之间调用当然是跨站的。还有一些默认的功能我们其实也不再需要,比如默认的登出Fillter、默认的登入登出页面Filter、请求缓存命中Filter、Basic认证Filter。所以在一切开始之前,我们需要把Security配置明白了。

HttpSecurity配置

Spring Security的配置由WebSecurityConfigurerAdapter完成,我们来看看默认情况下,它都做了什么。

private void applyDefaultConfiguration(HttpSecurity http) throws Exception {
    // 启用CSRF
    http.csrf();
    // 添加异步支持
    http.addFilter(new WebAsyncManagerIntegrationFilter());
    // 添加ExceptionTranslationFilter
    http.exceptionHandling();
    // 添加HeaderWriterFilter
    http.headers();
    // 添加SessionManagementFilter
    http.sessionManagement();
    // 添加SecurityContextPersistenceFilter
    http.securityContext();
    // 添加RequestCacheAwareFilter
    http.requestCache();
    // 添加AnonymousAuthenticationFilter
    http.anonymous();
    // 添加SecurityContextHolderAwareRequestFilter
    http.servletApi();
    // 启用默认的登录登出页面
    http.apply(new DefaultLoginPageConfigurer<>());
    // 启用默认的登出Filter
    http.logout();
}

// 默认的configure方法
protected void configure(HttpSecurity http) throws Exception {
    // 匹配所有接口,配置全都要认证后才能访问
    http.authorizeRequests((requests) -> requests.anyRequest().authenticated());
    // 启用表单登录
    http.formLogin();
    // 启用Basic认证
    http.httpBasic();
}

可以看到几乎是每一行对应启用一个Filter,我们只需要留下我们想要的Filter即可。为了实现自定义配置,我们继承WebSecurityConfigurerAdapter,并重写configure方法(又是模板方法模式,我感觉作者魔怔了),最后把我们自定义的配置类注入到Spring容器。

来看看配置代码:

/**
 * @description: SpringSecurity配置
 * @author: Sprite
 * @date: 2021-08-27 09:39
 **/
@Configuration
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {

    public SpringSecurityConfig() {
        // 关闭HttpSecurity的默认配置
        super(true);
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // 禁用Session
        http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
        // 禁用Csrf保护
        http.csrf().disable();
        // 添加异步支持
        http.addFilter(new WebAsyncManagerIntegrationFilter());
        // 添加ExceptionTranslationFilter
        http.exceptionHandling();
        // 添加HeaderWriterFilter
        http.headers();
        // 添加SecurityContextPersistenceFilter
        http.securityContext();
        // 添加AnonymousAuthenticationFilter
        http.anonymous();
        // 添加SecurityContextHolderAwareRequestFilter
        http.servletApi();
        // 启用用户名密码登录
        http.formLogin();
        // 匹配所有接口,配置全都要认证后才能访问
        http.authorizeRequests().anyRequest().authenticated();
    }
}

眼尖的读者应该可以看出一个问题,比如登出Filter,我们只要不写http.logout();就可以了,session和csrf的配置直接不写http.sessionManagement()和http.csrf()不就万事大吉了吗?其实是因为配置session和csrf不只是配置两个Filter,而且还牵扯到一些其他Filter的策略。我们调用disable和sessionCreationPolicy方法来配置关闭这些相关的策略。

此时我们执行断点测试,查看Security的Filter链。

Security的Filter链

从15个减少到9个,余下的都是我们需要的。

还记得SecurityContextPersistenceFilter的缓存策略吗?它会在请求结束时将SecurityContext缓存在HttpSession中。现在我们把HttpSession禁用了,来看看它有什么变化。

原来:

image-20210831165002915.png

现在:

image-20210831164832931.png

可以看到它的缓存库策略从HttpSession变成了Null,也就是不缓存。

看到这里你应该明白为什么要调用sessionCreationPolicy来配置无状态,而不是直接不写 http.sessionManagement()了。直接不写 http.sessionManagement()确实会让SessionManagementFilter从Filter链中消失,但是SecurityContextPersistenceFilter的缓存策略还是HttpSession,这当然不是我们想要的。

编写Filter

接下来我们要编写一个自定义的Filter,并把它加入到Security的Filter链中去。

它需要做的是请求到来时,读取Header中的JWT进行验证,验证通过后,将解析JWT中的数据包装为Authentication储存在SecurityContext中完成验证。

为了生成和验证JWT,我们需要引入JWT的操作库:

如果你不知道什么是JWT的话,强烈建议你去了解下相关概念。

// https://mvnrepository.com/artifact/com.auth0/java-jwt
implementation 'com.auth0:java-jwt:3.18.1'

然后编写Filter:

/**
 * @description: JWT验证过滤器
 * @author: Sprite
 * @date: 2021-08-31 17:16
 **/
public class JwtVerifyFilter implements Filter {

    // 请求头中jwt字串的字段名称
    private static final String JWT_FIELD_NAME = "jwt";
    // 默认的签名算法指定secret
    private static final Algorithm DEFAULT_ALGORITHM = Algorithm.HMAC256("Hello");
    // 默认的验证器,指定JWT签发主题和签名算法
    private static final JWTVerifier DEFAULT_VERIFIER = JWT.require(DEFAULT_ALGORITHM).withIssuer("security").build();

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        // 已经认证直接跳过
        if (SecurityContextHolder.getContext().getAuthentication() != null) {
            chain.doFilter(request, response);
        }
        HttpServletRequest httpServletRequest = (HttpServletRequest) request;
        // 从请求头获取JWT
        String jwtStr = httpServletRequest.getHeader(JWT_FIELD_NAME);
        // 请求不带JWT跳过认证
        if (jwtStr == null) {
            chain.doFilter(request, response);
        } else {
            try {
                // 验证JWT,验证失败会抛出JWTVerificationException
                DecodedJWT jwt = DEFAULT_VERIFIER.verify(jwtStr);
                // 读取JWT中的用户名
                String username = jwt.getSubject();
                // 包装一个经过验证的Authentication,偷懒直接使用UsernamePasswordAuthenticationToken
                UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(username, jwtStr, null);
                // 将Authentication放入SecurityContext完成认证
                SecurityContextHolder.getContext().setAuthentication(token);
                chain.doFilter(request, response);
            } catch (JWTVerificationException e) {
                // JWT验证失败将请求转发到异常处理Controller
                request.setAttribute(FilterErrorController.EXCEPTION_ATTRIBUTE_NAME, e);
                request.getRequestDispatcher(FilterErrorController.CONTROLLER_PATH).forward(request, response);
            }
        }
    }
}

这里我们在JWT验证异常时,把请求转发给了专门的异常处理控制器:

/**
 * @description: 过滤器异常处理
 * @author: Sprite
 * @date: 2021-09-01 10:10
 **/
@RestController
public class FilterErrorController {

    public static final String CONTROLLER_PATH = "filter/error";
    public static final String EXCEPTION_ATTRIBUTE_NAME = "exception";

    @RequestMapping(CONTROLLER_PATH)
    public Response error(@RequestAttribute(EXCEPTION_ATTRIBUTE_NAME) Exception e) {
        // 如果是JWTVerificationException异常,返回400和异常消息
        if (e instanceof JWTVerificationException) {
            return Response.BAD_REQUEST(e.getMessage());
        }
        return Response.ERROR(e.getMessage());
    }
}

这里的Response是我定义的标准响应模型:

/**
 * @description: 标准响应类
 * @author: Sprite
 * @date: 2021-09-01 10:26
 **/
@Data
@Accessors(chain = true)
public class Response {
    private Integer code;
    private String msg;
    private Object data;

    public static Response OK() {
        return new Response().setCode(200).setMsg("OK");
    }

    public static Response OK(Object data) {
        return Response.OK().setData(data);
    }

    public static Response ERROR() {
        return new Response().setCode(500).setMsg("ERROR");
    }

    public static Response ERROR(String msg) {
        return new Response().setCode(500).setMsg(msg);
    }

    public static Response BAD_REQUEST(String msg) {
        return new Response().setCode(400).setMsg(msg);
    }

    public static Response UNAUTHORIZED(String msg) {
        return new Response().setCode(401).setMsg(msg);
    }
}

然后我们把它配置到Security的Filter链中去,在SpringSecurityConfig中的configure中加入一行代码:

// 把JWT验证过滤器添加到UsernamePasswordAuthenticationFilter后面
http.addFilterAfter(new JwtVerifyFilter(), UsernamePasswordAuthenticationFilter.class);

然后我们断点测试看看是否配置成功:

image-20210901105913055.png

可以看到已经配置成功了。

编写successHandler

我们已经解决了JWT的验证问题,但是JWT从哪里来呢?JWT总得有人去签发,在微服务项目中大多是由统一的认证服务器签发,在类似我们demo的单体项目中,我们需要在登录成功后生成JWT响应给客户。我们依然使用Security提供的用户名密码认证,还记得AbstractAuthenticationProcessingFilter会在我们登录成功之后,会把后续操作交给successHandler吗?我们只需要实现一个successHandler配置给AbstractAuthenticationProcessingFilter来签发JWT即可。

创建一个handler包,在其中创建JwtLoginSuccessHandler类:

image-20210904163043651.png

然后编写JwtLoginSuccessHandler代码,实现AuthenticationSuccessHandler接口的onAuthenticationSuccess方法:

/**
 * @description: JWT登录成功Handler
 * @author: Sprite
 * @date: 2021-09-04 16:36
 **/
public class JwtLoginSuccessHandler implements AuthenticationSuccessHandler {

    // 默认的签名算法指定secret
    private static final Algorithm DEFAULT_ALGORITHM = Algorithm.HMAC256("Hello");

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        UserDetails userDetails = (UserDetails) authentication.getPrincipal();
        // 生成JWT
        String jwtStr = JWT.create()
                // 签发主体
                .withIssuer("security")
                // 用户名
                .withSubject(userDetails.getUsername())
                // 签发JWT 指定算法
                .sign(DEFAULT_ALGORITHM);
        // 带着JWT将请求转发到 JWT登录Controller
        request.setAttribute(JwtLoginController.JWT_ATTRIBUTE_NAME, jwtStr);
        request.getRequestDispatcher(JwtLoginController.SUCCESS_PATH).forward(request, response);
    }
}

代码非常简单,就是生成jwt然后交给Controller处理,我们使用controller来响应JWT:

/**
 * @description: JWT登录控制器
 * @author: Sprite
 * @date: 2021-09-04 16:36
 **/
@RestController
@RequestMapping("jwtLogin")
public class JwtLoginController {

    public static final String SUCCESS_PATH = "jwtLogin/success";
    public static final String JWT_ATTRIBUTE_NAME = "jwt";

    @RequestMapping("success")
    public Response success(@RequestAttribute(JWT_ATTRIBUTE_NAME) String jwtStr) {
        return Response.OK(jwtStr);
    }
}

然后我们把JwtLoginSuccessHandler配置给UsernamePasswordAuthenticationFilter:

// 启用用户名密码登录,指定successHandler
http.formLogin()
        .successHandler(new JwtLoginSuccessHandler());

到这里我们就写完了JWT的签发与认证,下面我们进行测试。

测试JWT认证

首先我们修改我们的ResourceController来进行测试:

/**
 * @description: 资源控制器
 * @author: Sprite
 * @date: 2021-08-26 15:14
 **/
@RestController
@RequestMapping("resource")
public class ResourceController {
    @GetMapping
    public Response getResource() {
        String username = (String) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
        // 标准响应并包含数据
        return Response.OK("Hello world! Hello " + username + "!");
    }
}

请求时不再传入name,通过SecurityContextHolder获取认证信息并读取到用户名。

因为使用浏览器来发送Post请求过于麻烦,我们使用Postman来测试。启动项目,然后用Postman直接请求login接口:

image-20210904175323625.png

查看返回值:

{
    "code": 200,
    "msg": "OK",
    "data": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyIiwiaXNzIjoic2VjdXJpdHkifQ.ZjeyfK2PiOouURIsP57MVyyhml125QOy4aVdu5scWUA"
}

data数据中就是jwt了,然后我们带着jwt去请求resource接口:

image-20210904175638221.png

image-20210904175854345.png

一切正常,此时无论服务器重启还是调用其他可以解析jwt的服务,我们都可以携带jwt去请求需要认证的接口。

到这里我们就实现了JWT的单点登录功能,但是还没有完善,我们需要配置一些附加功能来使认证完全遵循restful规范。比如目前在未登录的情况下,我们请求接口期望获得json的响应(如401),但是却会得到服务器的重定向响应... ...,这些边边角角就不再多赘述。