Spring Security & JWT

spring security and jwt

写在前面

Q: 为什么是 spring security

A: 无论是企业还是开源项目 一遍都会拿 shiro 作为首选,理由很简单

shiro 简单 易于上手 文档教程多,随便参考一个开源项目都可以写出标准的业务代码。

但 spring security 就不是这样了

根据我目前的使用来讲 你必须要了解 spring security 源代码 清楚执行流程,才能使用该框架。

并且 spring security 可以不依赖 servlet

Q:为什么需要 JWT

A: 这里就会牵扯到基于 token 认证 和 基于传统的 session 认证

http 本身是一种无状态的认证 这就意味着每一次请求 我们都不知道访问主体是谁

为了能够分辨访问主体 我们会在用户认证完毕之后给用户颁发一个标识 (sessionid 或者 token)

传统的 session 会将 session context 存储到后端 将 sessionid 存储到 cookie 中 服务器收到请求后 取出 cookie 中的 sessionid 根据 sessionid 从 session repository 取出 session context,如果取不到则认为是未认证请求。

而 jwt 则是将 context 存储到 token 上 每次请求都要吧 token 放在 header 上 服务区收到请求后 解析 header 取出 token 只需要验证 token 是否有效 如果需要用户相关的信息 只需要从 token 中直接拿出来就可以了

这样 token 也做到了无状态

do it

集成

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

不需要添加版本号 springboot 中有对应的

下一步之前

在下一步之前 请确保项目跑的起来 并且建立起基本的配置 并且能够以默认配置进行登陆

本篇不会讲过多的基础内容 我只会讲 如何优雅地使用 spring security

如有错误 欢迎指正

登陆

在使用默认的内存账户登陆之后 我们需要完成基于数据库的用户认证

我们需要的流程是

用户输入账号密码 -> spring security -> 根据账号密码匹配在数据库中的记录
如果成功将创建会话 否则将提示错误信息

那么好 我们先从登陆入口开始

spring security 的登陆是依靠 filter 的 默认的路径是 /login 浏览器打开则是 spring security 的默认登录页 如果用 POST 方法请求 则是正常的登陆逻辑

所以我们复用这个 filter

那么载入用户的信息 UserDetailsService 则需要改造成从数据库读取就可以了

@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    @Autowired
    private UserService userService;

    @Override
    public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {

        User user = userService.getUserByUsername(s);

        if (user == null)
            throw new UsernameNotFoundException("user not found");

        return new org.springframework.security.core.userdetails.User(user.getUsername(), user.getPassword(), Collections.emptyList());
    }
}

ok 问题解决

那么此时 用户的密码是用明文保存的 如果数据库遭到泄露 用户信息安全则是一个大问题

一般比较安全的做法是 签名算法 (密码 + 盐值)

但是 spring security 在 user 里面似乎没有提供盐值这个属性

不用慌

原来 spring security 早就准备好了一套解决方案 我们就不需要关心 盐值如何生成 如何存储

BCryptPasswordEncoder

生成的结果是这样样子的

{bcrypt}$2a$10$wGhC6bsgTghxFLFt1Aqbl.7t.MU/6mJLSkMwzTXxvRhZJToCWHQ5i

有加密算法 有盐值 有最终结果 all perfect

在 security config 里这么做就可以了

@Bean
public PasswordEncoder passwordEncoder() {

    return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}

有关 PasswordEncoderFactories 请自行查阅资料 它是一个更强的 PasswordEncoder

注册

我们搞定了登陆部分 但是我如何注册呢?

如何保存加密的密码呢?

其实最核心的代码就是

passwordEncoder.encode(password);

他的返回值就是加盐加密后的密码 我们只需要存储它就 OK 了

当然这个注册的 controller 还是要自己写的

jwt 认证

注册 登陆 都没有问题了

我们发现 session 还是传统 session 这对于前后端分离的应用不是那么优雅

那么 我们可以先看看 security 是怎么实现 session 的 (源代码需要自己啃)

非常关键的几个类

SessionManagementConfigurer

session 的配置类

SessionManagementFilter

session 的过滤器

SecurityContextRepository

控制 session 读取和保存

其实我们只需要根据这些定制出 jwt 的内容就可以了

首先 JwtAuthenticationToken 用于验证和以后使用的用途

@Getter
public class JwtAuthenticationToken extends AbstractAuthenticationToken {

    private String token;

    public JwtAuthenticationToken(String token, Collection<? extends GrantedAuthority> authorities) {
        super(authorities);
        this.token = token;
        super.setAuthenticated(true);
    }

    @Override
    public Object getCredentials() {
        return null;
    }

    @Override
    public Object getPrincipal() {
        return JwtUtil.getClaim(token, JwtUtil.JWT_USERNAME);
    }
}

写出 repository 用于提供 jwt 的读取和保存


@Slf4j
public class JwtSecurityContextRepository implements SecurityContextRepository {

    private static JwtSecurityContextRepository securityContextRepository = new JwtSecurityContextRepository();

    private JwtSecurityContextRepository() {

    }

    public static JwtSecurityContextRepository getInstance() {
        return securityContextRepository;
    }

    @Override
    public SecurityContext loadContext(HttpRequestResponseHolder requestResponseHolder) {
        String authorization = requestResponseHolder.getRequest().getHeader(LoginConst.AUTHORIZATION_HEADER_NAME);

        if (StringUtils.isNotBlank(authorization) && TOKEN_SESSION.containsKey(authorization.substring(7))) {
            SecurityContext securityContext = new SecurityContextImpl();
            securityContext.setAuthentication(new JwtAuthenticationToken(authorization.substring(7), Collections.emptyList()));
            return securityContext;
        }

        return generateNewContext();
    }

    protected SecurityContext generateNewContext() {
        return SecurityContextHolder.createEmptyContext();
    }

    @Override
    public void saveContext(SecurityContext context, HttpServletRequest request, HttpServletResponse response) {
        final Authentication authentication = context.getAuthentication();


        if (authentication == null) {
            //trustResolver.isAnonymous(authentication)
            log.debug("SecurityContext is empty or contents are anonymous - context will not be stored in HttpSession.");
            return;
        }

        //如果是jwt的token则不需要再次保存(签发)
        if (!(authentication instanceof UsernamePasswordAuthenticationToken))
            return;

        User principal = (User)authentication.getPrincipal();

        String code = UUIDUtil.generateShortUuid();

        String token = JwtUtil.sign(principal.getUsername(), code);

        //签发之后 缓存中保存一份 用于校验token和secret
        TOKEN_SESSION.put(token, code);

        response.setHeader(LoginConst.AUTHORIZATION_HEADER_NAME, LoginConst.AUTHORIZATION_PREFIX + token);
    }

    @Override
    public boolean containsContext(HttpServletRequest request) {

        String authorization = request.getHeader(LoginConst.AUTHORIZATION_HEADER_NAME);

        return StringUtils.isNotBlank(authorization) && TOKEN_SESSION.containsKey(authorization.substring(7));
    }

}

接着写 Filter


@Slf4j
@Setter
public class JwtManagementFilter extends GenericFilter {

    static final String FILTER_APPLIED = "__spring_security_session_jwt_filter_applied";

    private SecurityContextRepository securityContextRepository;
    private AuthenticationTrustResolver trustResolver = new AuthenticationTrustResolverImpl();
    private AuthenticationFailureHandler failureHandler = new SimpleUrlAuthenticationFailureHandler();
    private InvalidSessionStrategy invalidSessionStrategy = null;

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {

        HttpServletRequest request = (HttpServletRequest) servletRequest;
        HttpServletResponse response = (HttpServletResponse) servletResponse;

        if (request.getAttribute(FILTER_APPLIED) != null) {
            filterChain.doFilter(request, response);
            return;
        }

        request.setAttribute(FILTER_APPLIED, Boolean.TRUE);


        //校验 jwt
        if (!securityContextRepository.containsContext(request)) {
            Authentication authentication = SecurityContextHolder.getContext()
                    .getAuthentication();

            if (authentication != null && !trustResolver.isAnonymous(authentication)) {
                // The user has been authenticated during the current request, so call the
                // session strategy
                securityContextRepository.saveContext(SecurityContextHolder.getContext(),
                        request, response);
            }
            else {
                // No security context or authentication present. Check for a session
                // timeout
                if (isAuthorizationValid(request)) {

                    if (invalidSessionStrategy != null) {
                        invalidSessionStrategy
                                .onInvalidSessionDetected(request, response);
                        return;
                    }
                }
            }
        }

        filterChain.doFilter(request, response);
    }

    private boolean isAuthorizationValid(HttpServletRequest request) {

        String authorization = request.getHeader(LoginConst.AUTHORIZATION_HEADER_NAME);

        return StringUtils.isNotBlank(authorization);
    }
}

Configurer 类 用来配置 Filter 和一些其他的组件


public class JwtManagementConfigurer<H extends HttpSecurityBuilder<H>>
        extends AbstractHttpConfigurer<JwtManagementConfigurer<H>, H> {

    @Override
    public void configure(H builder) throws Exception {

        JwtManagementFilter jwtManagementFilter = new JwtManagementFilter();

        jwtManagementFilter.setSecurityContextRepository(JwtSecurityContextRepository.getInstance());

        JwtManagementFilter filter = postProcess(jwtManagementFilter);

        builder.addFilterBefore(filter, SessionManagementFilter.class);
    }
}

这个时候问题来了 拦截的逻辑都写好了 但是具体的认证逻辑是在哪里呢

就是 JwtAuthenticationProvider

public class JwtAuthenticationProvider implements AuthenticationProvider {

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        if (!supports(authentication.getClass())) {
            return null;
        }

        JwtAuthenticationToken jwtAuthentication = (JwtAuthenticationToken) authentication;

        String token = jwtAuthentication.getToken();

        if (TOKEN_SESSION.containsKey(token))
            return authentication;

        throw new BadCredentialsException("token 已失效");
    }

    @Override
    public boolean supports(Class<?> authentication) {

        return JwtAuthenticationToken.class.isAssignableFrom(authentication);
    }

}

最后 把这些配置放到配置文件中

@Override
protected void configure(HttpSecurity http) throws Exception {
    http
            //关掉csrf方便httpclient调试和以后的jwt token
            .csrf().disable()

            .formLogin()

            .and()
            //所有的请求都需要认证
            .authorizeRequests()
            .anyRequest()
            .authenticated()

            //无状态session
            .and()
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)

            //JWT相关配置
            .and()
            .apply(new JwtManagementConfigurer<>())

            //JWT认证Provider
            .and()
            .authenticationProvider(new JwtAuthenticationProvider());

}

不要忘记了 repository
不止我们的配置需要用 所以需要放到 shareobject 里一份

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
    auth.userDetailsService(userDetailsService);
    auth.setSharedObject(SecurityContextRepository.class, JwtSecurityContextRepository.getInstance());
}

一个最简单的 spring security 就配置完成了

这里面虽然没有涉及到权限 也没有做严格的校验

仔细看看源代码 你就知道 如何拓展权限和完整的校验

项目地址 Github:shenlanAZ/accelerator

tag:spring-security

本文链接:https://blog.inmind.ltd/index.php/archives/39/
This blog is under a CC BY-NC-SA 3.0 Unported License