很多人都会问微服务如何保证安全?我们知道一个系统即使做得再好,可能也会出现一些意料之外的Bug,安全也是一样的,防不胜防,但是如果我们能够尽量避免一些低级的错误或者本身代码的问题,那么我们可以将系统的风险降到最低。
随着这几年微服务的崛起,可以说很多技术的交流、讨论、实施都离不开微服务,微服务解决了传统项目中的很多问题,我想小伙伴们一定非常熟悉微服务开发的流程,那么这里我就不用很多文字来阐述,不清楚的可以看我的SpringCloud其它的文章,这里我就不多说了。
首先微服务的切入点那么大家能够想到的就是用户的登陆认证,那么在微服务中我们肯定有一个用户模块,这个用户模块负责登陆,那么登陆的话会考虑很多因素,比如传统的Session或者是比较流行的Token认证(颁发令牌)来完成认证,我们知道Session容易伪造和篡改,不安全,Token经常用在前后端分离项目中,前端一般会发起一个获取Token的一个请求,得到Token后,携带上这串Token去访问相关的服务,当然还有的就是直接请求登陆接口然后用户名和密码正确的情况下,直接将用户信息和Token返回(这种是不安全的),要知道我们不能把token暴露给前端,不能让前端使用字符串来拼接Token,我们尽量由后端来一气呵成,所谓的一气呵成指的是Token由后端来拼接或者通过网关来转发,而不是直接返回,这样能够降低被拦截的风险,从而保证系统的安全。
当然我认为系统没有绝对的安全,即使是BAT公司的项目也会存在安全的问题,但是Spring官方给我们提供了安全的解决方案,可以在spring.io的官网上的project栏目中找到Spring Security Oauth的栏目,如下图所示
如果没有Oauth相关的经验的话,我建议去看一下阮一峰老师Oauth2
讲的特别好,这里我就不用太多篇幅来讲一些Oauth的基础,下面我们就开始今天这个话题的目的,让大家学会如何在微服务中如何保证系统的安全。
首先这个是基于最新版本的SpringBoot以及GSR2的Cloud版本,这个项目没有使用到注册中心,因为注册中心由很多,eureka、zookeeper等,不统一,所以这里不用注册中心,为了大家能够看清项目结构我能截图项目结构给大家看看
login-template是一个前端的一个客户端,由vue编写
order-api 是一个模拟订单的微服务
server-auth 这是授权服务器
server-gateway-zuul 是微服务网关
首先看项目的名称大家应该也知道这里会使用Oauth2的密码模式,这种模式适合App项目,这里不做太多解释,详情可以看阮一峰老师的Oauth2精讲,这个多级项目的重点是在server-auth这个项目中,我们可以看下这个项目的结构还是蛮简单清爽的
首先来讲解一下UserDetailsServiceImpl这个类
@Component
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
private PasswordEncoder passwordEncoder;
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return User.withUsername(username)
.password(passwordEncoder.encode("123456"))
.authorities("ROLE_ADMIN")
.build();
}
}
这串代码非常的简单,这个类直接实现了Spring Security的UserDetailsService这个接口,而这个接口就只有一个方法loadUserByUsername,这个方法就是根据什么条件去查询用户信息,默认是username就是根据用户名查询,我这个方法呢比较简单,没有从数据库去查询用户的信息,只要密码是123456我就认为它是正确的,主要是让业务简化,在实际项目中,这里你需要注入mapper或者repository从数据库中查询,这里只是为了演示方便以及让大家能够简单的串起来,这里使用了Spring Security 官方提供的密码加密器PasswordEncoder,关于这个加密器大家可以去看官方文档,这个加密器非常的强大可以说是在Spring 中最强大的加密器。
我们继续来看一下Oauth2AuthServerConfig的代码
@Configuration
@EnableAuthorizationServer
public class Oauth2AuthServerConfig extends AuthorizationServerConfigurerAdapter {
@Autowired
private UserDetailsService userDetailsService;
@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private DataSource dataSource;
@Bean
public TokenStore tokenStore () {
return new JdbcTokenStore(dataSource);
}
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.jdbc(dataSource);
}
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints
.userDetailsService(userDetailsService)
.tokenStore(tokenStore())
.authenticationManager(authenticationManager);
}
@Override
public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
security.checkTokenAccess("isAuthenticated()");
}
}
我们来解析一下代码,第一个注解就不解释了,直接看第二个注解,@EnableAuthorizationServer
这个注解是告诉Spring 我是一个授权服务器,这里使用了原生的JdbcTokenStore
作为数据源,这个表结构可以从JdbcTokenStore这个源码中获取
private static final Log LOG = LogFactory.getLog(JdbcTokenStore.class);
private static final String DEFAULT_ACCESS_TOKEN_INSERT_STATEMENT = "insert into oauth_access_token (token_id, token, authentication_id, user_name, client_id, authentication, refresh_token) values (?, ?, ?, ?, ?, ?, ?)";
private static final String DEFAULT_ACCESS_TOKEN_SELECT_STATEMENT = "select token_id, token from oauth_access_token where token_id = ?";
private static final String DEFAULT_ACCESS_TOKEN_AUTHENTICATION_SELECT_STATEMENT = "select token_id, authentication from oauth_access_token where token_id = ?";
private static final String DEFAULT_ACCESS_TOKEN_FROM_AUTHENTICATION_SELECT_STATEMENT = "select token_id, token from oauth_access_token where authentication_id = ?";
private static final String DEFAULT_ACCESS_TOKENS_FROM_USERNAME_AND_CLIENT_SELECT_STATEMENT = "select token_id, token from oauth_access_token where user_name = ? and client_id = ?";
private static final String DEFAULT_ACCESS_TOKENS_FROM_USERNAME_SELECT_STATEMENT = "select token_id, token from oauth_access_token where user_name = ?";
private static final String DEFAULT_ACCESS_TOKENS_FROM_CLIENTID_SELECT_STATEMENT = "select token_id, token from oauth_access_token where client_id = ?";
private static final String DEFAULT_ACCESS_TOKEN_DELETE_STATEMENT = "delete from oauth_access_token where token_id = ?";
private static final String DEFAULT_ACCESS_TOKEN_DELETE_FROM_REFRESH_TOKEN_STATEMENT = "delete from oauth_access_token where refresh_token = ?";
private static final String DEFAULT_REFRESH_TOKEN_INSERT_STATEMENT = "insert into oauth_refresh_token (token_id, token, authentication) values (?, ?, ?)";
private static final String DEFAULT_REFRESH_TOKEN_SELECT_STATEMENT = "select token_id, token from oauth_refresh_token where token_id = ?";
private static final String DEFAULT_REFRESH_TOKEN_AUTHENTICATION_SELECT_STATEMENT = "select token_id, authentication from oauth_refresh_token where token_id = ?";
private static final String DEFAULT_REFRESH_TOKEN_DELETE_STATEMENT = "delete from oauth_refresh_token where token_id = ?";
private String insertAccessTokenSql = "insert into oauth_access_token (token_id, token, authentication_id, user_name, client_id, authentication, refresh_token) values (?, ?, ?, ?, ?, ?, ?)";
private String selectAccessTokenSql = "select token_id, token from oauth_access_token where token_id = ?";
private String selectAccessTokenAuthenticationSql = "select token_id, authentication from oauth_access_token where token_id = ?";
private String selectAccessTokenFromAuthenticationSql = "select token_id, token from oauth_access_token where authentication_id = ?";
private String selectAccessTokensFromUserNameAndClientIdSql = "select token_id, token from oauth_access_token where user_name = ? and client_id = ?";
private String selectAccessTokensFromUserNameSql = "select token_id, token from oauth_access_token where user_name = ?";
private String selectAccessTokensFromClientIdSql = "select token_id, token from oauth_access_token where client_id = ?";
private String deleteAccessTokenSql = "delete from oauth_access_token where token_id = ?";
private String insertRefreshTokenSql = "insert into oauth_refresh_token (token_id, token, authentication) values (?, ?, ?)";
private String selectRefreshTokenSql = "select token_id, token from oauth_refresh_token where token_id = ?";
private String selectRefreshTokenAuthenticationSql = "select token_id, authentication from oauth_refresh_token where token_id = ?";
private String deleteRefreshTokenSql = "delete from oauth_refresh_token where token_id = ?";
private String deleteAccessTokenFromRefreshTokenSql = "delete from oauth_access_token where refresh_token = ?";
private AuthenticationKeyGenerator authenticationKeyGenerator = new DefaultAuthenticationKeyGenerator();
private final JdbcTemplate jdbcTemplate;
这里我就不解释这些参数了,相信大家看sql就明白了,这个sql文件我放在了resouce目录中,大家copy然后执行即可。
我们的这个类集成的是AuthorizationServerConfigurerAdapter这个适配器,这里呢我们实现了这三个configure
方法其中ClientDetailsServiceConfigurer从那里获取clientid呢?当然这里是从数据库中获取,所以这里使用的是jdbc模式,将我们的dataSource
注入进去。
AuthorizationServerEndpointsConfigurer
这个是授权服务Endpoints配置类,说简单一点就是代理配置类,这个配置类里面我们端点配置了两项,分别是tokenstroe、authenticationManager(凭证信息管理)
AuthorizationServerSecurityConfigurer
这个类呢是授权服务访问授权配置类,它也是一个代理配置类,置客户端信息:从Spring容器中加载所有AuthorizationServerConfigurer
配置类来完成ClientDetailsServiceConfigurer
的配置
其中checkTokenAccess
是如何检查token的有效,这里使用的是表达式isAuthenticated()
意思是开启/oauth/check_token
验证端口认证权限访问
那么关于这个server-auth的重要的类我就讲完了,下面我们来看一下网关 server-gateway-zuul
这个项目
这个项目里面只关心AuthorizationFilter和OAuthFilter这两个类
我们来看一下AuthorizationFilter的代码
Slf4j
@Component
public class AuthorizationFilter extends ZuulFilter {
@Override
public boolean shouldFilter() {
return true;
}
@Override
public Object run() throws ZuulException {
log.info("authorization start");
RequestContext requestContext = RequestContext.getCurrentContext();
HttpServletRequest request = requestContext.getRequest();
if (isNeedAuth(request)) {
TokenInfo tokenInfo = (TokenInfo) request.getAttribute("tokenInfo");
if (tokenInfo != null && tokenInfo.isActive()) {
if (!hasPermission(tokenInfo, request)) {
log.info("audit log update fail 403");
handleError(403, requestContext);
}
requestContext.addZuulRequestHeader("username", tokenInfo.getUser_name());
} else {
if (!StringUtils.startsWith(request.getRequestURI(), "/token")) {
log.info("audit log update fail 401");
handleError(401, requestContext);
}
}
}
return null;
}
private void handleError(int status, RequestContext requestContext) {
requestContext.getResponse().setContentType("application/json");
requestContext.setResponseStatusCode(status);
requestContext.setResponseBody("{\"message\":\"auth fail\"}");
requestContext.setSendZuulResponse(false);
}
private boolean hasPermission(TokenInfo tokenInfo, HttpServletRequest request) {
return true; //RandomUtils.nextInt() % 2 == 0;
}
private boolean isNeedAuth(HttpServletRequest request) {
return true;
}
@Override
public String filterType() {
return "pre";
}
@Override
public int filterOrder() {
return 3;
}
}
这个类实现类ZuulFilter这个过滤器,这个过滤器是网关提供的,这里过滤器的类型我使用的是pre前置过滤器
,其重点run这个方法里面,因为run方法是我们逻辑的实现,其中由于我们的run方法本身是个没有带任何参数的方法,我们只能通过RequestContext获取上下文的HttpServletRequest对象,从里面获取我们需要的信息,这里看代码可以知道我从从request域对象里面获取tokeninfo的信息,我们来看一下TokenInfo这个类的代码
@Data
public class TokenInfo {
private boolean active; //是否有效
private String client_id;//客户端id
private String [] scope;//权限
private String user_name; //用户名
private String[] aud;//从那些资源服务访问
private Date exp;//过期时间
private String [] authorities;//角色集
}
这里核心的认证就是下面这串代码
if (isNeedAuth(request)) {
TokenInfo tokenInfo = (TokenInfo) request.getAttribute("tokenInfo");
if (tokenInfo != null && tokenInfo.isActive()) {
if (!hasPermission(tokenInfo, request)) {
log.info("audit log update fail 403");
handleError(403, requestContext);
}
requestContext.addZuulRequestHeader("username", tokenInfo.getUser_name());
} else {
if (!StringUtils.startsWith(request.getRequestURI(), "/token")) {
log.info("audit log update fail 401");
handleError(401, requestContext);
}
}
}
return null;
这里每次都会进入因为isNeedAuth永远返回的是true,每次进来之后,会判断从request域对象里面获取的tokeninfo信息是否为null并且是否在有效期,如果都满足那么就会进入hasPermission
进行权限判断,这个hasPermission方法也是永远返回true,永远是有效的,如果都满足那么网关就会放行,并且添加Header头。
这个就是基本的网关认证Filter过滤器,下面我们再来看一下OAuthFilter,这个过滤器负责的是发起Oauth请求并且拿到AccessToken,我们来看一下代码
@Slf4j
@Component
public class OAuthFilter extends ZuulFilter {
private RestTemplate restTemplate = new RestTemplate();
public boolean shouldFilter() {
return true;
}
public Object run() throws ZuulException {
log.info("oauth start");
RequestContext requestContext = RequestContext.getCurrentContext();
HttpServletRequest request = requestContext.getRequest();
if(StringUtils.startsWith(request.getRequestURI(), "/token")) {
return null;
}
String authHeader = request.getHeader("Authorization");
if(StringUtils.isBlank(authHeader)) {
return null;
}
if(!StringUtils.startsWithIgnoreCase(authHeader, "bearer ")) {
return null;
}
try {
TokenInfo info = getTokenInfo(authHeader);
request.setAttribute("tokenInfo", info);
} catch (Exception e) {
log.error("get token info fail", e);
}
return null;
}
private TokenInfo getTokenInfo(String authHeader) {
String token = StringUtils.substringAfter(authHeader, "bearer ");
String oauthServiceUrl = "http://localhost:9090/oauth/check_token";
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
headers.setBasicAuth("gateway", "123456");
MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
params.add("token", token);
HttpEntity<MultiValueMap<String, String>> entity = new HttpEntity<>(params, headers);
ResponseEntity<TokenInfo> response = restTemplate.exchange(oauthServiceUrl, HttpMethod.POST, entity, TokenInfo.class);
log.info("token info :" + response.getBody().toString());
return response.getBody();
}
@Override
public String filterType() {
return "pre";
}
@Override
public int filterOrder() {
return 1;
}
}
这个代码也很简单,其中最重要的也是run
方法和getTokenInfo
这个方法,
run方法里面的代码很简单,这里我主要讲一下getTokenInfo
方法,这方法目的是主要是发起oauth的认证和有效性检查,这里注意使用的是HttpHeaders
这个类来封装我们需要传递的一些头部信息,MultiValueMap
是键值对,我们将封装好的header头呢添加到MultiValueMap里面,最后通过restTemplate这个Spring提供的http请求工具发起http请求,最后完成相应得到相应的信息。
关于order-api这个项目很简单,里面的代码非常少,可以自己去看
我们看一下login-api这个登陆的项目
首先我们看这个项目的控制器的login方法
@PostMapping("/login")
public void login(@RequestBody Auth auth, HttpServletRequest request, HttpSession session) {
String oauthServiceUrl = "http://gateway.ityoudream.com:9070/token/oauth/token"; //请求的uri
HttpHeaders headers = new HttpHeaders();//组装header头
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
headers.setBasicAuth("auth","123456");//从数据库查询
MultiValueMap<String,String> params = new LinkedMultiValueMap<String, String>();//组装最后发送的参数
params.add("username", auth.getUsername());
params.add("password", auth.getPassword());
params.add("grant_type", "password");
params.add("scope", "read write");
log.info("params:" + params);
HttpEntity< MultiValueMap<String,String>> entity = new HttpEntity<MultiValueMap<String,String>>(params,headers);
ResponseEntity<TokenInfo> response = restTemplate.exchange(oauthServiceUrl, HttpMethod.POST, entity, TokenInfo.class);
session.setAttribute("token", response.getBody());//认证成功后就保存token
log.info("sessionId1:" + session.getId());
}
下面我们来看一下SessionTokenFilter这个类
@Component
@Slf4j
public class SessionTokenFilter extends ZuulFilter {
public String filterType() {
return "pre";
}
public int filterOrder() {
return 0;
}
public boolean shouldFilter() {
return true;
}
@Override
public Object run() throws ZuulException {
RequestContext requestContext = RequestContext.getCurrentContext(); //获取上下文
HttpServletRequest request = requestContext.getRequest();
HttpSession session = request.getSession();//得到session
TokenInfo token = (TokenInfo)session.getAttribute("token");//从session获取token
log.info("sessionId2:" + session.getId());
log.info("token:" + token);
if (token != null) {
//添加header给zuul
requestContext.addZuulRequestHeader("Authorization", "bearer "+token.getAccess_token());
}
return null;
}
}
这个类就是主要是从session获取这个token信息,然后添加到zuul里面。
最后就是前端的login-template这个是vue编写的,但是代码相当简单,我写的比较简单。
最后我们来看一下总体的效果
在微服务中保证安全其主要方向在于后端,在后端中应该尽量避免前端来传递Token信息,这样可以降低风险,同时保证系统的稳定性和效率,本案例使用的是Session和Token的混合方式,这种方式只适合小中型项目,用户数量在80w以下,如果高于80w那么本方案就不适合了,可能要考虑jwt的方式了,下面一节将讲如何使用授权码模式以及session的有效期和token有效期。
github