SpringSecurity(2)— 微服务权限方案

(1)如果是基于 Session,那么 Spring-security 会对 cookie 里的 sessionId 进行解析,找到服务器存储的 session 信息,然后判断当前用户是否符合请求的要求。

问题:

  1. 通常而言session都是保存在内存中,而随着认证用户的增多,服务端的开销会明显增大。
  2. 在分布式的应用上,如果认证的记录被保存在内存中的话,这意味着用户下次请求还必须要请求在这台服务器上,这样才能拿到授权的资源,相应的限制了负载均衡器的能力。
  3. 因为是基于cookie来进行用户识别的, cookie如果被截获,用户就会很容易受到跨站请求伪造的攻击。(CSRF攻击,跨站请求伪造)

(2)如果是基于 token 的形式进行授权与认证,流程如下:

  • 用户使用用户名密码来请求服务器
  • 服务器进行验证用户的信息
  • 服务器通过验证发送给用户一个token
  • 客户端存储token,并在每次请求时附送上这个token值
  • 服务端验证token值,并返回数据

这个token必须要在每次请求时传递给服务端,它应该保存在请求头里。

基于token认证机制的应用不需要去考虑用户在哪一台服务器登录了,这就为应用的扩展提供了便利。

JWT

json web token (JWT), 是为了在网络应用环境间传递声明而执行的一种基于JSON的开放标准。该token被设计为紧凑且安全的,特别适用于分布式站点的单点登录(SSO)场景。JWT的声明一般被用来在身份提供者和服务提供者间传递被认证的用户身份信息,以便于从资源服务器获取资源,也可以增加一些额外的其它业务逻辑所必须的声明信息,该token也可直接被用于认证,也可被加密。

JWT是由三段信息构成的,将这三段信息文本用.链接一起就构成了Jwt字符串。

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.
TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ

第一部分我们称它为头部(header),第二部分我们称其为载荷(payload, 类似于飞机上承载的物品),第三部分是签证(signature)。

header

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

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

完整的头部就像下面这样的JSON:

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

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

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9

playload

载荷就是存放有效信息的地方。这个名字像是特指飞机上承载的货品,这些有效信息包含三个部分

  • 标准中注册的声明
  • 公共的声明
  • 私有的声明

标准中注册的声明 (建议但不强制使用) :

  • iss: jwt签发者
  • sub: jwt所面向的用户
  • aud: 接收jwt的一方
  • exp: jwt的过期时间,这个过期时间必须要大于签发时间
  • nbf: 定义在什么时间之前,该jwt都是不可用的.
  • iat: jwt的签发时间
  • jti: jwt的唯一身份标识,主要用来作为一次性token,从而回避重放攻击。

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

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

定义一个payload:

{
  "sub": "1234567890",
  "name": "John Doe",
  "admin": true
}

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

eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9

signature

jwt的第三部分是一个签证信息,这个签证信息由三部分组成:

  • header (base64后的)
  • payload (base64后的)
  • secret

这个部分需要base64加密后的header和base64加密后的payload使用.连接组成的字符串,然后通过header中声明的加密方式进行加盐secret组合加密,然后就构成了jwt的第三部分。

var encodedString = base64UrlEncode(header) + '.' + base64UrlEncode(payload);

var signature = HMACSHA256(encodedString, 'secret');

得到Jwt的第三部分。

TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ

注意:secret是保存在服务器端的,jwt的签发生成也是在服务器端的,secret就是用来进行jwt的签发和jwt的验证,所以,它就是你服务端的私钥,在任何场景都不应该流露出去。一旦客户端得知这个secret, 那就意味着客户端是可以自我签发jwt了。

微服务权限方案

utils包

  1. 写一个工具类MD5Util,便于处理MD5加密
/**
 * MD5加密工具类
 */
public final class Md5Util {

    private Md5Util() {
        throw new UnsupportedOperationException("cannot be instantiated");
    }

    /**
     * 计算字符串的 MD5 值
     *
     * @param plaintext 明文
     * @return 密文
     */
    public static String encrypt(String plaintext) {
        if (StringUtils.isEmpty(plaintext)) {
            return "";
        }
        try {
            MessageDigest md5 = MessageDigest.getInstance("MD5");
            byte[] bytes = md5.digest(plaintext.getBytes());
            String result = "";
            for (byte b : bytes) {
                String temp = Integer.toHexString(b & 0xff);
                if (temp.length() == 1) {
                    temp = "0" + temp;
                }
                result += temp;
            }
            return result;
        } catch (NoSuchAlgorithmException e) {
            e.printStackTrace();
        }
        return "";
    }

    /**
     * 计算文件的 MD5 值
     *
     * @param file 需要加密的文件
     * @return 加密后的数据
     */
    public static String encrypt(File file) {
        if (file == null || !file.isFile() || !file.exists()) {
            return "";
        }
        FileInputStream in = null;
        String result = "";
        byte buffer[] = new byte[8192];
        int len;
        try {
            MessageDigest md5 = MessageDigest.getInstance("MD5");
            in = new FileInputStream(file);
            while ((len = in.read(buffer)) != -1) {
                md5.update(buffer, 0, len);
            }
            byte[] bytes = md5.digest();

            for (byte b : bytes) {
                String temp = Integer.toHexString(b & 0xff);
                if (temp.length() == 1) {
                    temp = "0" + temp;
                }
                result += temp;
            }
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            if (null != in) {
                try {
                    in.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
        return result;
    }

    /**
     * 采用nio方式进行 MD5 加密
     *
     * @param file 需要加密的文件
     * @return 加密后的数据
     */
    public static String encryptByNio(File file) {
        String result = "";
        FileInputStream in = null;
        try {
            in = new FileInputStream(file);
            MappedByteBuffer byteBuffer = in.getChannel().map(FileChannel.MapMode.READ_ONLY, 0, file.length());
            MessageDigest md5 = MessageDigest.getInstance("MD5");
            md5.update(byteBuffer);
            byte[] bytes = md5.digest();
            for (byte b : bytes) {
                String temp = Integer.toHexString(b & 0xff);
                if (temp.length() == 1) {
                    temp = "0" + temp;
                }
                result += temp;
            }
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            if (null != in) {
                try {
                    in.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
        return result;
    }

    /**
     * 对字符串进行对此 MD5 加密,提高安全性
     *
     * @param string 需要加密的数据
     * @param times  加密次数
     * @return 加密后的数据
     */
    public static String encrypt(String string, int times) {
        if (StringUtils.isEmpty(string)) {
            return "";
        }
        String md5 = encrypt(string);
        for (int i = 0; i < times - 1; i++) {
            md5 = encrypt(md5);
        }
        return encrypt(md5);
    }

    /**
     * MD5加盐
     * <p>
     * string + key(盐值 key)然后进行 MD5 加密
     *
     * @param string 需要加密的数据
     * @param slat 盐
     * @return 加密后的数据
     */
    public static String encrypt(String string, String slat) {
        if (StringUtils.isEmpty(string)) {
            return "";
        }
        MessageDigest md5 = null;
        try {
            md5 = MessageDigest.getInstance("MD5");
            byte[] bytes = md5.digest((string + slat).getBytes());
            String result = "";
            for (byte b : bytes) {
                String temp = Integer.toHexString(b & 0xff);
                if (temp.length() == 1) {
                    temp = "0" + temp;
                }
                result += temp;
            }
            return result;
        } catch (NoSuchAlgorithmException e) {
            e.printStackTrace();
        }
        return "";
    }
}
  1. 写统一结果返回结果类
// 统一结果返回结果类
@Data
public class R {
    private Boolean success;
    private Integer code;
    private String message;
    private Map<String, Object> data = new HashMap<String, Object>();

    private R() {
    }

    public static R ok() {
        R r = new R();
        r.setSuccess(true);
        r.setCode(200);
        r.setMessage("成功");
        return r;
    }

    public static R error() {
        R r = new R();
        r.setSuccess(false);
        r.setCode(500);
        r.setMessage("失败");
        return r;
    }

    public R success(Boolean success) {
        this.setSuccess(success);
        return this;
    }

    public R message(String message) {
        this.setMessage(message);
        return this;
    }

    public R code(Integer code) {
        this.setCode(code);
        return this;
    }

    public R data(String key, Object value) {
        this.data.put(key, value);
        return this;
    }

    public R data(Map<String, Object> map) {
        this.setData(map);
        return this;
    }
}
  1. 写一个响应工具类
public class ResponseUtil {

    private ResponseUtil() {
    }

    public static void write(HttpServletResponse response, R r) {
        ObjectMapper mapper = new ObjectMapper();
        response.setStatus(HttpStatus.OK.value());
        response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
        try {
            mapper.writeValue(response.getWriter(),r);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

}

security包

  1. 写密码处理类,实现PasswordEncoder接口,加密方式使用MD5加密。
@Component
public class DefaultPasswordEncoder implements PasswordEncoder {

    public DefaultPasswordEncoder() {
        this(-1);
    }

    public DefaultPasswordEncoder(int strength) {

    }

    public String encode(CharSequence rawPassword) {
        return Md5Util.encrypt(rawPassword.toString());
    }

    public boolean matches(CharSequence rawPassword, String encodedPassword) {
        return encodedPassword.equals(Md5Util.encrypt(rawPassword.toString()));
    }
}
  1. 写token操作的工具类,使用JWT,做token的加密和解密。
@Component
public class TokenManager {

    // token有效时长
    private long tokenExpiration = 24 * 60 * 60 * 1000;

    // 密钥
    private String tokenSignKey = "123456";

    // 1.使用JWT根据用户名生成token
    public String createToken(String username) {
        return Jwts.builder().setSubject(username)
                .setExpiration(new Date(System.currentTimeMillis() + tokenExpiration))
                .signWith(SignatureAlgorithm.HS512, tokenSignKey).compressWith(CompressionCodecs.GZIP).compact();
    }

    // 2.根据token字符串得到用户信息
    public String getUserFromToken(String token) {
        return Jwts.parser().setSigningKey(tokenSignKey).parseClaimsJws(token).getBody().getSubject();
    }

    public void removeToken(String token) {        
        //jwttoken 无需删除,客户端扔掉即可。     
    }
    
}
  1. 自定义退出处理器,实现LogoutHandler接口,从redis中删除用户的认证信息。
public class TokenLogoutHandler implements LogoutHandler {

    private TokenManager tokenManager;

    private RedisTemplate redisTemplate;

    public TokenLogoutHandler(TokenManager tokenManager, RedisTemplate redisTemplate) {
        this.tokenManager = tokenManager;
        this.redisTemplate = redisTemplate;
    }

    @Override
    public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
        // 1.从header中获取token
        String token = request.getHeader("token");

        // 2.token不为空,移除token,从redis删除对应token数据
        if (token != null) {
            tokenManager.removeToken(token);
            String userName = tokenManager.getUserFromToken(token);
            redisTemplate.delete(userName);
        }
            ResponseUtil.write(response, R.ok());
    }
}
  1. 写未认证统一处理类,实现AuthenticationEntryPoint接口,认证的入口点,这个类的主要作用是呈现给用户一个合适的响应从而提示用户能够重新登录

AuthenticationEntryPoint :它在用户请求处理过程中遇到认证异常时,被ExceptionTranslationFilter过滤器拦截,用于开启特定认证方案的认证流程。

方法commence实现,就是处理认证异常的 request 用户请求,通过 response 返回给用户响应,引导用户进入认证流程。

public class UnauthenticatedEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
            ResponseUtil.write(response, R.error());
    }
}

entity包

  1. 用户实体类
@Data
public class User implements Serializable {
    private String username;
    private String password;
    private String nickName;
    private String salt;
    private String token;
}
  1. UserDetail
@Data
public class SecurityUser implements UserDetails {     
    private transient User currentUserInfo;    // 当前登录用户实体类
    private List<String> permissionValueList;  // 当前登录用户权限列表

    public SecurityUser() {
    }

    public SecurityUser(User user) {
        if (user != null) {
            this.currentUserInfo = user;
        }
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {

        Collection<GrantedAuthority> authorities = new ArrayList<>();
        for (
                String permissionValue : permissionValueList) {
            if (StringUtils.isEmpty(permissionValue)) continue;
            SimpleGrantedAuthority authority = new SimpleGrantedAuthority(permissionValue);
            authorities.add(authority);
        }
        return authorities;
    }

    @Override
    public String getPassword() {
        return currentUserInfo.getPassword();
    }

    @Override
    public String getUsername() {
        return currentUserInfo.getUsername();
    }

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

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

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

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

filter包

Spring Security 采取过滤链实现认证与授权,只有当前过滤器通过,才能进入下一个过滤器:

kubernetes spring微服务 微服务 springsecurity_客户端

绿色部分是认证过滤器,需要我们自己配置,可以配置多个认证过滤器。认证过滤器可以使用 Spring Security 提供的认证过滤器,也可以自定义过滤器(例如:短信验证)。认证过滤器要在 configure(HttpSecurity http)方法中配置,没有配置不生效。

  1. UsernamePasswordAuthenticationFilter:处理用户表单登录认证,认证成功生成token存入用户浏览器和redis中
public class TokenUsernamePasswordAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

    private AuthenticationManager authenticationManager;
    private TokenManager tokenManager;
    private RedisTemplate redisTemplate;

    public TokenUsernamePasswordAuthenticationFilter(AuthenticationManager authenticationManager, TokenManager tokenManager, RedisTemplate redisTemplate) {
        this.authenticationManager = authenticationManager;
        this.tokenManager = tokenManager;
        this.redisTemplate = redisTemplate;
        this.setPostOnly(false);
        // 设置登录路径和提交方式
        this.setRequiresAuthenticationRequestMatcher(new AntPathRequestMatcher("/admin/acl/login", "POST")); 
    }

    // 获得表单提交的用户名和密码,调用认证的方法
    @Override
    public Authentication attemptAuthentication(HttpServletRequest req, HttpServletResponse res) throws AuthenticationException {
        try {
            // 获得表单提交的用户名和密码,将它封装到实体类User对象中。
            User user = new ObjectMapper().readValue(req.getInputStream(), User.class);
            // 将表单提交的用户名和密码封装到 UsernamePasswordAuthenticationToken 中。
            // UsernamePasswordAuthenticationToken继承AbstractAuthenticationToken实现Authentication接口,用于存储用户认证的信息。
            UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(user.getUsername(), user.getPassword());
            // AuthenticationManager 对 Authentication 进行认证操作,认证操作会调用 userServiceDetails 进行认证。
            return authenticationManager.authenticate(authentication);
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    // 认证成功之后执行的方法
    @Override
    protected void successfulAuthentication(HttpServletRequest req, HttpServletResponse res, FilterChain chain, Authentication auth) throws IOException, ServletException {
        // 使用 UserServiceDetail 认证成功后用户认证的信息存放在参数 Authentication auth 中 ,从这个参数中可以获取用户的信息 UserDetail 。
        SecurityUser user = (SecurityUser) auth.getPrincipal();

        // 根据 UserDetail 获取用户信息中的用户名,将用户名放入redis里的key中,将用户权限列表放入redis里的 value 中。
        redisTemplate.opsForValue().set(user.getCurrentUserInfo().getUsername(), user.getPermissionValueList());
        // 根据 UserDetail  获取用户信息中的用户名,将用户名通过 jwt 加密生成token并返回给用户浏览器。
        String token = tokenManager.createToken(user.getCurrentUserInfo().getUsername());
        ResponseUtil.write(res, R.ok().data("token", token));
    }

    // 认证失败之后执行的方法
    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException {
        ResponseUtil.write(response, R.error());
    }
}
  1. BasicAuthenticationFilter:处理用户访问需要认证授权的页面查看是否认证
public class TokenAuthenticationFilter extends BasicAuthenticationFilter {
    private TokenManager tokenManager;
    private RedisTemplate redisTemplate;

    public TokenAuthenticationFilter(AuthenticationManager authManager, TokenManager tokenManager, RedisTemplate redisTemplate) {
        super(authManager);
        this.tokenManager = tokenManager;
        this.redisTemplate = redisTemplate;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain) throws IOException, ServletException {
        // 1.获取当前认证成功用户的权限信息
        UsernamePasswordAuthenticationToken authentication = getAuthentication(req);
        // 2.判断是否有权限信息,有的话放入权限上下文中
        if (authentication != null) {
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }
        chain.doFilter(req, res);
    }

    private UsernamePasswordAuthenticationToken getAuthentication(HttpServletRequest request) {
        // 从 header 中获得 token,如果token为空说明没登录,返回null 
        String token = request.getHeader("token");
        if (token != null) {
            // 解析token获得用户名
            String userName = tokenManager.getUserFromToken(token);
            // 根据用户名获得对应的权限列表
            List<String> permissionValueList = (List<String>) redisTemplate.opsForValue().get(userName);
			// 将权限列表转成 Collection<GrantedAuthority>数据类型,最后封装到UsernamePasswordAuthenticationToken用户权限信息中。
            Collection<GrantedAuthority> authorities = new ArrayList<>();
            for (String permissionValue : permissionValueList) {
                SimpleGrantedAuthority authority = new SimpleGrantedAuthority(permissionValue);
                authorities.add(authority);
            }
            return new UsernamePasswordAuthenticationToken(userName, token, authorities);
        }
        return null;
    }
}

service包

  1. 写UserDetailServiceImpl,实现 UserDetailsService接口,自定义查询数据库用户名密码和权限信息。
@Service("userDetailsService")
public class UserDetailsServiceImpl implements UserDetailsService {

    @Autowired
    private UserService userService;

    @Autowired
    private PermissionService permissionService;

    // 通过用户名验证,生成UserDetails对象封装用户信息
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // 根据用户输入的用户名查找数据库,查找是否有这个用户,没有就抛出异常。
        User user = userService.selectByUsername(username);
        if (user==null){
            throw new UsernameNotFoundException("用户不存在");
        }
        // 查询数据库查找此用户的权限列表,将此用户和此用户的权限列表封装到 UserDetails 中。
        List<String> permissionList = permissionService.selectPermissionValueByUserId(user.getId());
        SecurityUser securityUser = new SecurityUser();
        securityUser.setCurrentUserInfo(user);
        securityUser.setPermissionValueList(permissionList);
        return securityUser;
    }
}

config包

  1. 编写 TokenWebSecurityConfig ,继承 WebSecurityConfigurerAdapter 类,配置Spring Security。
@Configuration
public class TokenWebSecurityConfig extends WebSecurityConfigurerAdapter {

    // 自定义查询数据库用户名密码和权限信息
    @Autowired
    private UserDetailsService userDetailsService;
    // token 管理工具类(生成 token )
    private TokenManager tokenManager;
    // 密码管理工具类
    private DefaultPasswordEncoder defaultPasswordEncoder;
    // redis 操作工具类
    private RedisTemplate redisTemplate;

    public TokenWebSecurityConfig(UserDetailsService userDetailsService, DefaultPasswordEncoder defaultPasswordEncoder, TokenManager tokenManager, RedisTemplate redisTemplate) {
        this.userDetailsService = userDetailsService;
        this.defaultPasswordEncoder = defaultPasswordEncoder;
        this.tokenManager = tokenManager;
        this.redisTemplate = redisTemplate;
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.exceptionHandling()
                .authenticationEntryPoint(new UnauthenticatedEntryPoint()); // 配置未认证统一处理类

        http.authorizeRequests()
                .anyRequest().authenticated(); // 所有请求路径都需要认证

        http.logout()
                .logoutUrl("/admin/acl/logout") // 注销的路径
                .addLogoutHandler(new TokenLogoutHandler(tokenManager, redisTemplate)); // 注销执行注销的处理器

        // 添加两个自定义的认证过滤器
        http.addFilter(new TokenUsernamePasswordAuthenticationFilter(authenticationManager(), tokenManager, redisTemplate))
                .addFilter(new TokenAuthenticationFilter(authenticationManager(), tokenManager, redisTemplate)).httpBasic();

        http.csrf().disable();  // 关闭csrf
    }

    @Override
    public void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService) // 自定义获取用户信息和权限列表
                .passwordEncoder(defaultPasswordEncoder); // 自定义密码的处理方式
    }

    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring()
                .antMatchers("/api/**", "/swagger-ui.html/**");  // 不进行认证的路径
    }

}

总结流程:

  • 用户访问需要认证授权的界面,被BasicAuthenticationFilter拦截,根据用户浏览器header中的token进行解析,解析出用户名,通过用户名查找redis获取用户的权限列表,来判断用户是否认证。
  • 如果认证,生成Authentication已认证,可以访问。
  • 如果未认证,抛出异常被ExceptionTranslationFilter拦截,执行AuthenticationEntryPoint中的commence方法,引导用户进入认证流程,例如返回登录界面。
  • 用户填写用户名密码进行表单登录,被UsernamePasswordAuthenticationFilter拦截,获得表单提交的用户名和密码,生成Authentication未认证,通过AuthenticationManager对authentication进行认证,AuthenticationManager会调用UserDetailsService进行认证,UserDetailsService通过用户名查询数据库里的用户的信息和权限列表,返回UserDetails对象封装用户信息和权限列表,判断用户名密码是否正确,正确会生成Authentication已认证。
  • 如果正确,生成Authentication已认证,将用户名和用户权限放入 redis 中,key 存用户名,value 存权限列表。
  • 如果不正确,返回错误码,重新认证。
  • 用户注销时,根据用户浏览器header中的token进行解析,解析出用户名,从redis删除对应token数据。