保护你的API(上) - I code it

标签: | 发表时间:2019-07-20 08:39 | 作者:
出处:http://icodeit.org

保护你的API

在大部分时候,我们讨论API的设计时,会从功能的角度出发定义出完善的,易用的API。而很多时候,非功能需求如安全需求则会在很晚才加入考虑。而往往这部分会涉及很多额外的工作量,比如与外部的SSO集成,Token机制等等。

这篇文章会以一个简单的例子,从应用程序和部署架构上分别讨论几种常见的模型。这篇文章是这个系列的第一篇,会讨论两个简单的主题:

  • 基于Session的用户认证
  • 基于Token的RESTful API(使用Spring Security)

使用Session

由于HTTP协议本身是无状态的,服务器需要某种机制来区分每个请求。比如在返回给客户的响应中加入一些ID,客户端再次请求时带上这个ID,这样服务器就可以区分出来每个请求,并完成事务性的操作(完成订单的创建,更新,商品派送等等)。

在多数Web容器中,这种机制通过Session来实现。Web容器会为每个首次请求创建一个Session,并将Session的ID以浏览器Cookie的方式返回给客户端。客户端(常常是浏览器)在后续的请求中带上这个Session的ID来表明自己的身份。这种机制同样被用在了鉴权方面,用户登录系统之后,系统分配一个Session ID给他。

除非Session过期,或者用户从客户端的Cookie中主动删了Session ID,否则在服务器端来看,用户的信息会和这个Session绑定起来。后台系统也可以随时知道请求某个资源的真实用户是谁,并以此来判断该用户时候真的有权限这么做。

1
2
3
4
5
6
      HttpSession session = request.getSession();
String user = (String)session.getAttribute("user");

if(user != null) {
    //
}

Session的问题

这种做法在小规模应用中工作良好,随着用户的增多,企业往往需要部署多台服务器形成集群来对外提供服务。在集群模式下,当某个节点挂掉之后,由于Session默认是保存在部署Web容器中的,用户会被误判为未登录,后续的请求会被重定向到登陆页面,影响用户体验。

这种将应用程序状态内置的方法已经完全无法满足应用的扩展,因此在工程实践中,我们会采用将Session外置的方式来解决这个问题。即集群中的所有节点都将Session保存在一个公用的键值数据库中:

1
2
3
4
      @Configuration
@EnableRedisHttpSession
public class HttpSessionConfig {
}

上面这个例子是在Spring Boot中使用Redis来外置Session。Spring会拦截所有对HTTPSession对象的操作,后续的对Session的操作,Spring都会自动转换为与后台的Redis服务器的交互,从而避免节点挂掉之后Session丢失的问题。

1
2
3
      spring.redis.host=192.168.99.100
spring.redis.password=
spring.redis.port=6379

如果你跟我一样懒的话,直接启动一个redis的docker container就可以:

1
      $ docker run --name redis-server -d redis

这样,多个应用共享这一个实例,任何一个节点的终止、异常都不会产生Session的问题。

基于Token的安全机制

上面说到的场景中,使用Session需要额外部署一个组件(或者引入更加复杂的Session同步机制),这会带来另外的问题,比如如何保证这个节点的高可用,除了Production环境之外,Staging和QA环境也需要这个组件的配置、测试和维护。

很多项目现在会采用另外一种更加简单的方式:基于Token的安全机制。即不使用Session,用户在登陆之后,会获得一个Token,这个Token会以HTTP Header的方式发送给客户,同样,客户再后续的资源请求中也需要带着这个Token。通常这个Token还会有过期时间的限制(比如只能使用1周,一周之后需要重新获取)。

基于Token的机制更加简单,和RESTful风格的API一起使用更加自然,相较于传统的Web应用,RESTful的消费者可能是人,也可能是Mobile App,也可能是系统中另外的Service。也就是说,并不是所有的消费者都可以处理一个登陆表单!

Restful API

我们通过一个实例来看使用Spring Security保护受限制访问资源的场景。

对于Controller:

1
2
3
4
5
6
7
8
9
      @RestController
@RequestMapping("/protected")
public class ProtectedResourceController {

    @RequestMapping("/{id}")
    public Message getOne(@PathVariable("id") String id) {
        return new Message("Protected resource "+id);
    }
}

我们需要所有请求上都带有一个 X-Auth-Token的Header,简单起见,如果这个Header有值,我们就认为这个请求已经被授权了。我们在Spring Security中定义这样的一个配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
      @Override
protected void configure(HttpSecurity http) throws Exception {
    http.
            csrf().disable().
            sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).
            and().
            authorizeRequests().
            anyRequest().
            authenticated().
            and().
            exceptionHandling().
            authenticationEntryPoint(new RestAuthenticationEntryPoint());
}

我们使用 SessionCreationPolicy.STATELESS无状态的Session机制(即Spring不使用HTTPSession),对于所有的请求都做权限校验,这样Spring Security的拦截器会判断所有请求的Header上有没有”X-Auth-Token”。对于异常情况(即当Spring Security发现没有),Spring会启用一个认证入口: new RestAuthenticationEntryPoint。在我们这个场景下,这个入口只是简单的返回一个401即可:

1
2
3
4
5
6
7
8
9
      @Component
public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response,
                         AuthenticationException authException ) throws IOException {
        response.sendError( HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized" );
    }
}

这时候,如果我们请求这个受限制的资源:

1
2
3
4
5
6
7
8
      $ curl http://api.kanban.com:9000/api/protected/1 -s | jq .
{
  "timestamp": 1462621552738,
  "status": 401,
  "error": "Unauthorized",
  "message": "Unauthorized",
  "path": "/api/protected/1"
}

过滤器(Filter)及预认证(PreAuthentication)

为了让Spring Security可以处理用户登录的case,我们需要提供一个 Filter。当然,Spring Security提供了丰富的 Filter机制,我们这里使用一个预认证的 Filter(即假设用户已经在别的外部系统如SSO中登录了):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
      public class KanBanPreAuthenticationFilter extends AbstractPreAuthenticatedProcessingFilter {
    public static final String SSO_TOKEN = "X-Auth-Token";
    public static final String SSO_CREDENTIALS = "N/A";

    public KanBanPreAuthenticationFilter(AuthenticationManager authenticationManager) {
        setAuthenticationManager(authenticationManager);
    }

    @Override
    protected Object getPreAuthenticatedPrincipal(HttpServletRequest request) {
        return request.getHeader(SSO_TOKEN);
    }

    @Override
    protected Object getPreAuthenticatedCredentials(HttpServletRequest request) {
        return SSO_CREDENTIALS;
    }
}

过滤器在获得Header中的Token后,Spring Security会尝试去认证用户:

1
2
3
4
5
6
7
8
9
10
11
      @Override
protected void configure(AuthenticationManagerBuilder builder) throws Exception {
    builder.authenticationProvider(preAuthenticationProvider());
}

private AuthenticationProvider preAuthenticationProvider() {
    PreAuthenticatedAuthenticationProvider provider = new PreAuthenticatedAuthenticationProvider();
    provider.setPreAuthenticatedUserDetailsService(new KanBanAuthenticationUserDetailsService());

    return provider;
}

这里的 KanBanAuthenticationUserDetailsService是一个实现了Spring Security的UserDetailsService的类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
      public class KanBanAuthenticationUserDetailsService
        implements AuthenticationUserDetailsService<PreAuthenticatedAuthenticationToken> {

    @Override
    public UserDetails loadUserDetails(PreAuthenticatedAuthenticationToken token) throws UsernameNotFoundException {
        String principal = (String) token.getPrincipal();

        if(!StringUtils.isEmpty(principal)) {
            return new KanBanUserDetails(new KanBanUser(principal));
        }

        return null;
    }
}

这个类的职责是,查看从 KanBanPreAuthenticationFilter返回的 PreAuthenticatedAuthenticationToken,如果不为空,则表示该用户在系统中存在,并正常加载用户。如果返回null,则表示该认证失败,这时根据配置,Spring Security会重定向到认证入口 RestAuthenticationEntryPoint

加上这个过滤器的配置之后:

1
2
3
4
5
6
7
8
9
10
      @Override
protected void configure(HttpSecurity http) throws Exception {
    //...
    http.addFilter(headerAuthenticationFilter());
}

@Bean
public KanBanPreAuthenticationFilter headerAuthenticationFilter() throws Exception {
    return new KanBanPreAuthenticationFilter(authenticationManager());
}

这样,当我们在Header上加上 X-Auth-Token之后,就会访问到受限的资源了:

1
2
3
4
      $ curl -H "X-Auth-Token: juntao" http://api.kanban.com:9000/api/protected/1 -s | jq .
{
  "content": "Protected resource for 1"
}

总结

下一篇文章会以另外一个方式来完成鉴权机制和系统的集成问题。我们会在反向代理中做一些配置,将多个Endpoint组织起来。要完成这样的功能,使用Spring Security也可以做到,不过可能会为应用程序本身引入额外的复杂性。

相关 [api code it] 推荐:

保护你的API(上) - I code it

- -
在大部分时候,我们讨论API的设计时,会从功能的角度出发定义出完善的,易用的API. 而很多时候,非功能需求如安全需求则会在很晚才加入考虑. 而往往这部分会涉及很多额外的工作量,比如与外部的SSO集成,Token机制等等. 这篇文章会以一个简单的例子,从应用程序和部署架构上分别讨论几种常见的模型. 这篇文章是这个系列的第一篇,会讨论两个简单的主题:.

Clean-Code: 注释

- We_Get - 博客园-首页原创精华区
别给糟糕的代码加注释-----------------重新写吧. 这是书中的关于注释一章的第一句话,怎么说呢,这句话个人感觉很对,但是实际上却很少这么做,. 糟糕的代码不是自己写的,别人写的代码,还是让别人自己去维护吧,出了问题也是别人的. 糟糕的代码目前可以正常工作,软件开发中有一条古老哲言:如果它能工作就不要动它,很多程序员都遵守这条准则.

聊聊Code Review

- - 梦想风暴
hopesfish评论《 那一点的调用》时,问了一个关于Code Review的问题:. 想请教一下,TW的筒子是如何做code reivew或者鼓励客户做code review的. 我在翻阅博主的帖子的时候,似乎对这块没有特别强调,而是更多偏重于TDD,我觉得TDD的问题是一碰到没有责任心的程序猿,就很容易流于形式了.

Java Code Review清单

- - ImportNew
使用可以表达实际意图(Intention-Revealing)的名称. DRY(Don’t Repeat Yourself)原则,(拒绝重复). 用代码来解释自己的做法(译者注:即代码注释). *参考自: http://techbus.safaribooksonline.com/book/software-engineering-and-development/agile-development/9780136083238.

Api Blocking

- - xiaobaoqiu Blog
4.RateLimiter实现限流. 接口限流是保证系统稳定性的三大法宝之一(缓存, 限流, 降级).. 本文使用三种方式实现Api限流, 并提供了一个用Spring实现的Api限流的简单Demo, Demo的git地址: https://github.com/xiaobaoqiu/api-blocking.

我的code review规则

- vento - 我的宝贝孙秀楠 ﹣C++, Lua, 大连,程序员
1) 是否有语法错误,编译错误,编译警告. 做法:下载最新代码,将编译警告级别提升到最高,检查output信息. 2)是否符合需求,完成requirement文档要求的内容,不能多,也不能少. 注意:即使发现有问题代码,如果与需求关联不大,不要涉及. 应该让每次enhancement和bug fix最简洁,牵涉范围最小,影响到组件最少.

Google Code Jam Japan開催

- Adam - スラッシュドット・ジャパン
あるAnonymous Coward 曰く、Googleによって毎年開催されているプログラミングコンテストGoogle Code Jamですが、今年は初めて日本人向けの日本語でのコンテストGoogle Code Jam Japanが開催されるようです(Google Japan Blog). 予選は10月1日にオンラインで開催され、予選開催中にも参加登録を受け付けるとのこと(スケジュール).

Code Review那些事儿

- - 非技术 - ITeye博客
       曾经有一段 垃圾代码放在我的面前,我没有拒绝,等我真正开始接手的时候我才后悔莫及,程序员最痛苦的事莫过于此. ---------改编于周星星的经典台词.       虽然有点夸张,但编码界确实大大存在这种情况,每当接手别人的代码,都有一种想重新写一遍的感觉,等到别人再来接手你的代码时,同样的感觉.

股票API

- 狗尾草 - 博客园-首页原创精华区
股票数据的获取目前有如下两种方法可以获取:. http/javascript接口取数据. 1.http/javascript接口取数据. 以大秦铁路(股票代码:601006)为例,如果要获取它的最新行情,只需访问新浪的股票数据. 这个url会返回一串文本,例如:. var hq_str_sh601006="大秦铁路, 27.55, 27.25, 26.91, 27.55, 26.20, 26.91, 26.92, .

API 与 ABI

- Ant - A Geek&#39;s Page
(本文亦是《C语言编程艺术》中的一部分,所以请勿用于商业用途. 一些程序员居然对API和ABI这两个概念都不清楚,我感到有些惊讶. 这里以 Linux 内核为例简单解释一下. API,顾名思义,是编程的接口,换句话说也就是你编写“应用程序”时候调用的函数之类的东西. 对于内核来说,它的“应用程序”有两种:一种是在它之上的,用户空间的真正的应用程序,内核给它们提供的是系统调用这种接口,比如 read(2),write(2);另一种就是内核模块了,它们和内核处于同一层,内核给它们提供的是导出的内核函数,比如 kmalloc(),printk().