Spring Security + JWT 前后端分离

Spring Security

1、基本简介

SpringSecurity 是企业应用系统的权限管理框架,应用的安全性包括用户认证(Authentication)和用户授权(Authorization)两个部分。用户认证一般要求用户提供用户名和密码。系统通过校验用户名和密码来完成认证过程,用户授权指的是验证某个用户是否有权限执行某个操作。在一个系统中,不同用户所具有的权限是不同的。spring security的主要核心功能为认证和授权,所有的架构也是基于这两个核心功能去实现的。

2、框架原理

总所周知,想要对 Web 资源进行控制,最好的莫过于加 Filter;想要对方法调用进行控制,最好的办法莫过于 AOP。所以 SpringSecurity 在我们进行用户认证以及授权权限的时候,通过各种各样的 Filter 来控制权限的访问。


  • 框架的核心组件
  1. SecurityContextHolder:提供对 SecurityContext 的访问,底层封装了 ThreadLocal,使其管理的对象(SecurityContext )存储在当前线程上;
  2. SecurityContext,:持有 Authentication 对象和其他可能需要的信息;
  3. AuthenticationManager 其中可以包含多个AuthenticationProvider;
  4. ProviderManager 对象为 AuthenticationManager 接口的实现类;
  5. AuthenticationProvider 主要用来进行认证操作的类 调用其中的 authenticate() 方法去进行认证操作;
  6. Authentication:Spring Security 方式的认证主体;
  7. GrantedAuthority:对认证主题的应用层面的授权,含当前用户的权限信息,通常使用角色表示;
  8. UserDetails:构建Authentication对象必须的信息,可以自定义,可能需要访问DB得到;
  9. UserDetailsService:通过username构建UserDetails对象,通过loadUserByUsername根据userName获取UserDetail对象 (可以在这里基于自身业务进行自定义的实现 如通过数据库,xml,缓存获取等)。

3、认证流程说明

当点击登录操作时,会到第一个拦截器UsernamePasswordAuthenticationFilterdoFilter方法,我们直接看这个类:

public class UsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        // 必须 POST 请求
        if (this.postOnly && !request.getMethod().equals("POST")) {
            throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
        } else {
            // 获取用户名,密码
            String username = this.obtainUsername(request);
            String password = this.obtainPassword(request);
            if (username == null) {
                username = "";
            }

            if (password == null) {
                password = "";
            }

            username = username.trim();
            // 生成 Token
            UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password);
            this.setDetails(request, authRequest);
            // 进行验证
            return this.getAuthenticationManager().authenticate(authRequest);
        }
    }
}

从源码可知,UsernamePasswordAuthenticationFilterAbstractAuthenticationProcessingFilter的子类,故其实是走AbstractAuthenticationProcessingFilterdoFilter方法:

public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
            throws IOException, ServletException {

        HttpServletRequest request = (HttpServletRequest) req;
        HttpServletResponse response = (HttpServletResponse) res;

        if (!requiresAuthentication(request, response)) {
            chain.doFilter(request, response);

            return;
        }

        if (logger.isDebugEnabled()) {
            logger.debug("Request is to process authentication");
        }

        Authentication authResult;

        try {
            authResult = attemptAuthentication(request, response);
            if (authResult == null) {
                // return immediately as subclass has indicated that it hasn't completed
                // authentication
                return;
            }
            sessionStrategy.onAuthentication(authResult, request, response);
        }
        catch (InternalAuthenticationServiceException failed) {
            logger.error(
                    "An internal error occurred while trying to authenticate the user.",
                    failed);
            unsuccessfulAuthentication(request, response, failed);

            return;
        }
        catch (AuthenticationException failed) {
            // Authentication failed
            unsuccessfulAuthentication(request, response, failed);

            return;
        }

        // Authentication success
        if (continueChainBeforeSuccessfulAuthentication) {
            chain.doFilter(request, response);
        }

        successfulAuthentication(request, response, chain, authResult);
    }

doFilter中会调用UsernamePasswordAuthenticationFilterattemptAuthentication方法,主要是进行 username 和 password 请求值的获取,然后再生成一个UsernamePasswordAuthenticationToken 对象,进行验证。
不过我们可以先看看UsernamePasswordAuthenticationToken的构造方法:

public UsernamePasswordAuthenticationToken(Object principal, Object credentials) {
        // 设置空权限
        super(null);
        // 设置用户名
        this.principal = principal;
        // 设置密码
        this.credentials = credentials;
        // 设置是否通过了校验
        setAuthenticated(false);
    }

其实UsernamePasswordAuthenticationToken是继承于Authentication,该对象是处理登录成功回调方法中的一个参数,里面包含了用户信息、请求信息等参数。
接下来我们看:
this.getAuthenticationManager().authenticate(authRequest);
这里有一个AuthenticationManager,但是真正调用的是ProviderManager

public class ProviderManager implements AuthenticationManager, MessageSourceAware,InitializingBean {
  public Authentication authenticate(Authentication authentication)
            throws AuthenticationException {
        Class<? extends Authentication> toTest = authentication.getClass();
        AuthenticationException lastException = null;
        Authentication result = null;
        boolean debug = logger.isDebugEnabled();

        for (AuthenticationProvider provider : getProviders()) {
            // 判断是否有provider支持该 Authentication
            if (!provider.supports(toTest)) {
                continue;
            }

            try {
                // 真正的逻辑判断
                result = provider.authenticate(authentication);

                if (result != null) {
                    copyDetails(authentication, result);
                    break;
                }
            }
            catch (AccountStatusException e) {
                prepareException(e, authentication);
                // SEC-546: Avoid polling additional providers if auth failure is due to
                // invalid account status
                throw e;
            }
            ...
}
  1. 这里首先通过provider判断是否支持当前传入进来的Authentication,目前我们使用的是UsernamePasswordAuthenticationToken,因为除了帐号密码登录的方式,还会有其他的方式,比如SocialAuthenticationToken
  2. 根据我们目前所使用的UsernamePasswordAuthenticationTokenprovider对应的是DaoAuthenticationProvider
public Authentication authenticate(Authentication authentication)
            throws AuthenticationException {

        // Determine username
        String username = (authentication.getPrincipal() == null) ? "NONE_PROVIDED" : authentication.getName();

        boolean cacheWasUsed = true;
        UserDetails user = this.userCache.getUserFromCache(username);
        if (user == null) {
            cacheWasUsed = false;   
            // 1.获取 UserDetails
            user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication);
        }
        try {
            // 2.用户信息预检查
            preAuthenticationChecks.check(user);
            // 3.附加的信息检查(密码检查)
            additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication);
        }
        catch (AuthenticationException exception) {
    
        }
        // 4.最后的检查
        postAuthenticationChecks.check(user);
        // 5.返回真正经过认证的 Authentication 
        return createSuccessAuthentication(principalToReturn, authentication, user);
}
  1. 去调用自己实现的UserDetailsServiceloadUserByUsername方法,返回UserDetails
  2. UserDetails的信息进行校验,主要是帐号是否被冻结,是否过期,用户是否可用等
  3. 对密码进行检查,这里调用了PasswordEncoder
  4. 检查UserDetailsisCredentialsNonExpired是否可用
  5. 返回经过认证的Authentication

这里的两次对UserDetails的检查,主要就是通过它的四个返回boolean类型的方法。经过信息的校验之后,通过UsernamePasswordAuthenticationToken的构造方法,返回了一个经过认证的Authentication

在通过attemptAuthentication方法之后,如果认证成功,会调用successfulAuthentication方法:

protected void successfulAuthentication(HttpServletRequest request,
            HttpServletResponse response, FilterChain chain, Authentication authResult)
            throws IOException, ServletException {

        if (logger.isDebugEnabled()) {
            logger.debug("Authentication success. Updating SecurityContextHolder to contain: "
                    + authResult);
        }

        SecurityContextHolder.getContext().setAuthentication(authResult);

        rememberMeServices.loginSuccess(request, response, authResult);

        // Fire event
        if (this.eventPublisher != null) {
            eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(
                    authResult, this.getClass()));
        }

        successHandler.onAuthenticationSuccess(request, response, authResult);
    }

该方法中有一行比较重要的代码SecurityContextHolder.getContext().setAuthentication(authResult);
SecurityContextHolder是对于ThreadLocal的封装。 ThreadLocal是一个线程内部的数据存储类,通过它可以在指定的线程中存储数据,数据存储以后,只有在指定线程中可以获取到存储的数据,对于其他线程来说则无法获取到数据。
最后执行successHandler.onAuthenticationSuccess(request, response, authResult),该方法会走登录成功之后的操作(一般我们会自定义登录成功之后的操作)。

如果认证失败,即抛AuthenticationException异常时,就会走unsuccessfulAuthentication方法:

protected void unsuccessfulAuthentication(HttpServletRequest request,
            HttpServletResponse response, AuthenticationException failed)
            throws IOException, ServletException {
        SecurityContextHolder.clearContext();

        if (logger.isDebugEnabled()) {
            logger.debug("Authentication request failed: " + failed.toString(), failed);
            logger.debug("Updated SecurityContextHolder to contain null Authentication");
            logger.debug("Delegating to authentication failure handler " + failureHandler);
        }

        rememberMeServices.loginFail(request, response);

        failureHandler.onAuthenticationFailure(request, response, failed);
    }

这里会清空SecurityContextHolder的值,然后执行failureHandler.onAuthenticationFailure(request, response, failed)来处理登录失败后的操作(一般我们会自定义登录失败后的操作)。

JWT

JSON Web Token (JWT) 是 JSON 格式的被加密了的字符串。在传统的用户登录认证中,都是基于session的登录认证。用户登录成功,服务端会保存一个session,当然会给客户端一个 sessionId,客户端会把 sessionId 保存在cookie中,每次请求都会携带这个 sessionId。
cookie+session这种模式通常是保存在内存中,而且服务从单服务到多服务会面临的session共享问题,随着用户量的增多,开销就会越大。而 JWT 不是这样的,只需要服务端生成token,客户端保存这个token,每次请求携带这个token,服务端认证解析。

JWT 的构成

JWT 由三部分构成,第一部分为头部(header),第二部分为载荷(playload),第三部分是签证(signature)。

header

JWT 的头部承载两部分信息:

  • 声明类型,这里是 JWT
  • 声明加密的算法,通常直接使用 HMAC SHA256

完整的头部如下:

{
"typ": "JWT",
"alg": "HS256"
}

然后将头部进行base64加密(该加密是可以对称解密的),构成了第一部分。

playload

载荷就是存放有效信息的地方,这些有效信息包含三个部分

  • 标准中注册的声明(Registered claims)
  • 公共的声明(Public claims)
  • 私有的声明(Private claims)
标准中注册的声明(建议但不强制使用)
  • iss: jwt 签发者
  • sub: jwt 所面向的用户
  • aud: 接收 jwt 的一方
  • exp: jwt 的过期时间,这个过期时间必须大于签发时间
  • nbf: 定义在什么时间之前,该 jwt 都不可用
  • iat: jwt 的签发时间
  • jti: jwt 的唯一标识
公共的声明

公共的声明可以添加任何的信息,一般添加用户的相关信息或其他业务需要的必要信息。但不建议添加敏感信息,因为该部分在客户端可解密。

私有的声明

私有声明是提供者和消费者所共同定义的声明,一般不建议存放敏感信息,因为base64是对称解密的,意味着该部分信息可以归类为明文信息。
定义一个 playload:

{
"name":"Free码农",
"age":"28",
"org":"今日头条"
}

然后将其进行base64加密,得到 jwt 的第二部分。

signature(签名)

JWT 的第三部分是一个签证信息,这个签证信息由三部分组成,base64编译过的 header 和 playload,以及一个 secret 秘钥。签名算法是 header 中指定的那个。签名公式为:HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), secret)
签名是用于验证消息在传递过程中有没有被更改,并且,对于使用私钥签名的token,它还可以验证 JWT的发送方是否为它所指定的发送方。

JWT 的三个部分,是以.分隔的。如:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJvcmciOiLku4rml6XlpLTmnaEiLCJuYW1lIjoiRnJlZeeggeWGnCIsImV4cCI6MTUxNDM1NjEwMywiaWF0IjoxNTE0MzU2MDQzLCJhZ2UiOiIyOCJ9.49UF72vSkj-sA4aHHiYN5eoZ9Nb4w5Vb45PsLF7x_NY

SpringSecurity + JWT 代码实现

  • 导入依赖
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt</artifactId>
            <version>0.9.0</version>
        </dependency>
  • 首先创建一个 JwtUser 实现 UserDetails
    org.springframework.security.core.userdetails.UserDetails
    先看一下这个接口的源码,其实很简单
public interface UserDetails extends Serializable {

    Collection<? extends GrantedAuthority> getAuthorities();

    String getUsername();

    boolean isAccountNonExpired();

    boolean isAccountNonLocked();

    boolean isCredentialsNonExpired();

    boolean isEnabled();
}

这个是Spring Security给我们提供的一个简单的接口,因为我们需要通过SecurityContextHolder去取得用户凭证等等信息。

package com.yongda.security;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.Collection;

/**
 * @author K. L. Mao
 * @create 2019/1/10
 */
public class JwtUser implements UserDetails {

    private String username;

    private String password;

    private Collection<? extends GrantedAuthority> authorities;

    public JwtUser(String username, String password, Integer state, Collection<? extends GrantedAuthority> authorities) {
        this.username = username;
        this.password = password;
        this.state = state;
        this.authorities = authorities;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return authorities;
    }

    @Override
    public String getPassword() {
        return password;
    }

    @Override
    public String getUsername() {
        return username;
    }

    // 账户是否未过期
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    // 账户是否未被锁
    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }
}
  • 编写一个工具类来生成令牌等操作
package com.yongda.security;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;

import java.util.Date;
import java.util.HashMap;
import java.util.Map;

/**
 * 生成令牌,验证等等一些操作
 * @author K. L. Mao
 * @create 2019/1/10
 */
@Data
@ConfigurationProperties(prefix = "jwt")
@Component
public class JwtTokenUtil {

    private String secret;

    // 过期时间 毫秒
    private Long expiration;

    private String header;

    /**
     * 从数据声明生成令牌
     *
     * @param claims 数据声明
     * @return 令牌
     */
    private String generateToken(Map<String, Object> claims) {
        Date expirationDate = new Date(System.currentTimeMillis() + expiration);
        return Jwts.builder().setClaims(claims).setExpiration(expirationDate).signWith(SignatureAlgorithm.HS512, secret).compact();
    }

    /**
     * 从令牌中获取数据声明
     *
     * @param token 令牌
     * @return 数据声明
     */
    private Claims getClaimsFromToken(String token) {
        Claims claims;
        try {
            claims = Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody();
        } catch (Exception e) {
            claims = null;
        }
        return claims;
    }

    /**
     * 生成令牌
     *
     * @param userDetails 用户
     * @return 令牌
     */
    public String generateToken(UserDetails userDetails) {
        Map<String, Object> claims = new HashMap<>(2);
        claims.put(Claims.SUBJECT, userDetails.getUsername());
        claims.put(Claims.ISSUED_AT, new Date());
        return generateToken(claims);
    }

    /**
     * 从令牌中获取用户名
     *
     * @param token 令牌
     * @return 用户名
     */
    public String getUsernameFromToken(String token) {
        String username;
        try {
            Claims claims = getClaimsFromToken(token);
            username = claims.getSubject();
        } catch (Exception e) {
            username = null;
        }
        return username;
    }

    /**
     * 判断令牌是否过期
     *
     * @param token 令牌
     * @return 是否过期
     */
    public Boolean isTokenExpired(String token) {
        try {
            Claims claims = getClaimsFromToken(token);
            Date expiration = claims.getExpiration();
            return expiration.before(new Date());
        } catch (Exception e) {
            return true;
        }
    }

    /**
     * 刷新令牌
     *
     * @param token 原令牌
     * @return 新令牌
     */
    public String refreshToken(String token) {
        String refreshedToken;
        try {
            Claims claims = getClaimsFromToken(token);
            claims.put(Claims.ISSUED_AT, new Date());
            refreshedToken = generateToken(claims);
        } catch (Exception e) {
            refreshedToken = null;
        }
        return refreshedToken;
    }

    /**
     * 验证令牌
     *
     * @param token       令牌
     * @param userDetails 用户
     * @return 是否有效
     */
    public Boolean validateToken(String token, UserDetails userDetails) {
        JwtUser user = (JwtUser) userDetails;
        String username = getUsernameFromToken(token);
        return (username.equals(user.getUsername()) && !isTokenExpired(token));
    }
}

@ConfigurationProperties(prefix = "jwt")读取配置文件以 "jwt" 前缀的配置信息。

  • 编写一个 Filter
package com.yongda.filter;

import com.yongda.security.JwtTokenUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
 * 拦截器
 * @author K. L. Mao
 * @create 2019/1/11
 */
@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {

    @Autowired
    private UserDetailsService userDetailsService;
    @Autowired
    private JwtTokenUtil jwtTokenUtil;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
        String token = request.getHeader(jwtTokenUtil.getHeader());
        if (!StringUtils.isEmpty(token)) {
            String username = jwtTokenUtil.getUsernameFromToken(token);
            if (username != null && SecurityContextHolder.getContext().getAuthentication() == null){
                UserDetails userDetails = userDetailsService.loadUserByUsername(username);
                if (jwtTokenUtil.validateToken(token, userDetails)){
                    // 将用户信息存入 authentication,方便后续校验
                    UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
                    authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                    // 将 authentication 存入 ThreadLocal,方便后续获取用户信息
                    SecurityContextHolder.getContext().setAuthentication(authentication);
                }
            }
        }
        chain.doFilter(request, response);
    }
}

此过滤器主要是验证令牌的合法性,如果令牌合法,则获取用户信息,并且存入SecurityContextHolder

  • JwtUserDetailsServiceImpl
    JwtUserDetailsServiceImpl这个实现类是实现了UserDetailsServiceUserDetailsService是 Spring Security 进行身份验证的时候会使用,我们这里就一个加载用户信息的简单方法loadUserByUsername,就是得到当前登录用户的一些用户名、密码、用户所拥有的角色等等一些信息。
package com.yongda.security;

import com.yongda.model.Role;
import com.yongda.model.User;
import com.yongda.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.stream.Collectors;

/**
 * @author K. L. Mao
 * @create 2019/1/11
 */
@Service
public class JwtUserDetailsServiceImpl implements UserDetailsService {

    @Autowired
    private UserService userService;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userService.findByUsername(username);
        if (user == null) {
            throw new UsernameNotFoundException(String.format("%s.这个用户不存在", username));
        }
        List<SimpleGrantedAuthority> authorities = user.getRoles().stream().map(Role::getRolename).map(SimpleGrantedAuthority::new).collect(Collectors.toList());
        return new JwtUser(user.getUsername(), user.getPassword(), user.getState(), authorities);
    }
}
  • 自定义登录成功之后的操作类 MyAuthenticationSuccessHandler
package com.yongda.security.handler;

import com.alibaba.fastjson.JSONObject;
import com.yongda.exception.CodeMsg;
import com.yongda.exception.Result;
import com.yongda.security.JwtTokenUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.stereotype.Component;

import javax.servlet.ServletException;
import javax.servlet.ServletOutputStream;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
 * 登录成功操作
 * @author K. L. Mao
 * @create 2019/1/15
 */
@Component
@Slf4j
public class MyAuthenticationSuccessHandler implements AuthenticationSuccessHandler {

    @Autowired
    private JwtTokenUtil jwtTokenUtil;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Authentication authentication) throws IOException, ServletException {
        UserDetails userDetails = (UserDetails) authentication.getPrincipal();
        SecurityContextHolder.getContext().setAuthentication(authentication);
        String token = jwtTokenUtil.generateToken(userDetails);
        renderToken(httpServletResponse, token);
    }

    /**
     * 渲染返回 token 页面,因为前端页面接收的都是Result对象,故使用application/json返回
     *
     * @param response
     * @throws IOException
     */
    public void renderToken(HttpServletResponse response, String token) throws IOException {
        response.setContentType("application/json;charset=UTF-8");
        ServletOutputStream out = response.getOutputStream();
        String str = JSONObject.toJSONString(Result.succes(token));
        out.write(str.getBytes("UTF-8"));
        out.flush();
        out.close();
    }
}

实现接口AuthenticationSuccessHandler,登录成功,把用户信息存入SecurityContextHolder,并且生成token返回给前端。

  • 自定义登录失败操作类 MyAuthenticationFailureHandler
package com.yongda.security.handler;

import com.yongda.exception.CodeMsg;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.stereotype.Component;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
 * 登录失败操作
 * @author K. L. Mao
 * @create 2019/1/15
 */
@Component
public class MyAuthenticationFailureHandler implements AuthenticationFailureHandler {
    @Override
    public void onAuthenticationFailure(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException {
        CodeMsg.USERNAME_OR_PWD_ERROR.renderError(httpServletResponse);
    }
}

实现接口AuthenticationFailureHandler,登录失败,直接返回错误信息给前端。

  • 自定义身份认证失败处理类 EntryPointUnauthorizedHandler
package com.yongda.security.handler;

import com.yongda.exception.CodeMsg;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
 * 身份校验失败处理器,如 token 错误
 * @author K. L. Mao
 * @create 2019/1/11
 */
@Component
public class EntryPointUnauthorizedHandler implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        CodeMsg.AUTH_FAILURE.renderError(response);
    }
}

实现接口AuthenticationEntryPointtoken失效或者错误,直接返回前端认证失败信息。

  • 自定义权限不足处理类 RestAccessDeniedHandler
package com.yongda.security.handler;

import com.yongda.exception.CodeMsg;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
 * 权限校验处理器
 * @author K. L. Mao
 * @create 2019/1/11
 */
@Component
public class RestAccessDeniedHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        CodeMsg.ACCESS_DENIED.renderError(response);
    }
}

实现接口AccessDeniedHandler,权限不足信息返回给前端。

  • WebSecurityConfig
    这个就是Spring Security 的配置类
package com.yongda.config;

import com.yongda.filter.JwtAuthenticationTokenFilter;
import com.yongda.security.handler.EntryPointUnauthorizedHandler;
import com.yongda.security.handler.MyAuthenticationFailureHandler;
import com.yongda.security.handler.MyAuthenticationSuccessHandler;
import com.yongda.security.handler.RestAccessDeniedHandler;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.annotation.web.configurers.ExpressionUrlAuthorizationConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsUtils;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;

/**
 * Spring Security 配置类
 * @EnableGlobalMethodSecurity 开启注解的权限控制,默认是关闭的。
 * prePostEnabled:使用表达式实现方法级别的控制,如:@PreAuthorize("hasRole('ADMIN')")
 * securedEnabled: 开启 @Secured 注解过滤权限,如:@Secured("ROLE_ADMIN")
 * jsr250Enabled: 开启 @RolesAllowed 注解过滤权限,如:@RolesAllowed("ROLE_ADMIN")
 *
 * @author K. L. Mao
 * @create 2019/1/11
 */
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class WebSecurity extends WebSecurityConfigurerAdapter {
    @Autowired
    private UserDetailsService userDetailsService;
    @Autowired
    private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;
    @Autowired
    private EntryPointUnauthorizedHandler entryPointUnauthorizedHandler;
    @Autowired
    private RestAccessDeniedHandler restAccessDeniedHandler;
    @Autowired
    private MyAuthenticationSuccessHandler myAuthenticationSuccessHandler;
    @Autowired
    private MyAuthenticationFailureHandler myAuthenticationFailureHandler;

    /**
     * 从容器中取出 AuthenticationManagerBuilder,执行方法里面的逻辑之后,放回容器
     * @param authenticationManagerBuilder
     * @throws Exception
     */
    @Autowired
    public void configureAuthentication(AuthenticationManagerBuilder authenticationManagerBuilder) throws Exception {
        authenticationManagerBuilder.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
    }

    private PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        /**
         * 在 UsernamePasswordAuthenticationFilter 之前添加 JwtAuthenticationTokenFilter
          */
        http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);

        http.csrf().disable().sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and().authorizeRequests()
                .antMatchers(HttpMethod.OPTIONS, "/**").permitAll()
                // 角色校验时,会自动拼接 "ROLE_"
                .antMatchers("/user/**").hasAnyRole("ADMIN","USER")
                .antMatchers("/non-auth/**").permitAll()
                .anyRequest().authenticated()   // 任何请求,登录后可以访问
                .and().formLogin().loginProcessingUrl("/login")
                .successHandler(myAuthenticationSuccessHandler)
                .failureHandler(myAuthenticationFailureHandler)
                .and().headers().cacheControl();

        ExpressionUrlAuthorizationConfigurer<HttpSecurity>.ExpressionInterceptUrlRegistry registry = http.authorizeRequests();
        //让Spring security 放行所有preflight request(cors 预检请求)
        registry.requestMatchers(CorsUtils::isPreFlightRequest).permitAll();
        // 处理异常情况:认证失败和权限不足
        http.exceptionHandling().authenticationEntryPoint(entryPointUnauthorizedHandler).accessDeniedHandler(restAccessDeniedHandler);
    }

    @Bean
    public CorsFilter corsFilter(){
        UrlBasedCorsConfigurationSource configurationSource = new UrlBasedCorsConfigurationSource();
        CorsConfiguration cors = new CorsConfiguration();
        cors.setAllowCredentials(true);
        cors.addAllowedOrigin("*");
        cors.addAllowedHeader("*");
        cors.addAllowedMethod("*");
        configurationSource.registerCorsConfiguration("/**", cors);
        return new CorsFilter(configurationSource);
    }
}

@EnableGlobalMethodSecurity 开启注解的权限控制,默认是关闭的。

  • prePostEnabled:使用表达式实现方法级别的控制,如:@PreAuthorize("hasRole('ADMIN')")
  • securedEnabled: 开启 @Secured 注解过滤权限,如:@Secured("ROLE_ADMIN")
  • jsr250Enabled: 开启 @RolesAllowed 注解过滤权限,如:@RolesAllowed("ROLE_ADMIN")

通过AuthenticationManagerBuilder将我们自定义的JwtUserDetailsServiceImpl和加密方式BCryptPasswordEncoder进行赋值。

http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
UsernamePasswordAuthenticationFilter之前添加 JwtAuthenticationTokenFilter,让所有请求先到JwtAuthenticationTokenFilter

formLogin().loginProcessingUrl("/login")指定登录请求路径,该路径会走UsernamePasswordAuthenticationFilter进行登录操作。必须是POST请求,而且是FORM表单传参,不能JSON传参。

successHandler(myAuthenticationSuccessHandler)登录成功处理器,failureHandler(myAuthenticationFailureHandler)登录失败处理器。

http.exceptionHandling().authenticationEntryPoint(entryPointUnauthorizedHandler).accessDeniedHandler(restAccessDeniedHandler);捕捉权限控制异常,如果是身份认证异常,就走entryPointUnauthorizedHandler,如果是权限不足异常,则走restAccessDeniedHandler

至此,SpringSecurity 和 JWT 的集成配置完毕!!!

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,015评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,262评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,727评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,986评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,363评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,610评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,871评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,582评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,297评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,551评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,053评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,385评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,035评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,079评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,841评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,648评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,550评论 2 270

推荐阅读更多精彩内容