Spring Cloud OAuth2 实现单点登录

文章较长,建议好看,建议转发,建议收藏。

OAuth 2 有四种授权模式,分别是授权码模式(authorization code)、简化模式(implicit)、密码模式(resource owner password credentials)、客户端模式(client credentials),具体 OAuth2 是什么,可以参考这篇文章。(http://www.ruanyifeng.com/blog/2014/05/oauth_2_0.html)

本文我们将使用授权码模式和密码模式两种方式来实现用户认证和授权管理。

OAuth2 其实是一个关于授权的网络标准,它制定了设计思路和运行流程,利用这个标准我们其实是可以自己实现 OAuth2 的认证过程的。今天要介绍的 spring-cloud-starter-oauth2 ,其实是 Spring Cloud 按照 OAuth2 的标准并结合 spring-security 封装好的一个具体实现。

什么情况下需要用 OAuth2

首先大家最熟悉的就是几乎每个人都用过的,比如用微信登录、用 QQ 登录、用微博登录、用 Google 账号登录、用 github 授权登录等等,这些都是典型的 OAuth2 使用场景。假设我们做了一个自己的服务平台,如果不使用 OAuth2 登录方式,那么我们需要用户先完成注册,然后用注册号的账号密码或者用手机验证码登录。而使用了 OAuth2 之后,相信很多人使用过、甚至开发过公众号网页服务、小程序,当我们进入网页、小程序界面,第一次使用就无需注册,直接使用微信授权登录即可,大大提高了使用效率。因为每个人都有微信号,有了微信就可以马上使用第三方服务,这体验不要太好了。而对于我们的服务来说,我们也不需要存储用户的密码,只要存储认证平台返回的唯一ID 和用户信息即可。

以上是使用了 OAuth2 的授权码模式,利用第三方的权威平台实现用户身份的认证。当然了,如果你的公司内部有很多个服务,可以专门提取出一个认证中心,这个认证中心就充当上面所说的权威认证平台的角色,所有的服务都要到这个认证中心做认证。

这样一说,发现没,这其实就是个单点登录的功能。这就是另外一种使用场景,对于多服务的平台,可以使用 OAuth2 实现服务的单点登录,只做一次登录,就可以在多个服务中自由穿行,当然仅限于授权范围内的服务和接口。

实现统一认证功能

本篇先介绍密码模式实现的单点登录,下一篇再继续说授权码模式。

在微服务横行的今天,谁敢说自己手上没几个微服务。微服务减少了服务间的耦合,同时也在某些方面增加了系统的复杂度,比如说用户认证。假设我们这里实现了一个电商平台,用户看到的就是一个 APP 或者一个 web 站点,实际上背后是由多个独立的服务构成的,比如用户服务、订单服务、产品服务等。用户只要第一次输入用户名、密码完成登录后,一段时间内,都可以任意访问各个页面,比如产品列表页面、我的订单页面、我的关注等页面。

我们可以想象一下,自然能够想到,在请求各个服务、各个接口的时候,一定携带着什么凭证,然后各个服务才知道请求接口的用户是哪个,不然肯定有问题,那其实这里面的凭证简单来说就是一个 Token,标识用户身份的 Token。

系统架构说明

认证中心:oauth2-auth-server,OAuth2 主要实现端,Token 的生成、刷新、验证都在认证中心完成。

订单服务:oauth2-client-order-server,微服务之一,接收到请求后会到认证中心验证。

用户服务:oauth2-client-user-server,微服务之二,接收到请求后会到认证中心验证。

客户端:例如 APP 端、web 端 等终端

上图描述了使用了 OAuth2 的客户端与微服务间的请求过程。大致的过程就是客户端用用户名和密码到认证服务端换取 token,返回给客户端,客户端拿着 token 去各个微服务请求数据接口,一般这个 token 是放到 header 中的。当微服务接到请求后,先要拿着 token 去认证服务端检查 token 的合法性,如果合法,再根据用户所属的角色及具有的权限动态的返回数据。

创建并配置认证服务端

配置最多的就是认证服务端,验证账号、密码,存储 token,检查 token ,刷新 token 等都是认证服务端的工作。

1、引入需要的 maven 包

 

spring-cloud-starter-oauth2包含了 spring-cloud-starter-security,所以不用再单独引入了。之所以引入 redis 包,是因为下面会介绍一种用 redis 存储 token 的方式。

2、配置好 application.yml

将项目基本配置设置好,并加入有关 redis 的配置,稍后会用到。

 

3、spring security 基础配置

 

使用@EnableWebSecurity注解修饰,并继承自WebSecurityConfigurerAdapter类。

这个类的重点就是声明 PasswordEncoder 和 AuthenticationManager两个 Bean。稍后会用到。其中 BCryptPasswordEncoder是一个密码加密工具类,它可以实现不可逆的加密,AuthenticationManager是为了实现 OAuth2 的 password 模式必须要指定的授权管理 Bean。

4、实现 UserDetailsService

如果你之前用过 Security 的话,那肯定对这个类很熟悉,它是实现用户身份验证的一种方式,也是最简单方便的一种。另外还有结合 AuthenticationProvider的方式,有机会讲 Security 的时候再展开来讲吧。

UserDetailsService的核心就是 loadUserByUsername方法,它要接收一个字符串参数,也就是传过来的用户名,返回一个 UserDetails对象。

 

这里为了做演示,把用户名、密码和所属角色都写在代码里了,正式环境中,这里应该是从数据库或者其他地方根据用户名将加密后的密码及所属角色查出来的。账号 admin ,密码 123456,稍后在换取 token 的时候会用到。并且给这个用户设置 “ROLE_ADMIN” 角色。

5、OAuth2 配置文件

创建一个配置文件继承自 AuthorizationServerConfigurerAdapter.

 

有三个 configure 方法的重写。

AuthorizationServerEndpointsConfigurer参数的重写

 

authenticationManage() 调用此方法才能支持 password 模式。

userDetailsService() 设置用户验证服务。

tokenStore() 指定 token 的存储方式。

redisTokenStore Bean 的定义如下:

 

ClientDetailsServiceConfigurer参数的重写,在这里定义各个端的约束条件。包括

ClientId、Client-Secret:这两个参数对应请求端定义的 cleint-id 和 client-secret

authorizedGrantTypes 可以包括如下几种设置中的一种或多种:

  • authorization_code:授权码类型。

  • implicit:隐式授权类型。

  • password:资源所有者(即用户)密码类型。

  • client_credentials:客户端凭据(客户端ID以及Key)类型。

  • refresh_token:通过以上授权获得的刷新令牌来获取新的令牌。

accessTokenValiditySeconds:token 的有效期

scopes:用来限制客户端访问的权限,在换取的 token 的时候会带上 scope 参数,只有在 scopes 定义内的,才可以正常换取 token。

上面代码中是使用 inMemory 方式存储的,将配置保存到内存中,相当于硬编码了。正式环境下的做法是持久化到数据库中,比如 mysql 中。

具体的做法如下:

1. 在数据库中增加表,并插入数据

 

注意: client_secret 字段不能直接是 secret 的原始值,需要经过加密。因为是用的 BCryptPasswordEncoder,所以最终插入的值应该是经过 BCryptPasswordEncoder.encode()之后的值。

2. 然后在配置文件 application.yml 中添加关于数据库的配置

 

Spring Boot 2.0 之后默认使用 hikari 作为数据库连接池。如果使用其他连接池需要引入相关包,然后对应的增加配置。

3. 在 OAuth2 配置类(OAuth2Config)中增加 DataSource 的注入

 

4. 将 public void configure(ClientDetailsServiceConfigurer clients)重写方法修改为如下:

 

还有一个重写的方法 public void configure(AuthorizationServerSecurityConfigurer security),这个方法限制客户端访问认证接口的权限。

 

第一行代码是允许客户端访问 OAuth2 授权接口,否则请求 token 会返回 401。

第二行和第三行分别是允许已授权用户访问 checkToken 接口和获取 token 接口。

完成之后,启动项目,如果你用的是 IDEA 会在下方的 Mapping 窗口中看到 oauth2 相关的 RESTful 接口。

 

主要有如下几个:

 

创建用户客户端项目

上面创建完成了认证服务端,下面开始创建一个客户端,对应到我们系统中的业务相关的微服务。我们假设这个微服务项目是管理用户相关数据的,所以叫做用户客户端。

1、引用相关的 maven 包

 

2、application.yml 配置文件

 

上面是常规配置信息以及 redis 配置,重点是下面的 security 的配置,这里的配置稍有不注意就会出现 401 或者其他问题。

client-id、client-secret 要和认证服务中的配置一致,如果是使用 inMemory 还是 jdbc 方式。

user-authorization-uri 是授权码认证方式需要的,下一篇文章再说。

access-token-uri 是密码模式需要用到的获取 token 的接口。

authorization.check-token-access 也是关键信息,当此服务端接收到来自客户端端的请求后,需要拿着请求中的 token 到认证服务端做 token 验证,就是请求的这个接口

3、资源配置文件

在 OAuth2 的概念里,所有的接口都被称为资源,接口的权限也就是资源的权限,所以 Spring Security OAuth2 中提供了关于资源的注解 @EnableResourceServer,和 @EnableWebSecurity的作用类似。

 

因为使用的是 redis 作为 token 的存储,所以需要特殊配置一下叫做 tokenService 的 Bean,通过这个 Bean 才能实现 token 的验证。

4、最后,添加一个 RESTful 接口

 

一个 RESTful 方法,只有当访问用户具有 ROLE_ADMIN 权限时才能访问,否则返回 401 未授权。

通过 Authentication 参数或者 SecurityContextHolder.getContext().getAuthentication() 可以拿到授权信息进行查看。

测试认证功能

1、启动认证服务端,启动端口为 6001

2、启动用户服务客户端,启动端口为6101

3、请求认证服务端获取 token

我是用 REST Client 来做访问请求的,请求格式如下:

 

假设咱们在一个 web 端使用,grant_type 是 password,表明这是使用 OAuth2 的密码模式。

username=admin 和 password=123456 就相当于在 web 端登录界面输入的用户名和密码,我们在认证服务端配置中固定了用户名是 admin 、密码是 123456,而线上环境中则应该通过查询数据库获取。

scope=all 是权限有关的,在认证服务的 OAuthConfig 中指定了 scope 为 all 。

Authorization 要加在请求头中,格式为 Basic 空格 base64(clientId:clientSecret),这个微服务客户端的 client-id 是 user-client,client-secret 是 user-secret-8888,将这两个值通过冒号连接,并使用 base64 编码(user-client:user-secret-8888)之后的值为 dXNlci1jbGllbnQ6dXNlci1zZWNyZXQtODg4OA==,可以通过 https://www.sojson.com/base64.html 在线编码获取。

运行请求后,如果参数都正确的话,获取到的返回内容如下,是一段 json 格式

 

access_token :  就是之后请求需要带上的 token,也是本次请求的主要目的 token_type:为 bearer,这是 access token 最常用的一种形式 refresh_token:之后可以用这个值来换取新的 token,而不用输入账号密码 expires_in:token 的过期时间(秒)

4、用获取到的 token 请求资源接口

我们在用户客户端中定义了一个接口 http://localhost:6101/client-user/get,现在就拿着上一步获取的 token 来请求这个接口。

 

同样需要请求头 Authorization,格式为 bearer + 空格 + token,正常情况下根据接口的逻辑,会把 token 原样返回。

5、token 过期后,用 refresh_token 换取 access_token

一般都会设置 access_token 的过期时间小于 refresh_token 的过期时间,以便在 access_token 过期后,不用用户再次登录的情况下,获取新的 access_token。

 

grant_type 设置为 refresh_token。

refresh_token 设置为请求 token 时返回的 refresh_token 的值。

请求头加入 Authorization,格式依然是 Basic + 空格 + base64(client-id:client-secret)

请求成功后会返回和请求 token 同样的数据格式。

用 JWT 替换 redisToken

上面 token 的存储用的是 redis 的方案,Spring Security OAuth2 还提供了 jdbc 和 jwt 的支持,jdbc 的暂不考虑,现在来介绍用 JWT 的方式来实现 token 的存储。

用 JWT 的方式就不用把 token 再存储到服务端了,JWT 有自己特殊的加密方式,可以有效的防止数据被篡改,只要不把用户密码等关键信息放到 JWT 里就可以保证安全性。

认证服务端改造

先把有关 redis 的配置去掉。

添加 JwtConfig 配置类

 

JwtAccessTokenConverter是为了做 JWT 数据转换,这样做是因为 JWT 有自身独特的数据格式。如果没有了解过 JWT ,可以搜索一下先了解一下。

更改 OAuthConfig 配置类

 

注入 JWT 相关的 Bean,然后修改 configure(final AuthorizationServerEndpointsConfigurer endpoints) 方法为 JWT 存储模式。

改造用户客户端

修改 application.yml 配置文件

 

注意认证服务端 JwtAccessTokenConverter设置的 SigningKey 要和配置文件中的 key-value 相同,不然会导致无法正常解码 JWT ,导致验证不通过。

ResourceServerConfig 类的配置

 

运行请求 token 接口的请求

 

返回结果如下:

 

我们已经看到返回的 token 是 JWT 格式了,到 JWT 在线解码网站 https://jwt.io/ 或者 http://jwt.calebb.net/将 token 解码看一下

看到了没,user_name、client_id 等信息都在其中。

拿着返回的 token 请求用户客户端接口

 

增强 JWT

如果我想在 JWT 中加入额外的字段(比方说用户的其他信息)怎么办呢,当然可以。spring security oauth2 提供了 TokenEnhancer 增强器。其实不光 JWT ,RedisToken 的方式同样可以。

声明一个增强器

 

通过 oAuth2Authentication 可以拿到用户名等信息,通过这些我们可以在这里查询数据库或者缓存获取更多的信息,而这些信息都可以作为 JWT 扩展信息加入其中。

OAuthConfig 配置类修改

注入增强器

 

修改 configure(final AuthorizationServerEndpointsConfigurer endpoints)方法

 

再次请求 token ,返回内容中多了个刚刚加入的 jwt-ext 字段

 

用户客户端解析 JWT 数据

我们如果在 JWT 中加入了额外信息,这些信息我们可能会用到,而在接收到 JWT 格式的 token 之后,用户客户端要把 JWT 解析出来。

引入 JWT 包

 

加一个 RESTful 接口,在其中解析 JWT

 

同样注意其中签名的设置要与认证服务端相同。

用上一步的 token 请求上面的接口

 

返回内容如下:

 

以上就是 password 模式的完整过程。

源码地址: https://github.com/huzhicheng/spring-cloud-study/tree/master/oauth2 

 


相关阅读

http://www.ruanyifeng.com/blog/2019/04/oauth-grant-types.html

https://mp.weixin.qq.com/s?__biz=MzAxMjA0MDk2OA==&mid=2449469148&idx=1&sn=cf7f6f81134e8f43e6fc6a71e36f940a&scene=21#wechat_redirect



微信授权就是这个原理,Spring Cloud OAuth2 授权码模式

上一篇文章Spring Cloud OAuth2 实现单点登录介绍了使用 password 模式进行身份认证和单点登录。本篇介绍 Spring Cloud OAuth2 的另外一种授权模式-授权码模式。

授权码模式的认证过程是这样的:

1、用户客户端请求认证服务器的认证接口,并附上回调地址;

2、认证服务接口接收到认证请求后调整到自身的登录界面;

3、用户输入用户名和密码,点击确认,跳转到授权、拒绝提示页面(也可省略);

4、用户点击授权或者默认授权后,跳转到微服务客户端的回调地址,并传入参数 code;

5、回调地址一般是一个 RESTful 接口,此接口拿到 code 参数后,再次请求认证服务器的 token 获取接口,用来换取 access_token 等信息;

6、获取到 access_token 后,拿着 token 去请求各个微服务客户端的接口。

注意上面所说的用户客户端可以理解为浏览器、app 端,微服务客户端就是我们系统中的例如订单服务、用户服务等微服务,认证服务端就是用来做认证授权的服务,相对于认证服务端来说,各个业务微服务也可以称作是它的客户端。

认证服务端配置

认证服务端继续用上一篇文章的配置,代码不需要任何改变,只需要在数据库里加一条记录,来支持新加的微服务客户端的认证

我们要创建的客户端的 client-id 为 code-client,client-secret 为 code-secret-8888,但是同样需要加密,可以用如下代码获取:

 

除了以上这两个参数,要将 authorized_grant_types 设置为 authorization_code,refresh_token,web_server_redirect_uri 设置为回调地址,稍后微服务客户端会创建这个接口。

然后将这条记录组织好插入数据库中。

 

 

创建授权模式的微服务

引入 maven 包

 

引入 okhttp 和 thymeleaf 是因为要做一个简单的页面并模拟正常的认证过程。

配置文件 application.yml

 

创建 resourceConfig

 

使用 jwt 作为 token 的存储,注意允许 /login 接口无授权访问,这个地址是认证的回调地址,会返回 code 参数。

创建 application.java启动类

 

 

到这步可以先停一下了。我们把认证服务端和刚刚创建的认证客户端启动起来,就可以手工测试一下了。回调接口不是还没创建呢吗,没关系,我们权当那个地址现在就是为了接收 code 参数的。 1、在浏览器访问 /oauth/authorize 授权接口,接口地址为:

 

注意 response_type 参数设置为 code,redirect_uri 设置为数据库中插入的回调地址。

2、输入上面地址后,会自动跳转到认证服务端的登录页面,输入用户名、密码,这里用户名是 admin,密码是 123456

3、点击确定后,来到授权确认页面,页面上有 Authorize 和 Deny (授权和拒绝)两个按钮。可通过将 autoapprove 字段设置为 0 来取消此页面的展示,默认直接同意授权。 

4、点击同意授权后,跳转到了回调地址,虽然是 404 ,但是我们只是为了拿到 code 参数,注意地址后面的 code 参数。

5、拿到这个 code 参数是为了向认证服务器 /oauth/token 接口请求 access_token ,继续用 REST Client 发送请求,同样的,你也可以用 postman 等工具测试。

注意 grant_type 参数设置为 authorization_code,code 就是上一步回调地址中加上的,redirect_uri 仍然要带上,作为验证条件,如果不带或者与前面设置的不一致,会出现错误。

请求头 Authorization ,仍然是 Basic + 空格 + base64(client_id:client_secret),可以通过 https://www.sojson.com/base64.html 网站在线做 base64 编码。

code-client:code-secret-8888 通过 base64 编码后结果为 Y29kZS1jbGllbnQ6Y29kZS1zZWNyZXQtODg4OA==

 

发送请求后,返回的 json 内容如下:

 

和上一篇文章 password 模式拿到的 token 内容是一致的,接下来的请求都需要带上 access_token 。

6、把获取到的 access_token 代入到下面的请求中 ${access_token} 的位置,就可以请求微服务中的需要授权访问的接口了。

 

接口内容如下:

 

 

经过以上的手工测试,证明此过程是通的,但是还没有达到自动化。如果你集成过微信登录,那你一定知道我们在回调地址中做了什么,拿到返回的 code 参数去 token 接口换取 access_token 对不对,没错,思路都是一样的,我们的回调接口中同样要拿 code 去换取 access_token。

为此,我做了一个简单的页面,并且在回调接口中请求获取 token 的接口。

创建简单的登录页面

在 resources 目录下创建 templates 目录,用来存放 thymeleaf 的模板,不做样式,只做最简单的演示,创建 index.html 模板,内容如下:

 

回调接口及其他接口

 

其中 index() 方法是为了展示 thymeleaf 模板,login 方法就是回调接口,这里用了 okhttp3 用作接口请求,请求认证服务端的 /oauth/token 接口来换取 access_token,只是把我们手工测试的步骤自动化了。

访问 index.html 页面

我们假设这个页面就是一个网站的首页,未登录的用户会在网站上看到登录按钮,我们访问这个页面:http://localhost:6102/client-authcode/index,看到的页面是这样的

接下来,点击登录按钮,通过上面的模板代码看出,点击后其实就是跳转到了我们手工测试第一步访问的那个地址,之后的操作和上面手工测试的是一致的,输入用户名密码、点击同意授权。

接下来,页面跳转回回调地址

最后,拿到 token 后的客户端,就可以将 token 加入到请求头后,去访问需要授权的接口了。

结合上一篇文章,我们就实现了 password 和 授权码两种模式的 oauth2 认证。

对应的源码地址为:

https://github.com/huzhicheng/spring-cloud-study/tree/master/oauth2/oauth2-client-authorization-code-server

 


相关阅读

http://www.ruanyifeng.com/blog/2019/04/oauth-grant-types.html

https://mp.weixin.qq.com/s?__biz=MzAxMjA0MDk2OA==&mid=2449469148&idx=1&sn=cf7f6f81134e8f43e6fc6a71e36f940a&scene=21#wechat_redirect

参考:

http://www.ruanyifeng.com/blog/2019/04/oauth-grant-types.html

https://mp.weixin.qq.com/s/tXIycDTHw4nruuMP7xirQA

系统架构说明

认证中心:oauth2-auth-server,OAuth2 主要实现端,Token 的生成、刷新、验证都在认证中心完成。

订单服务:oauth2-client-order-server,微服务之一,接收到请求后会到认证中心验证。

用户服务:oauth2-client-user-server,微服务之二,接收到请求后会到认证中心验证。

客户端:例如 APP 端、web 端 等终端

上图描述了使用了 OAuth2 的客户端与微服务间的请求过程。大致的过程就是客户端用用户名和密码到认证服务端换取 token,返回给客户端,客户端拿着 token 去各个微服务请求数据接口,一般这个 token 是放到 header 中的。当微服务接到请求后,先要拿着 token 去认证服务端检查 token 的合法性,如果合法,再根据用户所属的角色及具有的权限动态的返回数据。

创建并配置认证服务端

配置最多的就是认证服务端,验证账号、密码,存储 token,检查 token ,刷新 token 等都是认证服务端的工作。

1、引入需要的 maven 包


<dependency>   <groupId>org.springframework.boot</groupId>   <artifactId>spring-boot-starter-web</artifactId></dependency><dependency>   <groupId>org.springframework.cloud</groupId>   <artifactId>spring-cloud-starter-oauth2</artifactId></dependency><dependency>   <groupId>org.springframework.boot</groupId>   <artifactId>spring-boot-starter-data-redis</artifactId></dependency><dependency>   <groupId>org.springframework.boot</groupId>   <artifactId>spring-boot-starter-actuator</artifactId></dependency>

spring-cloud-starter-oauth2包含了 spring-cloud-starter-security,所以不用再单独引入了。之所以引入 redis 包,是因为下面会介绍一种用 redis 存储 token 的方式。

2、配置好 application.yml

将项目基本配置设置好,并加入有关 redis 的配置,稍后会用到。


spring:application:  name: auth-serverredis:  database: 2  host: localhost  port: 32768  password: 1qaz@WSX  jedis:    pool:      max-active: 8      max-idle: 8      min-idle: 0  timeout: 100msserver:port: 6001management:endpoint:  health:    enabled: true

3、spring security 基础配置


@EnableWebSecuritypublic class WebSecurityConfig extends WebSecurityConfigurerAdapter {   @Bean   public PasswordEncoder passwordEncoder() {       return new BCryptPasswordEncoder();  }   @Bean   @Override   public AuthenticationManager authenticationManagerBean() throws Exception {       return super.authenticationManagerBean();  }   /**    * 允许匿名访问所有接口 主要是 oauth 接口    * @param http    * @throws Exception    */   @Override   protected void configure(HttpSecurity http) throws Exception {       http.authorizeRequests()              .antMatchers("/**").permitAll();  }}

使用@EnableWebSecurity注解修饰,并继承自WebSecurityConfigurerAdapter类。

这个类的重点就是声明 PasswordEncoder 和 AuthenticationManager两个 Bean。稍后会用到。其中 BCryptPasswordEncoder是一个密码加密工具类,它可以实现不可逆的加密,AuthenticationManager是为了实现 OAuth2 的 password 模式必须要指定的授权管理 Bean。

4、实现 UserDetailsService

如果你之前用过 Security 的话,那肯定对这个类很熟悉,它是实现用户身份验证的一种方式,也是最简单方便的一种。另外还有结合 AuthenticationProvider的方式,有机会讲 Security 的时候再展开来讲吧。

UserDetailsService的核心就是 loadUserByUsername方法,它要接收一个字符串参数,也就是传过来的用户名,返回一个 UserDetails对象。


@Slf4j@Component(value = "kiteUserDetailsService")public class KiteUserDetailsService implements UserDetailsService {   @Autowired   private PasswordEncoder passwordEncoder;   @Override   public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {       log.info("usernameis:" + username);       // 查询数据库操作       if(!username.equals("admin")){           throw new UsernameNotFoundException("the user is not found");      }else{           // 用户角色也应在数据库中获取           String role = "ROLE_ADMIN";           List<SimpleGrantedAuthority> authorities = new ArrayList<>();           authorities.add(new SimpleGrantedAuthority(role));           // 线上环境应该通过用户名查询数据库获取加密后的密码           String password = passwordEncoder.encode("123456");           return new org.springframework.security.core.userdetails.User(username,password, authorities);      }  }}

这里为了做演示,把用户名、密码和所属角色都写在代码里了,正式环境中,这里应该是从数据库或者其他地方根据用户名将加密后的密码及所属角色查出来的。账号 admin ,密码 123456,稍后在换取 token 的时候会用到。并且给这个用户设置 “ROLE_ADMIN” 角色。

5、OAuth2 配置文件

创建一个配置文件继承自 AuthorizationServerConfigurerAdapter.


@Configuration@EnableAuthorizationServerpublic class OAuth2Config extends AuthorizationServerConfigurerAdapter {   @Autowired   public PasswordEncoder passwordEncoder;   @Autowired   public UserDetailsService kiteUserDetailsService;   @Autowired   private AuthenticationManager authenticationManager;   @Autowired   private TokenStore redisTokenStore;   @Override   public void configure(final AuthorizationServerEndpointsConfigurer endpoints) throws Exception {       /**        * redis token 方式        */       endpoints.authenticationManager(authenticationManager)              .userDetailsService(kiteUserDetailsService)              .tokenStore(redisTokenStore);  }   @Override   public void configure(ClientDetailsServiceConfigurer clients) throws Exception {       clients.inMemory()              .withClient("order-client")              .secret(passwordEncoder.encode("order-secret-8888"))              .authorizedGrantTypes("refresh_token", "authorization_code", "password")              .accessTokenValiditySeconds(3600)              .scopes("all")              .and()              .withClient("user-client")              .secret(passwordEncoder.encode("user-secret-8888"))              .authorizedGrantTypes("refresh_token", "authorization_code", "password")              .accessTokenValiditySeconds(3600)              .scopes("all");  }   @Override   public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {       security.allowFormAuthenticationForClients();       security.checkTokenAccess("isAuthenticated()");       security.tokenKeyAccess("isAuthenticated()");  }}

有三个 configure 方法的重写。

AuthorizationServerEndpointsConfigurer参数的重写


endpoints.authenticationManager(authenticationManager)              .userDetailsService(kiteUserDetailsService)              .tokenStore(redisTokenStore);

authenticationManage() 调用此方法才能支持 password 模式。

userDetailsService() 设置用户验证服务。

tokenStore() 指定 token 的存储方式。

redisTokenStore Bean 的定义如下:


@Configurationpublic class RedisTokenStoreConfig {   @Autowired   private RedisConnectionFactory redisConnectionFactory;   @Bean   public TokenStore redisTokenStore (){       return new RedisTokenStore(redisConnectionFactory);  }}

ClientDetailsServiceConfigurer参数的重写,在这里定义各个端的约束条件。包括

ClientId、Client-Secret:这两个参数对应请求端定义的 cleint-id 和 client-secret

authorizedGrantTypes 可以包括如下几种设置中的一种或多种:

  • authorization_code:授权码类型。
  • implicit:隐式授权类型。
  • password:资源所有者(即用户)密码类型。
  • client_credentials:客户端凭据(客户端ID以及Key)类型。
  • refresh_token:通过以上授权获得的刷新令牌来获取新的令牌。

accessTokenValiditySeconds:token 的有效期

scopes:用来限制客户端访问的权限,在换取的 token 的时候会带上 scope 参数,只有在 scopes 定义内的,才可以正常换取 token。

上面代码中是使用 inMemory 方式存储的,将配置保存到内存中,相当于硬编码了。正式环境下的做法是持久化到数据库中,比如 mysql 中。

具体的做法如下:

1. 在数据库中增加表,并插入数据


create table oauth_client_details (  client_id VARCHAR(256) PRIMARY KEY,  resource_ids VARCHAR(256),  client_secret VARCHAR(256),  scope VARCHAR(256),  authorized_grant_types VARCHAR(256),  web_server_redirect_uri VARCHAR(256),  authorities VARCHAR(256),  access_token_validity INTEGER,  refresh_token_validity INTEGER,  additional_information VARCHAR(4096),  autoapprove VARCHAR(256));INSERT INTO oauth_client_details  (client_id, client_secret, scope, authorized_grant_types,  web_server_redirect_uri, authorities, access_token_validity,  refresh_token_validity, additional_information, autoapprove)VALUES  ('user-client', '$2a$10$o2l5kA7z.Caekp72h5kU7uqdTDrlamLq.57M1F6ulJln9tRtOJufq', 'all',   'authorization_code,refresh_token,password', null, null, 3600, 36000, null, true);INSERT INTO oauth_client_details  (client_id, client_secret, scope, authorized_grant_types,  web_server_redirect_uri, authorities, access_token_validity,  refresh_token_validity, additional_information, autoapprove)VALUES  ('order-client', '$2a$10$GoIOhjqFKVyrabUNcie8d.ADX.qZSxpYbO6YK4L2gsNzlCIxEUDlW', 'all',   'authorization_code,refresh_token,password', null, null, 3600, 36000, null, true);

注意: client_secret 字段不能直接是 secret 的原始值,需要经过加密。因为是用的 BCryptPasswordEncoder,所以最终插入的值应该是经过 BCryptPasswordEncoder.encode()之后的值。

2. 然后在配置文件 application.yml 中添加关于数据库的配置


spring:datasource:  url: jdbc:mysql://localhost:3306/spring_cloud?characterEncoding=UTF-8&useSSL=false  username: root  password: password  hikari:    connection-timeout: 30000    idle-timeout: 600000    max-lifetime: 1800000    maximum-pool-size: 9  

Spring Boot 2.0 之后默认使用 hikari 作为数据库连接池。如果使用其他连接池需要引入相关包,然后对应的增加配置。

3. 在 OAuth2 配置类(OAuth2Config)中增加 DataSource 的注入


@Autowiredprivate DataSource dataSource;

4. 将 public void configure(ClientDetailsServiceConfigurer clients)重写方法修改为如下:


@Overridepublic void configure(ClientDetailsServiceConfigurer clients) throws Exception {JdbcClientDetailsServiceBuilder jcsb = clients.jdbc(dataSource);jcsb.passwordEncoder(passwordEncoder);}

还有一个重写的方法 public void configure(AuthorizationServerSecurityConfigurer security),这个方法限制客户端访问认证接口的权限。


security.allowFormAuthenticationForClients();security.checkTokenAccess("isAuthenticated()");security.tokenKeyAccess("isAuthenticated()");

第一行代码是允许客户端访问 OAuth2 授权接口,否则请求 token 会返回 401。

第二行和第三行分别是允许已授权用户访问 checkToken 接口和获取 token 接口。

完成之后,启动项目,如果你用的是 IDEA 会在下方的 Mapping 窗口中看到 oauth2 相关的 RESTful 接口。

主要有如下几个:


POST /oauth/authorize 授权码模式认证授权接口GET/POST /oauth/token 获取 token 的接口POST /oauth/check_token 检查 token 合法性接口
创建用户客户端项目

上面创建完成了认证服务端,下面开始创建一个客户端,对应到我们系统中的业务相关的微服务。我们假设这个微服务项目是管理用户相关数据的,所以叫做用户客户端。

1、引用相关的 maven 包


<dependency>   <groupId>org.springframework.boot</groupId>   <artifactId>spring-boot-starter-web</artifactId></dependency><dependency>   <groupId>org.springframework.cloud</groupId>   <artifactId>spring-cloud-starter-oauth2</artifactId></dependency><dependency>   <groupId>org.springframework.boot</groupId>   <artifactId>spring-boot-starter-data-redis</artifactId></dependency>

2、application.yml 配置文件


spring:application:  name: client-userredis:  database: 2  host: localhost  port: 32768  password: 1qaz@WSX  jedis:    pool:      max-active: 8      max-idle: 8      min-idle: 0  timeout: 100msserver:port: 6101servlet:  context-path: /client-usersecurity:oauth2:  client:    client-id: user-client    client-secret: user-secret-8888    user-authorization-uri: http://localhost:6001/oauth/authorize    access-token-uri: http://localhost:6001/oauth/token  resource:    id: user-client    user-info-uri: user-info  authorization:    check-token-access: http://localhost:6001/oauth/check_token

上面是常规配置信息以及 redis 配置,重点是下面的 security 的配置,这里的配置稍有不注意就会出现 401 或者其他问题。

client-id、client-secret 要和认证服务中的配置一致,如果是使用 inMemory 还是 jdbc 方式。

user-authorization-uri 是授权码认证方式需要的,下一篇文章再说。

access-token-uri 是密码模式需要用到的获取 token 的接口。

authorization.check-token-access 也是关键信息,当此服务端接收到来自客户端端的请求后,需要拿着请求中的 token 到认证服务端做 token 验证,就是请求的这个接口

3、资源配置文件

在 OAuth2 的概念里,所有的接口都被称为资源,接口的权限也就是资源的权限,所以 Spring Security OAuth2 中提供了关于资源的注解 @EnableResourceServer,和 @EnableWebSecurity的作用类似。


@Configuration@EnableResourceServer@EnableGlobalMethodSecurity(prePostEnabled = true)public class ResourceServerConfig extends ResourceServerConfigurerAdapter {   @Value("${security.oauth2.client.client-id}")   private String clientId;   @Value("${security.oauth2.client.client-secret}")   private String secret;   @Value("${security.oauth2.authorization.check-token-access}")   private String checkTokenEndpointUrl;   @Autowired   private RedisConnectionFactory redisConnectionFactory;   @Bean   public TokenStore redisTokenStore (){       return new RedisTokenStore(redisConnectionFactory);  }   @Bean   public RemoteTokenServices tokenService() {       RemoteTokenServices tokenService = new RemoteTokenServices();       tokenService.setClientId(clientId);       tokenService.setClientSecret(secret);       tokenService.setCheckTokenEndpointUrl(checkTokenEndpointUrl);       return tokenService;  }   @Override   public void configure(ResourceServerSecurityConfigurer resources) throws Exception {       resources.tokenServices(tokenService());  }}

因为使用的是 redis 作为 token 的存储,所以需要特殊配置一下叫做 tokenService 的 Bean,通过这个 Bean 才能实现 token 的验证。

4、最后,添加一个 RESTful 接口


@Slf4j@RestControllerpublic class UserController {   @GetMapping(value = "get")   //@PreAuthorize("hasAuthority('ROLE_ADMIN')")   @PreAuthorize("hasAnyRole('ROLE_ADMIN')")   public Object get(Authentication authentication){       //Authentication authentication = SecurityContextHolder.getContext().getAuthentication();       authentication.getCredentials();       OAuth2AuthenticationDetails details = (OAuth2AuthenticationDetails)authentication.getDetails();       String token = details.getTokenValue();       return token;  }}

一个 RESTful 方法,只有当访问用户具有 ROLE_ADMIN 权限时才能访问,否则返回 401 未授权。

通过 Authentication 参数或者 SecurityContextHolder.getContext().getAuthentication() 可以拿到授权信息进行查看。

测试认证功能

1、启动认证服务端,启动端口为 6001

2、启动用户服务客户端,启动端口为6101

3、请求认证服务端获取 token

我是用 REST Client 来做访问请求的,请求格式如下:


POST http://localhost:6001/oauth/token?grant_type=password&username=admin&password=123456&scope=allAccept: */*Cache-Control: no-cacheAuthorization: Basic dXNlci1jbGllbnQ6dXNlci1zZWNyZXQtODg4OA==

假设咱们在一个 web 端使用,grant_type 是 password,表明这是使用 OAuth2 的密码模式。

username=admin 和 password=123456 就相当于在 web 端登录界面输入的用户名和密码,我们在认证服务端配置中固定了用户名是 admin 、密码是 123456,而线上环境中则应该通过查询数据库获取。

scope=all 是权限有关的,在认证服务的 OAuthConfig 中指定了 scope 为 all 。

Authorization 要加在请求头中,格式为 Basic 空格 base64(clientId:clientSecret),这个微服务客户端的 client-id 是 user-client,client-secret 是 user-secret-8888,将这两个值通过冒号连接,并使用 base64 编码(user-client:user-secret-8888)之后的值为 dXNlci1jbGllbnQ6dXNlci1zZWNyZXQtODg4OA==,可以通过 https://www.sojson.com/base64.html 在线编码获取。

运行请求后,如果参数都正确的话,获取到的返回内容如下,是一段 json 格式


{ "access_token": "9f958300-5005-46ea-9061-323c9e6c7a4d", "token_type": "bearer", "refresh_token": "0f5871f5-98f1-405e-848e-80f641bab72e", "expires_in": 3599, "scope": "all"}

access_token :  就是之后请求需要带上的 token,也是本次请求的主要目的 token_type:为 bearer,这是 access token 最常用的一种形式 refresh_token:之后可以用这个值来换取新的 token,而不用输入账号密码 expires_in:token 的过期时间(秒)

4、用获取到的 token 请求资源接口

我们在用户客户端中定义了一个接口 http://localhost:6101/client-user/get,现在就拿着上一步获取的 token 来请求这个接口。


GET http://localhost:6101/client-user/getAccept: */*Cache-Control: no-cacheAuthorization: bearer ce334918-e666-455a-8ecd-8bd680415d84

同样需要请求头 Authorization,格式为 bearer + 空格 + token,正常情况下根据接口的逻辑,会把 token 原样返回。

5、token 过期后,用 refresh_token 换取 access_token

一般都会设置 access_token 的过期时间小于 refresh_token 的过期时间,以便在 access_token 过期后,不用用户再次登录的情况下,获取新的 access_token。


### 换取 access_tokenPOST http://localhost:6001/oauth/token?grant_type=refresh_token&refresh_token=706dac10-d48e-4795-8379-efe8307a2282Accept: */*Cache-Control: no-cacheAuthorization: Basic dXNlci1jbGllbnQ6dXNlci1zZWNyZXQtODg4OA==

grant_type 设置为 refresh_token。

refresh_token 设置为请求 token 时返回的 refresh_token 的值。

请求头加入 Authorization,格式依然是 Basic + 空格 + base64(client-id:client-secret)

请求成功后会返回和请求 token 同样的数据格式。

用 JWT 替换 redisToken

上面 token 的存储用的是 redis 的方案,Spring Security OAuth2 还提供了 jdbc 和 jwt 的支持,jdbc 的暂不考虑,现在来介绍用 JWT 的方式来实现 token 的存储。

用 JWT 的方式就不用把 token 再存储到服务端了,JWT 有自己特殊的加密方式,可以有效的防止数据被篡改,只要不把用户密码等关键信息放到 JWT 里就可以保证安全性。

认证服务端改造

先把有关 redis 的配置去掉。

添加 JwtConfig 配置类

@Configurationpublic class JwtTokenConfig {   @Bean   public TokenStore jwtTokenStore() {       return new JwtTokenStore(jwtAccessTokenConverter());  }   @Bean   public JwtAccessTokenConverter jwtAccessTokenConverter() {       JwtAccessTokenConverter accessTokenConverter = new JwtAccessTokenConverter();       accessTokenConverter.setSigningKey("dev");       return accessTokenConverter;  }}

JwtAccessTokenConverter是为了做 JWT 数据转换,这样做是因为 JWT 有自身独特的数据格式。如果没有了解过 JWT ,可以搜索一下先了解一下。

更改 OAuthConfig 配置类

@Autowiredprivate TokenStore jwtTokenStore;@Autowiredprivate JwtAccessTokenConverter jwtAccessTokenConverter;@Overridepublic void configure(final AuthorizationServerEndpointsConfigurer endpoints) throws Exception {       /**        * 普通 jwt 模式        */        endpoints.tokenStore(jwtTokenStore)              .accessTokenConverter(jwtAccessTokenConverter)              .userDetailsService(kiteUserDetailsService)               /**                * 支持 password 模式                */              .authenticationManager(authenticationManager);}

注入 JWT 相关的 Bean,然后修改 configure(final AuthorizationServerEndpointsConfigurer endpoints) 方法为 JWT 存储模式。

改造用户客户端

修改 application.yml 配置文件

security:oauth2:  client:    client-id: user-client    client-secret: user-secret-8888    user-authorization-uri: http://localhost:6001/oauth/authorize    access-token-uri: http://localhost:6001/oauth/token  resource:    jwt:      key-uri: http://localhost:6001/oauth/token_key      key-value: dev

注意认证服务端 JwtAccessTokenConverter设置的 SigningKey 要和配置文件中的 key-value 相同,不然会导致无法正常解码 JWT ,导致验证不通过。

ResourceServerConfig 类的配置

@Configuration@EnableResourceServer@EnableGlobalMethodSecurity(prePostEnabled = true)public class ResourceServerConfig extends ResourceServerConfigurerAdapter {   @Bean   public TokenStore jwtTokenStore() {       return new JwtTokenStore(jwtAccessTokenConverter());  }   @Bean   public JwtAccessTokenConverter jwtAccessTokenConverter() {       JwtAccessTokenConverter accessTokenConverter = new JwtAccessTokenConverter();       accessTokenConverter.setSigningKey("dev");       accessTokenConverter.setVerifierKey("dev");       return accessTokenConverter;  }   @Autowired   private TokenStore jwtTokenStore;   @Override   public void configure(ResourceServerSecurityConfigurer resources) throws Exception {       resources.tokenStore(jwtTokenStore);  }}
运行请求 token 接口的请求

POST http://localhost:6001/oauth/token?grant_type=password&username=admin&password=123456&scope=allAccept: */*Cache-Control: no-cacheAuthorization: Basic dXNlci1jbGllbnQ6dXNlci1zZWNyZXQtODg4OA==

返回结果如下:


{ "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1NzE3NDM0OTQsInVzZXJfbmFtZSI6ImFkbWluIiwiYXV0aG9yaXRpZXMiOlsiUk9MRV9BRE1JTiJdLCJqdGkiOiI4Y2NhMjlhZi1lYTc3LTRmZTYtOWZlMS0zMjc0MTVkY2QyMWQiLCJjbGllbnRfaWQiOiJ1c2VyLWNsaWVudCIsInNjb3BlIjpbImFsbCJdfQ.0Ik3UwB1xjX2le5luEdtVAI_MEyu_OloRRYtPOvtvwM", "token_type": "bearer", "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX25hbWUiOiJhZG1pbiIsInNjb3BlIjpbImFsbCJdLCJhdGkiOiI4Y2NhMjlhZi1lYTc3LTRmZTYtOWZlMS0zMjc0MTVkY2QyMWQiLCJleHAiOjE1NzE3NzU4OTQsImF1dGhvcml0aWVzIjpbIlJPTEVfQURNSU4iXSwianRpIjoiZjdkMjg4NDUtMmU2ZC00ZmRjLTg1OGYtMWNiY2RlNzI1ZmMyIiwiY2xpZW50X2lkIjoidXNlci1jbGllbnQifQ.vk_msYtbrAr93h5sK4wy6EC2_wRD_cD_UBS8O6eRziw", "expires_in": 3599, "scope": "all", "jti": "8cca29af-ea77-4fe6-9fe1-327415dcd21d"}

我们已经看到返回的 token 是 JWT 格式了,到 JWT 在线解码网站 https://jwt.io/ 或者 http://jwt.calebb.net/将 token 解码看一下

看到了没,user_name、client_id 等信息都在其中。

拿着返回的 token 请求用户客户端接口

GET http://localhost:6101/client-user/getAccept: */*Cache-Control: no-cacheAuthorization: bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1NzE3NDM0OTQsInVzZXJfbmFtZSI6ImFkbWluIiwiYXV0aG9yaXRpZXMiOlsiUk9MRV9BRE1JTiJdLCJqdGkiOiI4Y2NhMjlhZi1lYTc3LTRmZTYtOWZlMS0zMjc0MTVkY2QyMWQiLCJjbGllbnRfaWQiOiJ1c2VyLWNsaWVudCIsInNjb3BlIjpbImFsbCJdfQ.0Ik3UwB1xjX2le5luEdtVAI_MEyu_OloRRYtPOvtvwM

增强 JWT

如果我想在 JWT 中加入额外的字段(比方说用户的其他信息)怎么办呢,当然可以。spring security oauth2 提供了 TokenEnhancer 增强器。其实不光 JWT ,RedisToken 的方式同样可以。

声明一个增强器

public class JWTokenEnhancer implements TokenEnhancer {   @Override   public OAuth2AccessToken enhance(OAuth2AccessToken oAuth2AccessToken, OAuth2Authentication oAuth2Authentication) {       Map<String, Object> info = new HashMap<>();       info.put("jwt-ext", "JWT 扩展信息");      ((DefaultOAuth2AccessToken) oAuth2AccessToken).setAdditionalInformation(info);       return oAuth2AccessToken;  }}

通过 oAuth2Authentication 可以拿到用户名等信息,通过这些我们可以在这里查询数据库或者缓存获取更多的信息,而这些信息都可以作为 JWT 扩展信息加入其中。

OAuthConfig 配置类修改

注入增强器


@Autowiredprivate TokenEnhancer jwtTokenEnhancer;@Beanpublic TokenEnhancer jwtTokenEnhancer(){   return new JWTokenEnhancer();}

修改 configure(final AuthorizationServerEndpointsConfigurer endpoints)方法


@Overridepublic void configure( final AuthorizationServerEndpointsConfigurer endpoints ) throws Exception{/*** jwt 增强模式*/TokenEnhancerChain enhancerChain = new TokenEnhancerChain();List<TokenEnhancer> enhancerList = new ArrayList<>();enhancerList.add( jwtTokenEnhancer );enhancerList.add( jwtAccessTokenConverter );enhancerChain.setTokenEnhancers( enhancerList );endpoints.tokenStore( jwtTokenStore ).userDetailsService( kiteUserDetailsService )/*** 支持 password 模式*/.authenticationManager( authenticationManager ).tokenEnhancer( enhancerChain ).accessTokenConverter( jwtAccessTokenConverter );}
再次请求 token ,返回内容中多了个刚刚加入的 jwt-ext 字段

{ "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX25hbWUiOiJhZG1pbiIsImp3dC1leHQiOiJKV1Qg5omp5bGV5L-h5oGvIiwic2NvcGUiOlsiYWxsIl0sImV4cCI6MTU3MTc0NTE3OCwiYXV0aG9yaXRpZXMiOlsiUk9MRV9BRE1JTiJdLCJqdGkiOiJhNDU1MWQ5ZS1iN2VkLTQ3NTktYjJmMS1mMGI5YjIxY2E0MmMiLCJjbGllbnRfaWQiOiJ1c2VyLWNsaWVudCJ9.5j4hNsVpktG2iKxNqR-q1rfcnhlyV3M6HUBx5cd6PiQ", "token_type": "bearer", "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX25hbWUiOiJhZG1pbiIsImp3dC1leHQiOiJKV1Qg5omp5bGV5L-h5oGvIiwic2NvcGUiOlsiYWxsIl0sImF0aSI6ImE0NTUxZDllLWI3ZWQtNDc1OS1iMmYxLWYwYjliMjFjYTQyYyIsImV4cCI6MTU3MTc3NzU3OCwiYXV0aG9yaXRpZXMiOlsiUk9MRV9BRE1JTiJdLCJqdGkiOiJmNTI3ODJlOS0wOGRjLTQ2NGUtYmJhYy03OTMwNzYwYmZiZjciLCJjbGllbnRfaWQiOiJ1c2VyLWNsaWVudCJ9.UQMf140CG8U0eWh08nGlctpIye9iJ7p2i6NYHkGAwhY", "expires_in": 3599, "scope": "all", "jwt-ext": "JWT 扩展信息", "jti": "a4551d9e-b7ed-4759-b2f1-f0b9b21ca42c"}

用户客户端解析 JWT 数据

我们如果在 JWT 中加入了额外信息,这些信息我们可能会用到,而在接收到 JWT 格式的 token 之后,用户客户端要把 JWT 解析出来。

引入 JWT 包

<dependency>   <groupId>io.jsonwebtoken</groupId>   <artifactId>jjwt</artifactId>   <version>0.9.1</version></dependency>
加一个 RESTful 接口,在其中解析 JWT

@GetMapping(value = "jwt")@PreAuthorize("hasAnyRole('ROLE_ADMIN')")public Object jwtParser(Authentication authentication){   authentication.getCredentials();   OAuth2AuthenticationDetails details = (OAuth2AuthenticationDetails)authentication.getDetails();   String jwtToken = details.getTokenValue();   Claims claims = Jwts.parser()              .setSigningKey("dev".getBytes(StandardCharsets.UTF_8))              .parseClaimsJws(jwtToken)              .getBody();   return claims;}

同样注意其中签名的设置要与认证服务端相同。

用上一步的 token 请求上面的接口

### 解析 jwtGET http://localhost:6101/client-user/jwtAccept: */*Cache-Control: no-cacheAuthorization: bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX25hbWUiOiJhZG1pbiIsImp3dC1leHQiOiJKV1Qg5omp5bGV5L-h5oGvIiwic2NvcGUiOlsiYWxsIl0sImV4cCI6MTU3MTc0NTE3OCwiYXV0aG9yaXRpZXMiOlsiUk9MRV9BRE1JTiJdLCJqdGkiOiJhNDU1MWQ5ZS1iN2VkLTQ3NTktYjJmMS1mMGI5YjIxY2E0MmMiLCJjbGllbnRfaWQiOiJ1c2VyLWNsaWVudCJ9.5j4hNsVpktG2iKxNqR-q1rfcnhlyV3M6HUBx5cd6PiQ

返回内容如下:


{ "user_name": "admin", "jwt-ext": "JWT 扩展信息", "scope": [   "all"], "exp": 1571745178, "authorities": [   "ROLE_ADMIN"], "jti": "a4551d9e-b7ed-4759-b2f1-f0b9b21ca42c", "client_id": "user-client"}

以上就是 password 模式的完整过程,源码放到了 github 上,有需要的可以去看一下。

源码地址: https://github.com/huzhicheng/spring-cloud-study/tree/master/oauth2

mysql数据库连接超过8小时失效的解决方案(springboot)

最近由于业务需要,开发了一个考勤服务,昨天部署上去,当时测试业务OK,今天早上发现考勤无法正常上传服务器。 后台日志显示数据库连接不上。

使用IDE连接数据库正常,服务确显示链接超时。
查看错误日志,发现了如下的日志:

经查发现原来是mysql默认会将8个小时内没有操作过的数据库连接断开。

解决方法:

1)设置Mysql链接超时时间

mysql数据库有一个wait_timeout的配置,默认值为28800(即8小时).

在默认配置不改变的情况下,如果连续8小时内都没有访问数据库的操作,再次访问mysql数据库的时候,mysql数据库会拒绝访问。

查看超时时间:
show variables like  ‘%timeout%’;
闲置连接的超时时间由wait_timeout控制、默认8小时。

解决方案:

第一种途径使用命令行在mysql提示符下

>set  global wait_timeout=1814400
这种方式是一种临时方法,重启服务就会返回默认值了。

第二种途径修改my.ini配置文件

[mysqld]
wait_timeout=31536000
interactive_timeout=31536000 

在mysqld下面添加以上两行,后面的数字是时间

重启服务

2:代码服务中设置数据源超时检测

在application.yaml中设置datasource的时候,加入如下设置:

      #验证连接的有效性
      test-while-idle: true 
      #获取连接时候验证,会影响性能
      test-on-borrow: true
      #在连接归还到连接池时是否测试该连接
      secondary.test-on-return: false
      validation-query: SELECT 1 FROM DUAL
      #空闲连接回收的时间间隔,与test-while-idle一起使用,设置5分钟
      time-between-eviction-runs-millis: 300000
      #连接池空闲连接的有效时间 ,设置30分钟
      min-evictable-idle-time-millis: 600000
      initial-size: 5
      #指定连接池中最大的活跃连接数.
      max-active: 50
      #指定连接池等待连接返回的最大等待时间,毫秒单位.
      max-wait: 60000
      #指定必须保持连接的最小值
      min-idle: 5

它的作用就是会每隔一段时间向mysql进行一次连接可用确认。

测试:

  1. 将mysql的默认时间改为60s,方便测试。(这俩都设置)
set global interactive_timeout=60;
set global wait_timeout=60;
  1. 不设置上面的自动连接确认,隔两分钟请求测试接口。
    第一次请求正常,第二次请求又报上面的错误。
  2. 设置自动连接确认,隔两分钟请求测试接口。
    多次请求均正常。

也可以将上面两个参数设置大一些,比如一年等。需要注意这俩参数的作用级别是“数据库实例”。注意对其他库的影响。

20201016

好企业,好趋势,截断亏损让利润奔跑
汉王科技:
跌破5日线减仓。14号交易量突然很大且没有封住板,应该减仓一部分的,现在有跌回来了。
对于盘面的感觉还是有点差,没有思考。交易量比平时高2倍以上,涨幅超7%没有封板,一般第二天都是低开。
龙蟒佰利:
原定策略:跌破5日线减仓 10日线清仓。

16日中午急跌清仓,下午又买回来了。
没有按照定交易策略 5日线减仓,感觉离市场太近了,
看到急跌急涨容易激情操作。

山东药玻
交易逻辑:新冠疫苗接受预约,药品玻璃包装龙头,疫苗封装包装有优势。
从高点调整时间和空间教充分。
日线多均线已走平,微翘。
持有策略: 中期趋势交易 跌破43趋势线清仓。

16日中午急跌清仓,下午又买回来了。
没有按照定交易策略 5日线减仓,感觉离市场太近了,
看到急跌急涨容易激情操作。

业绩猛增的新上市公司!——稳健医疗

“一朵棉花,改变世界”,这是最近一家新上市公司的美好愿景。那什么样的棉花有这么大能耐呢?当然这里不是单纯地指棉花,而是棉织品。
说到棉织品大家能联想到的肯定是衣服,尤其像内衣,尽量要选择布料成分100%是棉的,那穿起来就会很舒服,透气又干爽。有个品牌叫“纯棉时代”,有些朋友应该听说过,有相当一部分的80、90后父母们一定用过他们家的棉柔巾、婴儿纯棉湿巾等,这些都是非常不错的产品。
“纯棉时代”只是个品牌,她背后的公司叫做稳健医疗,看似完全不相干的行业,一个做棉织消费品的公司为什么叫“医疗”,这是因为她还有一块业务,是从事医疗敷料的。这家公司所占的两个细分赛道都非常不错,今年9月也在创业板成功上市,今天老裘就带大家了解一下。
首先,大家可能对公司还不太熟悉,我们先简单介绍下。
公司是一家以“棉”为核心的企业,总部在深圳,刚才说的“全棉时代(Purcotton)”是其中一个品牌,另一个品牌叫“稳健医疗(Winner)”。全棉时代主要覆盖个人、家庭护理、母婴护理、纺织服装等;稳健医疗同样围绕“棉”在做文章,主要从事医用敷料领域,举个最简单的例子,就是包扎用的纱布、棉签这类产品。
公司的控股股东是稳健集团,实控人是李建全,实控人家族合计持股68%,股权结构稳定。公司给自己的定位如下图。

注:图片来自招股说明书
1995年,公司早期以OEM代工起步,随后逐步发展出自己的品牌;2000年开始生产各种规模的医用纱布和牙科片;2003年推出自有品牌“Winner稳健”,业务模式开始从OEM贴牌出口转向国内自有市场,进入医院和药店;2009年公司进入消费品领域,同年成立品牌“全棉时代”。
其实有挺多上市公司都是这种发展轨迹,代工一段时间,积累了一定技术后,逐步开始发展自有品牌,比如科沃斯、歌尔股份、开润股份、Stella Luna等都属于这类企业,这种发展轨迹在加工制造业,特别是纺服行业里最为多见。
接下来我们看看公司所处行业情况。
一方面,要聊聊健康生活消费品业务板块,即全棉时代所处赛道可以说是横跨了三细分行业。
首先是生活用纸行业,这个属于刚性需求,近年行业规模实现稳健增长。与此同时,由于纯棉柔巾的环保性和不易致敏性,形成了对于传统纸品的替代,市场潜力较大。根据中国造纸协会的数据,2018年我国生活用纸市场的消费总额达到1168亿元,同比增长5.6%。
据统计,2014年我国棉柔巾行业的市场需求仅为9.9亿张,预计2019年将增至232.6亿张,年均复合增速达到88.02%,棉柔巾市场的需求量实现高速增长,产品普遍被大家认同并接受。
其中,2019年全棉时代市场占有率超过65%,即使在全部抽纸类产品中进行排名,全棉时代旗舰店棉柔巾成交金额也已经超过了维达旗舰店、洁柔旗舰店等纸品的成交额。
其次是一次性卫生用品,包括吸收性卫生用品和擦拭巾(含湿巾),其中吸收性卫生用品包括女性卫生用品、婴儿纸尿布和成人失禁用品。2013-2018年,我国吸收性卫生用品市场规模由624.8亿元增长到1188.7亿元,年复合增长率达13.7%。
在吸收性卫生用品领域,行业竞争较为激烈,市场集中度较低,有众多国内厂商与全球性厂商共同参与竞争。尿布的品牌就有许多,比如花王、好奇、帮宝适等;女性卫生用品则有七度空间、ABC、苏菲、高洁丝等等,参与者众多。
最后是纺服行业,纺服是万亿级市场,自然参与者就更多了。截至2018年末,我国纺织业共有规模以上企业1.91万家,纺织服装、服饰业共有规模以上企业1.58万家。
而公司品牌Purcotton全棉时代,基于多年医疗业务中积累的出色质量管控能力及技术研发能力,持续推出医疗级品质的消费品。
从原材料开始,全棉时代核心产品的主要原材料棉花均采用优质的新疆棉、澳棉、美棉,从源头把控产品品质与安全。公司目前在深圳、北京、上海、广州等全国50多个重点城市的大型购物中心开设了250余家直营连锁店。
Purcotton从产品分类来看,分为无纺消费品和纺织消费品,无纺消费品的增长和渗透率不断提高。
另一方面,我们聊聊医用敷料板块,即稳健医疗所处赛道
中国是当前世界最大的医用敷料出口国,2019年我国医用敷料出口额达到27.16亿美元,同比增长4.16%,我国医用敷料出口额已经占据全球医用敷料20%的市场份额。2014-2018年,我国医用敷料行业规模复合增速达到13.15%,2020年预计达到82.25亿元。
由于我国医用敷料行业进入门槛较低,导致行业集中度较低,且我国国产医用敷料以传统伤口护理类产品为主,占比超过80%,所以产品同质化现象较为严重,以低价竞争为主。
虽然我国传统伤口护理类产品的质量已经达到世界领先水平,但是在产品创新、技术研发等方面仍与发达国家具备较大差距。美国、欧洲和日本等国家是全球最主要的医用敷料消费市场,根据BMI的预计,2020年全球医用敷料行业市场规模将达到132.84亿美元。
所以,未来新型高端医用敷料将逐步取代部分传统敷料的地位。在全球市场,欧洲地区新型高端医用敷料的份额占比达到42%,美国占比达到40%,而我国占比仅为3%,在渗透率上存在较大空间。
公司品牌“Winner稳健”的医用敷料业务主要包括伤口护理类、感染防护类、清洁消毒类,其中伤口护理类占比最高,而高端的伤口敷料产品占比在不断提升中,未来也是重点发展方向。
公司在境外市场主要通过OEM代工实现生产销售,医用敷料近三年始终位列全国第二名,第一名则是奥美医疗。国内方面,公司稳健医疗主要是自有产品,目前已进入了2000多家医院和4万家左右药店。
综上所述,公司的销售模式可以分为线上和线下、国内和国外、自有和代工,渠道非常齐全。

注:图片来自招股说明书
然后,我们聊一下公司财务状况。
公司2019年营收45.7亿元,归母净利润为5.5亿元,近五年的年复合增长率约在24%左右。到今年上半年,收入更是猛增至41.8亿元,同比增长98.5%,接近翻倍,归母净利润为10.3亿元,同比增长350%。上半年激增的原因很明显和疫情有关,相关医用感染防护设备、护理产品、消毒用湿巾等都会有超预期的需求。
前几天,公司发布公告,向上修正2020年前三季度营收为93.25-97.25亿元,同比增加199%-212%,较修正前预告值增加21%-26%;归母净利润为30.37-32.37亿元,同比增加651%-700%,较修正前预告值增加67%-78%。可见,公司业绩增速依旧没有放缓。
从2019年营收角度来看,医用敷料贡献营收约1/4,为11.9亿元,其中境外OEM代工收入占了约65%,消费品占营收约3/4,30.3亿元。从具体的细分产品增长角度而言,近三年的趋势如下图(单位:万元)。
从医用敷料业务看,高端伤口敷科产品占比正缓慢扩大营收占比,传统护理产品的比例则在逐步下降。从消费品看,公司的核心产品棉柔巾占比和增长趋势都较为明显,去年成人服饰的增长较为显著,同比增长51%。
从渠道来看,线上渠道的销售占比比较高,主要以直营旗舰店为主,2019年营收14.09亿元,占线上渠道的83.81%。线下渠道刚才已经介绍,公司线下有250家左右的门店,线下直营门店的收入占总收入的38%左右。
盈利能力方面,公司的各个业务板块毛利率近三年也是持续走高。毛利率的提升主要有两个方面,一方面原材料是棉花,而近几年棉花的价格整体水平一直处于低位,另一方面则是公司毛利率较高的健康生活消费品占比不断提高,拉高了公司整体毛利率。
费用方面,销售费用相比管理费用和研发支出上升得更快。由于公司有较多的线下门店,所以销售费用一定是费用的支出大头,占营收比也是每年在提升,2019年占比达到了29.6%;管理费用上升得则比较温和,去年占比7.7%;研发支出也在缓慢提升,去年占比3.4%。
净利率方面,基本维持在11%左右,但今年上半年由于疫情,关闭了许多门店,节省了不少费用开支,使得净利率上升至25%。
综上,公司ROE并不是特别高,但也不太低。由于其具有一定商超属性,资产周转略慢,外加净利率也并不高,所以ROE一直在18%左右,较为稳定。
最后,我们聊一聊公司估值。
由于篇幅原因,老裘并没有将每块业务都详细分析,仅挑了重点业务稍做介绍。但实际公司是由三部分组成,其中:医用敷料今年一定是个大年,增长一定是迅猛的,较去年翻3-4倍也是有可能的;健康生活品上半年也有不俗的表现,全年增长预计会比去年略高一些,可能在30%-35%左右;最后一小块业务是全面水刺无纺布,我们假设维持和去年同样的增长率20%左右。
所以,结合中报及三季报的表现,我们认为公司净利率可以取到25%-30%,测算下来得到今年的净利润可能在35-40亿元之间,供大家参考。
好了,今天就和大家聊到这里了。这篇文章给大家介绍了一家新上市的公司,稳健医疗,希望能给大家带来一些收获,喜欢我们的朋友别忘记点个“在看”支持一下哦,谢谢大家!
本文内容仅供参考,不作为任何投资建议;
股市有风险,投资需谨慎。
 

20201014

汉王科技

午后封板没有封住,成交量温和放大,创60日新高。

成交量近2个月有所放大,近期券商调研较多,可能有较大行情。

三季报预增,四季度业务旺季。今年利润有保障。

持有策略:有上涨加速迹象,修正持有策略。

跌破10日线清仓,跌破5日线减仓。

天坛生物:

九月底无消息暴跌, 38补仓 40T出,目前平均成本40


估价周线20日线 窄幅震荡, 日线走平。成交量缩量。等三季报快报。

持有策略: 长期

龙蟒佰利:

10.12 25.82减仓, 10.13 26.85加仓。

交易逻辑:

趋势交易。周线窄幅震荡3个月,有突破迹象。
日线均线缠绕,支持较强。

十月份券商金股推荐次数较多,钛白粉涨价预期概率大,下半年已涨价4次。钛白粉龙头企业。

持有策略:趋势
跌破10日线清仓,跌破5日线减仓。

山东药玻

交易逻辑:新冠疫苗接受预约,药品玻璃包装龙头,疫苗封装包装有优势。

从高点调整时间和空间教充分。

日线多均线已走平,微翘。

持有策略: 中期趋势交易 跌破43趋势线清仓。

纯干货!如何解读资产负债表(一)

证券研究可以分成基本面研究和技术面研究两大类。这两种研究在投资实战中都有各自的优点,同时也有各自的不足。老裘的经验教训总结起来就是两者兼顾:选股靠基本面、交易要靠技术面

 

基本面的研究包括了宏观和策略的研究、行业基本面的研究和个股基本面的研究。我们先从最基本的个股基本面的研究说起。

 

研究个股的基本面可以有很多角度,最常见的角度包括了:公司股东的研究、公司管理层的研究、公司战略的研究、公司产品的研究、财务报表的研究、搭建公司的盈利预测模型和公司估值的研究等方面。其中财报、盈利预测模型和估值是偏定量的研究,在整个研究中占了相当大的比重。

 

三张财务报表中,最最重要的就是资产负债表。资产负债表包含的财务信息的内容最多、公司给予的注解最清晰,同时资产负债表也是具有领先和预判作用的报表。很多时候公司当期的利润表看起来还不错,但是如果资产负债表已经恶化了,那么之后公司的收入和利润也都会大幅下滑。

 

资产负债表的内容非常多,所以我们拿到资产负债表之后第一个工作就是简化。当年老裘在复旦读书的时候,教授介绍读书的方法就一开始要把厚的书读薄,熟悉之后又要把薄书读厚。

 

简化后的资产负债表只要保留流动资产、非流动资产、总资产、流动负债、非流动负债、总负债、归母权益、少数股东权益和总权益这几个内容。为了解释清楚,我们把当红明星乐视网请出来(后面乐视网还会经常出现),帮助我们理解相关内容。


 

下表是简化后的乐视网的资产负债表。研究资产负债表的时候,我们一般拿最新一期的资产负债表(17年9月30日)和前几年(16年年报、15年年报、14年年报和13年年报)资产负债表做对比。

 

在简化后的资产负债表上我们主要研究1个金额,2个指标和3个结构。

 

一、1个金额指的是总资产的变动。

乐视网13年底的总资产是50亿元,14年底增加到88亿元,15年底几乎翻倍到170亿元,16年再大幅增加到322亿元。但是17年3季度末和16年末比基本没有增长。

 

作为投资者,我们喜欢去找有成长性的公司。那么成长性体现在哪里呢?是体现在收入和利润上吗?Too Young,Too Naive了。之前说过,资产负债表是领先利润表的,所以根据资产负债表就可以预先进行判断。从道理上讲也能说通,公司只有先增加流动资产和非流动资产,同时提高杠杆率后才能支持后续的收入及利润的增长。

 

乐视网之前是资本市场的宠儿,股票价格大幅上升,我们在之前公司的资产负债表上就可以得到基本面的印证。


那增长多少才算大幅增加呢,这个没有一定的。我们往往参照行业龙头的总资产增加的数据或者低一点用GDP的增长率来作为标准。但根据经验,如果一个公司的总资产不再增加,或者总资产开始大幅萎缩(业内叫做“缩表”),这个公司的股价往往会有很大的下跌风险。

 

二、两个指标:资产负债率和流动比率


 

两个指标的算法就不写出来了,有兴趣的粉友可以自行百度。

 

拿乐视而言,公司的资产负债率从2015年78%逐步下降到17年3季度末的54%,表明公司在不断的降杠杆。高的资产负债率固然可以提升净资产收益率,但是这个比率太高则意味着公司面临潜在的非常高的财务风险。

 

作为投资人我们不喜欢太高杠杆的公司。当然每个行业的资产负债率天然不一样的。所以在看资产负债率的时候,还要结合各自行业及行业龙头的数据进行比较。国内制造业合适的资产负债率在50%左右,超过60%就要开始小心了。

 

当然乐视的降杠杆其实是用了一个非常不常见的手段,我们留到第三部分进行介绍。会计上这样的处理是可以的,但是作为投资人我们要保持清醒。

 

除了资产负债率,我们还要计算流动比率。这个比例比较合适的是2,也就是流动资产是流动负债的2倍。我们看到乐视的流动比率长期在1附近,表示了公司的流动性压力还是非常紧张的。

 

三、三个结构

资产的结构。相比16年底,到了17年3季度末,乐视的总资产基本没有变化,但是流动资产少了约17亿元,而非流动资产增加了17亿元。非流动资产都是变现能力很差的资产,我不认为现在增加非流动资产对乐视有帮助。

 

负债的结构。相比16年底,到了17年3季度末,乐视的总负债下降了43亿元,但从结构上看,乐视的流动负债基本没有变化,主要是非流动负债的金额从92亿下降到了47亿。非流动负债是一年以上的负债,一般需要支付的利率较多,非流动负债的下降可以减轻公司的利息支出,有利于改善利润表。

 

股东权益的结构。相比16年底,到了17年3季度末乐视的股东权益增加了44亿元,其中归母权益增加了22亿元,少数股东权益增加了22亿元。归母权益的增加和少数股东权益的增加主要是乐视放弃了部分乐视致新的控股权。我们希望看到的股东权益的增加是来自于股东的真金白银投入和公司盈利能力的增加,卖出控股权其实是一次性的,不能持续下去的。所以这样增加股东权益的方法并不是我们所喜欢的。

 

未完待续……


纯干货!如何解读资产负债表(三)

老裘一直说会计三张表中,资产负债表是最重要的财务报表。我们需要对资产负债表的总体进行把握。

 

当我们已经分清楚资产负债表上的每个科目属于“经营性”、“筹资性”或者“投资性”之后,我们要理解一个变化的关系,也就是从现金的角度看:

 


明白了现金流入流出之后,我们给整个资产负债表来个乾坤大挪移,完全改写成另外一种形式。依然是乐视的例子,从2016年12月31日(A时点)到17年9月30日(B时点),为了方便大家理解,老裘在真实数据的基础上做了相当大的简化,请见下表:

 

 

我们发现从A时点到B时点,乐视网经营性的现金变动是-26.1亿元,即表示现金流出;乐视网的投资性的现金变动时-14.7亿元,即表示现金流出;乐视网的筹资性的现金流入是12.4亿元,即表示现金流入。简单相加,就得到整个现金的流出是-28.4亿元。

 

这个数据和我们直接用B时点的报表上现金金额减去A时点报表上现金金额就可以得出,乐视网B时点比A时点的现金少了28.4亿元,是完全相同的。


那么有了这样的数据,我们就可以开始进行分析了。

 

如果有熟悉现金流量表的朋友们会知道这种乾坤大挪移的方法有些类似于“间接法编制现金流量表”,的确如此,但不完全相同。我们知道三季报只披露直接法的现金流量表,上市公司是不提供间接法的现金流量表。通过乾坤大挪移的方法,我们可以大致测算经营性、筹资性和投资性的现金变动。

 

从分析的角度,我们希望看到的是经营性现金流一定要是正数,表示现金流入,而投资性现金流可以是负数,表示流出,但是最好比经营性现金流的流入要少一点。筹资性现金流中来自于债务的变动和权益的变动最好和历史相互匹配。

 

乐视网前三季度经营性现金流大幅流出,投资性现金流也是大幅流出,如果没有筹资性的现金流入,公司很难维系下去。但是筹资性的现金流入不是一个稳定的来源,债权融资和股权融资会随着融资金额的增加越来越困难。

 

今天主要介绍资产负债表的乾坤大挪移。关于资产负债表的更多解读,感兴趣的新朋友可以看老裘的菜单栏-武功秘籍-进阶秘籍。

 

未完待续

 

 

 

 

 

 


 

 

 

纯干货!如何解读资产负债表(四)


大家好,老裘每天都要看上市公司的年报,这两天看到了一家很有意思的公司,名字叫做沈阳机床。看股价的话,年后开始股价连续上涨,其中还有两天是拉了涨停板。

 

这段时间公司的股价为什么会表现那么好呢,原来这家公司是叫做*ST沈机,17年年报实现盈利,所以就摘帽了,这个叫做摘帽行情。但是如果大家仔细看沈阳机床的财务报表,你会发现公司基本面根本没有好转

 

17年公司的营业总收入是42亿元,而公司总成本是55亿元,公司能够实现盈利是因为公司卖出了下属企业的控股权得到了12亿元,但这个收益是一次性的,18年就没有了。

 

另外如果大家看一下公司的资产负债表。沈阳机床2017年底的货币资金是58亿,短期借款是102亿,一年到期的非流动负债是28个亿,应付债券有5亿元,公司的净有息负债有77亿元,而公司的最新总市值是92个亿。

 

这意味着老裘如果花92亿把公司整个买下来,也就是老裘私有化这个公司,还要再额外支付给债权人77亿元!净有息负债居高不下,利息费用庞大,将会严重影响沈阳机床2018年的盈利。

 

让我们系统性的来学习一下,首先需要把资产负债表打开,分析资产负债表上的4个板块。以“板块”为名,因为每一个小板块里包含了很多有联系的会计科目。

 

今天首先要介绍的第一类板块是“现金类”板块。这个板块包括的会计科目有货币资金、可供出售金融资产、短期借款、一年到期的非流动负债、长期借款和应付债券。

 

货币资金和可供出售金融资产是资产

短期借款、一年到期的非流动负债、长期借款和应付债券是负债

 

常见的对负债的划分是根据负债的到期日,一年内到期的负债叫做流动负债,超过一年到期的负债叫做非流动负债。

 


现在,我们介绍另外一种对负债的划分方法,根据负债是否需要承担利息,把负债分成有息负债和非有息负债

 

短期借款、一年到期的非流动负债、长期借款和应付债券是公司从外部金融机构借的钱,需要支付利息,所以这四类负债是作为有息负债。

 

而其他类型的负债,比如应付账款,表示的是公司占用了供应商的资金,这部分资金不需要承担利息,因此应付账款不属于有息负债。

 

我们把有息负债扣除现金和可供出售的金融资产后,得到一个非常重要的数据,我们把它叫做净有息负债。净有息负债反映的是公司承担的需要支付利息的债务规模。

 

净有息负债是负数,表示公司在手的现金充裕。比如公司货币资金是10亿,没有其他的可供出售和短借这些有息负债,那么公司的净有息负债就是-10亿。

 


在实务中,现金充裕的公司可以做如下一些推论:

 

第一,现金充裕的公司一般不会做恶心人的增发或者配股。资本市场上,如果频繁对外部股东伸手要钱的公司都要引起注意。因为增发、配股、发行可转债这些方式都会摊薄现有股东的权益。极端一点说,老裘没有看到茅台再融资过,也没有看到苹果公司再融资。

第二,现金充裕的公司有极大的可能做一些行业兼并收购。而且用现金做兼并收购比用增发新股做收购来的方便。因为增发新股需要走会里的流程,时间相当慢,而且也可能通不过。

第三,在估值的时候,我们可以给账面现金高一点的估值倍数

比如公司在历史上经常做兼并收购,现在净有息负债是-10亿元,表示公司账上有10亿的净现金。公司的利息收入假设是0.5亿元,即5%的利率。公司正常经营活动的净利润是0.5亿元。


假设不考虑其他税费,公司净利润合计1亿元。研究员如果不分开估值,直接给30倍的PE,那么公司合理的市值是30亿元。

 


(图片来源于泰塔尼克号,版权归作者所有)