SpringCloud - zuul网关整合security,实现统一的认证授权中心,并实现权限动态维护
在微服务架构下,由于所有的微服务都被隐藏在网关之后,使得网关成为后端访问的唯一入口。基于这样一种架构,认证授权服务直接整合到网关中,就能很好的处理单点登录,权限控制这类问题。在本例中,网关使用的是zuul,安全框架使用的是security。大致的逻辑是这样的:通过一个过滤器拦截所有请求。对于未登录用户直接放行交由Security配置的访问规则过滤,如果需要登录才能访问的,那么直接跳转到登录...
在微服务架构下,由于所有的微服务都被隐藏在网关之后,使得网关成为后端访问的唯一入口。基于这样一种架构,认证授权服务直接整合到网关中,就能很好的处理单点登录,权限控制这类问题。
在本例中,网关使用的是zuul,安全框架使用的是security。
大致的逻辑是这样的:
- 通过一个过滤器拦截所有请求。对于未登录用户直接放行交由Security配置的访问规则过滤,如果需要登录才能访问的,那么直接跳转到登录页面。而登录的用户会携带一个token,此时过滤器对token进行判断,如果没有过期,则取出token中id字段,然后去缓存中间件查询用户详细信息,封装为一个携带了用户名、密码、以及权限信息的认证数据对象,交给security进行后续比对。对于比对通过权限符合的用户,允许访问接口,而比对不通过的用户,则交由相应处理器处理。
- 权限信息动态加载的问题:在容器初始化的时候,从数据库加载uri及对应访问角色信息,这些信息维护在内存中,以Map形式存在,通过将这个数据源加入到security,实现权限的动态维护。如果需要运行时更新uri对应访问角色信息,只需要提供接口修改这个Map接口。需要注意,一旦权限信息可以动态修改,那么就可能带来一定的安全问题,这是需要权衡的。
好了,现在开始代码部分:
首先是依赖,此处用到了security依赖,以及jwt用于token生成:
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!--JWT-->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
生成token的工具类:
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import javax.servlet.http.HttpServletRequest;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
public class JwtUtilHelper {
private static final Log LOG = LogFactory.getLog(JwtUtilHelper.class);
/**
* 用户名键值
*/
public static final String USERNAME = "username";
/**
* 过期时间
*/
private Long EXPIRATION_TIME;
/**
* 私钥
*/
private String SECRET;
/**
* token前缀
*/
private final String TOKEN_PREFIX = "Bearer";
/**
* 请求头名称
*/
private final String HEADER_STRING = "Authorization";
private final SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:ss:mm");
public JwtUtilHelper(String secret, long expire) {
this.EXPIRATION_TIME = expire;
this.SECRET = secret;
LOG.info("the jwtUtilHelper is started,and the expire is"+expire);
}
/**
* 生成Token
* @param claims 需要保存在token的参数,如用户名username,用户ID
* @return
*/
public Map<String, String> generateToken(Map<String, Object> claims) {
Calendar c = Calendar.getInstance();
c.setTime(new Date());
c.add(Calendar.SECOND, EXPIRATION_TIME.intValue());
Date d = c.getTime();
String jwt = Jwts.builder()
.setClaims(claims)
.setExpiration(d)
.signWith(SignatureAlgorithm.HS512, SECRET)
.compact();
Map<String, String> map = new HashMap<String, String>();
map.put("token",TOKEN_PREFIX + " " + jwt);
map.put("token-type", TOKEN_PREFIX);
map.put("expire-time",format.format(d));
return map;
}
/**
* 解析token
* @param request
* @return
*/
public Map<String, Object> validateTokenAndGetClaims(HttpServletRequest request) {
String token = request.getHeader(HEADER_STRING);
if (token == null) {
return null;
}
Map<String, Object> body = Jwts.parser()
.setSigningKey(SECRET)
.parseClaimsJws(token.replace(TOKEN_PREFIX, ""))
.getBody();
return body;
}
}
通过配置密钥和过期时间初始化Jwt工具类
@Configuration
public class JwtConfig {
/**
* 密钥
*/
private final String secret = "A0B1C2D3E4F5G6H7I8J9KALBMCNDOEPFQ0R1S2T3U4V5W6X7Y8Z9";
/**
* 过期时间
*/
private final long expire = 600L;
@Bean
public JwtUtilHelper jwtHelperBean() {
return new JwtUtilHelper(secret, expire);
}
}
准备工作完成后,就可以开始按流程编码了,先是过滤器对各种访问的处理:
/**
* Token验证
* @author GrainRain
*/
@Component
public class JwtAuthorizationTokenFilter extends OncePerRequestFilter{
private JwtUtilHelper jwtUtilHelper;
@Autowired
public void setJwtUtilHelper(JwtUtilHelper jwtUtilHelper){
this.jwtUtilHelper = jwtUtilHelper;
}
@Autowired
private UserServiceImp jwtUserDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
/**
* 解析token
*/
Map<String, Object> map = jwtUtilHelper.validateTokenAndGetClaims(request);
if (map!=null&&map.size()>0) {
/**
* 从token获取用户名
*/
String username = String.valueOf(map.get(JwtUtilHelper.USERNAME));
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
/**
* 获取用户信息
* 此处仅为示例,可进行更好的实现,如引入缓存中间件
*/
UserDetails userDetails = this.jwtUserDetailsService.loadUserByUsername(username);
if (userDetails!=null) {
/**
* 设置账号密码
*/
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
}
filterChain.doFilter(request, response);
}
}
加载用户信息的类,需要实现UserDetail接口,此处给出一个从数据库加载的简单实现:
/**
* @author GrainRain
* @date 2020/03/04 20:13
**/
//获取用户认证详细信息的类,需要传入username,password,role
@Component
public class UserServiceImp implements UserDetailsService {
@Autowired
private UserMapper userMapper;
@Autowired
private PasswordEncoder passEncoder;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
cn.whitetown.domain.User u = userMapper.selectUserByName(username);
Collection<GrantedAuthority> roles = new LinkedList<>();
roles.add(new SimpleGrantedAuthority(u.getNickname()));
/**
* 这里实际上应该把密码加密存储在数据库,此处就不再需要再加密这一层了
*/
String password = passEncoder.encode(u.getPassword());
UserDetails user = new User(u.getUsername(),password,roles);
return user;
}
}
现在可以装配起来了,只有带着有权限的token用户访问,那么就可以正常获取数据:
/**
* @author GrainRain
* @date 2020/03/04
**/
@EnableWebSecurity
@Configuration
public class DynSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private UserDetailsService userDetailsService;
@Autowired
private JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
@Autowired
private JwtAuthenticationAccessDeniedHandler jwtAuthenticationAccessDeniedHandler;
@Autowired
private JwtAuthorizationTokenFilter jwtAuthorizationTokenFilter;
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/favicon.ico");
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable(). //开启跨域
sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS); //关闭session
/**
* 未登录用户处理
*/
http.exceptionHandling().authenticationEntryPoint(jwtAuthenticationEntryPoint);
http.exceptionHandling().accessDeniedHandler(jwtAuthenticationAccessDeniedHandler);
// 防止iframe 造成跨域
http.headers().frameOptions().disable();
/**
* 添加自定义的过滤器,实现资源权限动态管理
*/
http.addFilterAfter(dynUrlInterceptor, FilterSecurityInterceptor.class);
http.authorizeRequests().antMatchers("/css/**","/index").permitAll(). //访问不受限的页面
anyRequest().permitAll().
and().formLogin().loginProcessingUrl("/login").permitAll().
successHandler(successLoginHandler). //登录成功处理器
and().logout().permitAll(); //退出登录访问的地址
/**
* 设置token解析过滤器在账号密码验证器之前
*/
http.addFilterBefore(jwtAuthorizationTokenFilter, UsernamePasswordAuthenticationFilter.class);
}
//指定UserDetailService
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
}
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
@Autowired
private DynUrlInterceptor dynUrlInterceptor;
@Autowired
private SuccessLoginHandler successLoginHandler;
}
以上可以看出,没登录的用户会交给一个为登录异常处理器处理,没有权限的访问交给异常访问处理器处理。以下是未登录处理器示例,无权限处理可以自己实现即可。
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint, Serializable {
private static final long serialVersionUID = -8970718410437077606L;
@Override
public void commence(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException) throws IOException, ServletException {
response.sendRedirect(request.getContextPath()+"/login.html");
}
}
通过以上处理,没有登录的用户会跳转登录页面,而登录成功的用户则会交给登录成功处理器处理,同样你也可以自行实现登录失败的处理器。
/**
* @author GrainRain
* @date 2020/03/05 10:23
**/
@Component
public class SuccessLoginHandler implements AuthenticationSuccessHandler {
private JwtUtilHelper jwtUtilHelper;
@Autowired
public void setJwtUtilHelper(JwtUtilHelper jwtUtilHelper){
this.jwtUtilHelper = jwtUtilHelper;
}
//登录成后的处理器
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
//获取登录成功后的UserDetail对象
User user = (User) authentication.getPrincipal();
Map<String,Object> chaims = new HashMap<>();
chaims.put("username",user.getUsername());
//生成token
Map<String, String> tokenMap = jwtUtilHelper.generateToken(chaims);
/**
* 实际开发按统一格式返回前端
*/
String returnData = tokenMap.toString();
response.setContentType("application/json;charset=UTF-8");
response.setHeader("Cache-Control","no-store, max-age=0, no-cache, must-revalidate");
response.addHeader("Cache-Control", "post-check=0, pre-check=0");
response.setHeader("Pragma", "no-cache");
response.setStatus(200); //登录成功,设置200
PrintWriter out = response.getWriter();
out.write(returnData);
out.flush();
out.close();
}
}
以上认证授权的代码也就算完成了,只需要和zuul网关整合到一起,就可以实现对微服务的访问权限控制。
接下来看一看动态加载资源和对应角色信息的代码。关键点在于初始化资源数据的类:
/**
* 自定义url与权限数据源
* @author GrainRain
* @date 2020/04/08
**/
@Component
public class SecurityMetaDataSource implements FilterInvocationSecurityMetadataSource {
private Map<RequestMatcher,Collection<ConfigAttribute>> securityMeta;
private Map<String,RequestMatcher> requestMap;
public SecurityMetaDataSource(){
securityMeta = new HashMap<>();
requestMap = new HashMap<>();
//可以多种形式初始化
//合理的做法可能是从数据库加载
String uri = "/test01";
AntPathRequestMatcher requestMatcher = new AntPathRequestMatcher(uri);
ArrayList<ConfigAttribute> configAttributes = new ArrayList<>();
configAttributes.add(new SecurityConfig("ROLE_USER"));
configAttributes.add(new SecurityConfig("ROLE_ADMIN"));
requestMap.put(uri,requestMatcher);
securityMeta.put(requestMatcher,configAttributes);
}
@Override
public Collection<ConfigAttribute> getAttributes(Object o) throws IllegalArgumentException {
FilterInvocation fi = (FilterInvocation)o;
HttpServletRequest request = fi.getRequest();
/**
* 面对大量的资源,每次访问都遍历map并不合理
* 此处仅进行了简单优化,如果提升性能需要仔细考虑
*/
RequestMatcher requestMatcher = requestMap.get(request.getRequestURI());
if(requestMatcher!= null){
System.out.println("请求进入::"+request.getRequestURI());
return securityMeta.get(requestMatcher);
}
for(Map.Entry<RequestMatcher,Collection<ConfigAttribute>> entry: securityMeta.entrySet()){
if(entry.getKey().matches(request)){
return entry.getValue();
}
}
return null;
}
@Override
public Collection<ConfigAttribute> getAllConfigAttributes() {
return null;
}
@Override
public boolean supports(Class<?> aClass) {
return FilterInvocation.class.isAssignableFrom(aClass);
}
Map<RequestMatcher, Collection<ConfigAttribute>> getSecurityMeta() {
return securityMeta;
}
Map<String, RequestMatcher> getRequestMap() {
return requestMap;
}
}
通过security配置类配置的一个过滤器(翻阅本文security配置类代码),使得解析到的访问中角色信息会与getAllConfigAttributes()获取的角色进行比对,如果方法返回角色包含从请求解析出的角色,那么才会允许访问。
以下是两个类是仿照security写的拦截器和投票器,代码几乎照抄,只是将数据源替换成了我们自己加载的数据源信息。
/**
* 自定义拦截器
* 通过对自定义url资源进行拦截,只需动态维护url资源权限,即可动态配置资源权限
* @author GrainRain
* @date 2020/04/08
**/
public class DynUrlInterceptor extends AbstractSecurityInterceptor implements Filter {
//标记自定义的url
private static final String FILTER_APPLIED = "__spring_security_filterSecurityInterceptor_filterApplied_dynamically";
private FilterInvocationSecurityMetadataSource securityMetadataSource;
private boolean observeOncePerRequest = true;
public DynUrlInterceptor() {
}
public DynUrlInterceptor(FilterInvocationSecurityMetadataSource securityMetadataSource) {
this.securityMetadataSource = securityMetadataSource;
}
@Override
public void init(FilterConfig filterConfig) throws ServletException {
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
FilterInvocation fi = new FilterInvocation(request, response, chain);
invoke(fi);
}
@Override
public void destroy() {
}
@Override
public Class<?> getSecureObjectClass() {
return FilterInvocation.class;
}
@Override
public SecurityMetadataSource obtainSecurityMetadataSource() {
return this.securityMetadataSource;
}
public FilterInvocationSecurityMetadataSource getSecurityMetadataSource() {
return this.securityMetadataSource;
}
public void invoke(FilterInvocation fi) throws IOException, ServletException {
if ((fi.getRequest() != null)
&& (fi.getRequest().getAttribute(FILTER_APPLIED) != null)
&& observeOncePerRequest) {
// filter already applied to this request and user wants us to observe
// once-per-request handling, so don't re-do security checking
fi.getChain().doFilter(fi.getRequest(), fi.getResponse());
}
else {
// first time this request being called, so perform security checking
if (fi.getRequest() != null) {
fi.getRequest().setAttribute(FILTER_APPLIED, Boolean.TRUE);
}
InterceptorStatusToken token = super.beforeInvocation(fi);
try {
fi.getChain().doFilter(fi.getRequest(), fi.getResponse());
}
finally {
super.finallyInvocation(token);
}
super.afterInvocation(token, null);
}
}
public void setSecurityMetadataSource(FilterInvocationSecurityMetadataSource securityMetadataSource) {
this.securityMetadataSource = securityMetadataSource;
}
}
/**
*
* @author taixian
* @date 2020/04/08
**/
public class DynAccessDecisionManager extends AbstractAccessDecisionManager {
public DynAccessDecisionManager(List<AccessDecisionVoter<?>> decisionVoters) {
super(decisionVoters);
}
@Override
public void decide(Authentication authentication, Object object, Collection<ConfigAttribute> configAttributes) throws AccessDeniedException, InsufficientAuthenticationException {
int deny = 0;
for (AccessDecisionVoter voter : getDecisionVoters()) {
int result = voter.vote(authentication, object, configAttributes);
if (logger.isDebugEnabled()) {
logger.debug("Voter: " + voter + ", returned: " + result);
}
switch (result) {
case AccessDecisionVoter.ACCESS_GRANTED:
return;
case AccessDecisionVoter.ACCESS_DENIED:
deny++;
break;
default:
break;
}
}
if (deny > 0) {
throw new AccessDeniedException(messages.getMessage(
"AbstractAccessDecisionManager.accessDenied", "Access is denied"));
}
// To get this far, every AccessDecisionVoter abstained
checkAllowIfAllAbstainDecisions();
}
}
同样,将其初始化,这样security配置类对应的配置就可以生效了。
/**
* 初始化配置
* @author taixian
* @date 2020/04/08
**/
@Configuration
public class SecurityInitConfig {
@Autowired
private SecurityMetaDataSource securityMetaDataSource;
@Bean
public DynUrlInterceptor getInstance(){
//这里,用了我们自己定义的资源权限数据源
DynUrlInterceptor dynUrlInterceptor = new DynUrlInterceptor(securityMetaDataSource);
//配置RoleVoter决策
List<AccessDecisionVoter<? extends Object>> decisionVoters = new ArrayList<AccessDecisionVoter<? extends Object>>();
decisionVoters.add(new RoleVoter());
dynUrlInterceptor.setAccessDecisionManager(new DynAccessDecisionManager(decisionVoters));
return dynUrlInterceptor;
}
@Bean
public Map<RequestMatcher, Collection<ConfigAttribute>> getSecurityMetaMap(){
return securityMetaDataSource.getSecurityMeta();
}
@Bean
public Map<String,RequestMatcher> getRequestMap(){
return securityMetaDataSource.getRequestMap();
}
}
到这一步就差不多了。如果要动态更新资源对应角色信息,那么只需要更新权限数据源中定义的Map即可。但是这么做是否合理是需要考虑的。
/**
* 动态更新数据源的实现,读者可按自己的方式实现
* @author taixian
* @date 2020/04/18
**/
@Service
public class SecurityServiceImp implements SecurityService{
@Autowired
private Map<String,RequestMatcher> requestMap;
@Autowired
private Map<RequestMatcher, Collection<ConfigAttribute>> securityMeta;
@Override
public void reloadMeta(String uri, String ro) {
if(uri==null || "".equals(uri))
throw new NullPointerException();
if(!uri.startsWith("/")){
uri = "/"+uri;
}
try {
RequestMatcher requestMatcher = new AntPathRequestMatcher(uri);
ArrayList<ConfigAttribute> configAttributes = new ArrayList<>();
String[] roles = ro.split(",");
for (String role : roles) {
configAttributes.add(new SecurityConfig(role));
}
if (!uri.endsWith("/**")) {
requestMap.put(uri, requestMatcher);
}
securityMeta.put(requestMatcher, configAttributes);
}catch (SecurityNoDefineException e){
throw new SecurityNoDefineException(e.getMessage());
}
}
}
总结:安全框架比较常用的有security和shiro,shiro做了更高阶的封装,相对也要容易一些,而security健壮性更强,提供了更灵活的实现方式,同样的编码难度也就相对更大一些。
以上实现,提供了security的常规用法。同时,通过自定义权限数据源,也可以非常灵活的实现资源权限动态控制。
更多推荐
所有评论(0)