spring-cloud-oauth2 认证授权

什么是OAuth2?

  OAuth2是一个关于授权的开放标准,核心思路是通过各类认证手段(具体什么手段OAuth2不关心)认证用户身份,并颁发token(令牌),使得第三方应用可以使用该令牌在限定时间、限定范围访问指定资源。主要涉及的RFC规范有RFC6749(整体授权框架),RFC6750(令牌使用),RFC6819(威胁模型)这几个,一般我们需要了解的就是RFC6749。获取令牌的方式主要有四种,分别是授权码模式简单模式密码模式客户端模式。这里要先明确几个OAuth2中的几个重要概念:

  • resource_owner : 拥有被访问资源的用户
  • user-agent: 一般来说就是浏览器
  • client : 第三方应用
  • Authorization server : 认证服务器,用来进行用户认证并颁发token
  • Resource server: 资源服务器,拥有被访问资源的服务器,需要通过token来确定是否有权限访问

  我们在浏览器端或者APP端做登录的时候时常会遇到 QQ登录、微信登陆、微博登录 等等。这一类称之为第三方登录。在APP端 往往会采用OAuth2。以QQ登录为准,通常是点击了QQ登录,首先跳转到QQ登录授权页面进行扫码授权。然后跳回原来网页设定好的一个回调地址。这其实就完成了OAuth的整个授权流程。OAuth在"客户端"与"服务提供商"之间,设置了一个授权层(authorization layer)。"客户端"不能直接登录"服务提供商",只能登录授权层,以此将用户与客户端区分开来。"客户端"登录授权层所用的令牌(token),与用户的密码不同。用户可以在登录的时候,指定授权层令牌的权限范围和有效期。"客户端"登录授权层以后,"服务提供商"根据令牌的权限范围和有效期,向"客户端"开放用户储存的资料。

OAuth2 运行流程:

  OAuth 2.0的运行流程如下图,摘自RFC6749。

  1. 用户打开客户端以后,客户端要求用户给予授权。( QQ登录跳转到授权页面)
  2. 用户同意给予客户端授权。  (用户扫码确定授权)
  3. 客户端使用上一步获得的授权,向认证服务器申请令牌。(跳转到回调地址,且携带一个 code )
  4. 认证服务器对客户端进行认证以后,确认无误,同意发放令牌。  (通过上一步得到的code 进行授权码认证)
  5. 客户端使用令牌,向资源服务器申请获取资源。 (用换取到的 access_token 进行访问资源)
  6. 资源服务器确认令牌无误,同意向客户端开放资源。 (token 认证通过 返回数据)。

授权方式:

  客户端必须得到用户的授权(authorization grant),才能获得令牌(access token)。OAuth 2.0定义了四种授权方式。

  • 授权码模式(authorization code)
  • 简化模式(implicit)
  • 密码模式(resource owner password credentials)
  • 客户端模式(client credentials)

  本文主要介绍 授权码模式 跟 密码模式。

授权认证服务实现:

  搭建认证服务 Authorization server:

1.导入依赖(包括后续要用到的一些依赖),这里 springboot 2.0.1 、springCloud 版本为 Finchley.SR3:

<dependencies>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-oauth2</artifactId>
        </dependency>
        <!-- https://mvnrepository.com/artifact/org.springframework.cloud/spring-cloud-starter-security -->
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-security</artifactId>
            <version>2.2.1.RELEASE</version>
        </dependency>
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>fastjson</artifactId>
        </dependency>
        <dependency>
            <groupId>commons-lang</groupId>
            <artifactId>commons-lang</artifactId>
            <version>2.6</version>
        </dependency>
        <dependency>
            <groupId>commons-collections</groupId>
            <artifactId>commons-collections</artifactId>
            <version>3.2.1</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.session</groupId>
            <artifactId>spring-session-data-redis</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.security.oauth</groupId>
            <artifactId>spring-security-oauth2</artifactId>
            <version>2.3.3.RELEASE</version>
        </dependency>
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt</artifactId>
            <version>0.7.0</version>
        </dependency>
    </dependencies>

2. 认证服务器配置,要实现认证服务器其实很简单,只要打上 @EnableAuthorizationServer 注解,然后继承 AuthorizationServerConfigurerAdapter 进行一些简单的配置即可。

@Configuration
@EnableAuthorizationServer
public class WuzzAuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {
    //http://localhost:8766/oauth/authorize?client_id=wuzzClientId&response_type=code&redirect_uri=http://www.baidu.com&scope=all

    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.inMemory().withClient("wuzzClientId")//客户端得ID,比如我们在QQ互联中心申请得。可以写多个。配置 循环
                .secret(passwordEncoder().encode("wuzzSecret")) // 客户端密钥,需要进行加密
                .accessTokenValiditySeconds(7200)// token 有效时常  0 永久有效
                .authorizedGrantTypes("password", "implicit", "refresh_token", "authorization_code")// 支持得授权类型
                .redirectUris("http://www.baidu.com")//回调地址
                .scopes("all", "read", "write");//拥有的 scope  可选
    }

    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {

        endpoints.userDetailsService(userDetailsService()) // 用户信息得服务,一版是都数据库
                .authenticationManager(authenticationManager())// 认证管理器。
                .allowedTokenEndpointRequestMethods(HttpMethod.GET, HttpMethod.POST);
    }

    @Override
    public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
        security.allowFormAuthenticationForClients()//允许表单登录
                .checkTokenAccess("permitAll()"); //开启/oauth/check_token验证端口认证权限访问
    }

    @Bean // 注入认证管理器
    public AuthenticationManager authenticationManager() {
        AuthenticationManager authenticationManager = new AuthenticationManager() {
            @Override
            public Authentication authenticate(Authentication authentication) throws AuthenticationException {
                return daoAuthenticationProvider().authenticate(authentication);
            }
        };
        return authenticationManager;
    }

    @Bean//注入认证器
    public AuthenticationProvider daoAuthenticationProvider() {
        DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();
        daoAuthenticationProvider.setUserDetailsService(userDetailsService());
        daoAuthenticationProvider.setHideUserNotFoundExceptions(false);
        daoAuthenticationProvider.setPasswordEncoder(passwordEncoder());
        return daoAuthenticationProvider;
    }

    @Bean//注入 用户信息服务
    public UserDetailsService userDetailsService() {
        return new MyUserDetailService();
    }

    @Bean//注入密码加密
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

3.由于 OAuth2 依赖于 Security 得配置,所以我们这里还需要配置一下  Security :

@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests().antMatchers("/**").fullyAuthenticated().and().httpBasic();
    }
}

4.自定义的用户信息服务类,由于Oauth 的用户需要有个  ROLE_USER 角色 才可以访问,所以这里写死。

public class MyUserDetailService implements UserDetailsService {

    private Logger logger = LoggerFactory.getLogger(getClass());

    @Autowired
    private PasswordEncoder passwordEncoder;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        logger.info("表单登录用户名:" + username);
        // 根据用户名查找用户信息
        //根据查找到的用户信息判断用户是否被冻结
        String password = passwordEncoder.encode("123456");
        logger.info("数据库密码是:" + password);
        return new User(username, password,
                true, true, true, true,
                AuthorityUtils.commaSeparatedStringToAuthorityList("admin,ROLE_USER"));
    }
}

5.启动主类即可进行访问。

  授权码模式:

  授权码需要访问接口 : http://localhost:8766/oauth/authorize?client_id=wuzzClientId&response_type=code&redirect_uri=http://www.baidu.com&scope=all

  其中 client_id 为认证服务器为每个对接的第三方提供的唯一ID。response_type 返回类型,写死为 code 。redirect_uri 回调地址。

  访问该地址,如果用户当前未登录将会跳转到用户登录页面进行登录。然后将会跳转到下面这个页面。询问用户是否为 wuzzClientId这个应用授权。

   点击授权,将会跳转到回调地址页,由于没有备案域名,这里直接跳到百度:

   可以看到这里后面携带了 一个 code 参数,这个参数就是认证服务器为第三方提供的授权码。然后再用这个授权码去换取 access_token。我这里就用 postman 进行测试:

  换取 access_token得地址为 /oauth/token,首先需要填入认证服务器颁发的 clientId、client-secret

  然后填写参数 ,发送请求。注意这里前三个参数是必填的。

   可以看到这样就可以成功的获取到 access_token 了。然后第三方用户就可以通过这个 token 去资源服务器上获取授权的用户信息了。后续会提到这个token 怎么用。

  密码模式:

  相比授权码授权方式来说,密码模式相对简单,我们只需要修改授权类型,增加 用户名、密码 字段:

 

  细心的小伙伴可能会发现,我这里用的是同一个用户  admin 去获取token,获取到的 access_token、refresh_token 都是一样的 ,唯独 expires_in(过期时间)逐渐减少。这是Oauth 提供的机制。在这个  expires_in 时间内 access_token都是有效的。当然,refresh_token  用于刷新 access_token,避免了用户的频繁认证,刷新token请求如下:

资源服务器 Resource server:

1.配置资源服务器就更简单了,新建一个 Springboot 标准工程,导入与认证服务器一样的依赖,然后定义一个类,打上 @EnableResourceServer 注解,实现 ResourceServerConfigurerAdapter 进行简单配置:

@Configuration
@EnableResourceServer
public class WuzzResourceServerConfig extends ResourceServerConfigurerAdapter {

    @Override
    public void configure(HttpSecurity http) throws Exception {
        //配置受保护的资源
        http.authorizeRequests().antMatchers("/api/order/**").authenticated();
    }
}

2.配置文件:

server.port = 8765
#check_token url
security.oauth2.resource.token-info-uri= http://localhost:8766/oauth/check_token
security.oauth2.resource.prefer-token-info= true
# authorize url
security.oauth2.client.access-token-uri=http://localhost:8766/oauth/authorize
#用户认证地址 check_token
security.oauth2.client.user-authorization-uri=http://localhost:8766/oauth/check_token
security.oauth2.client.client-id=wuzzClientId
security.oauth2.client.client-secret=wuzzSecret

3.提供一个测试接口

@RestController
@RequestMapping("/api/order")
public class OrderController {
@RequestMapping(
"addOrder") public String addOrder(){ return "addOrder"; } }

4.启动服务,当然,你想直接访问这个接口显然是不行的

 

   这个时候我们带上之前获取到的  token ,过期的话重新获取一个:

   这样就实现了资源服务器与认证服务器的打通。

Token 存储:

  OAuth2存储token值的方式由多种,所有的实现方式都是实现了TokenStore接口

  1. InMemoryTokenStore:token存储在本机的内存之中
  2. JdbcTokenStore:token存储在数据库之中
  3. JwtTokenStore:token不会存储到任何介质中
  4. RedisTokenStore:token存储在Redis数据库之中

   这里使用 Redis 进行存储演示:

1.配置 redis :

# Redis服务地址
spring.redis.host=192.168.1.101
# Redis服务端口
spring.redis.port=6379
# Redis 连接密码
spring.redis.password=wuzhenzhao

2.新增Redis连接工厂:

@Configuration
public class TokenStoreConfig {

    @Autowired
    private RedisConnectionFactory redisConnectionFactory;

    @Bean
    public TokenStore redisTokenStore() {
        return new RedisTokenStore(redisConnectionFactory);
    }
}

3.配置,再 WuzzAuthorizationServerConfig 中新增如下配置。

// 自定义token存储类型
@Autowired
private TokenStore tokenStore;

@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {

  endpoints.userDetailsService(userDetailsService()) // 用户信息得服务,一版是都数据库
    .authenticationManager(authenticationManager())// 认证管理器。
    .allowedTokenEndpointRequestMethods(HttpMethod.GET, HttpMethod.POST)
    .tokenStore(tokenStore);
}

4.启动服务并且通过密码授权获取 access_token.然后查看Redis 上的数据变化:

   可以发现 token 已经被存储到了 redis上面,然后我们把认证服务器重启,然后拿着哲哥 access_token 去访问资源服务器,发现依旧可以访问得到。Redis token 配置成功。

JWT 整合:

  JSON Web Token(JWT)是一个开放的行业标准(RFC 7519),它定义了一种自包含、可拓展、密签协议格式,用于在通信双方传递json对象,传递的信息经过数字签名可以被验证和信任。JWT可以使用HMAC算法或使用RSA的公钥/私钥对来签名,防止被篡改。

  JWT 的几个特点

  • JWT 默认是不加密,但也是可以加密的。生成原始 Token 以后,可以用密钥再加密一次。
  • JWT 不加密的情况下,不能将秘密数据写入 JWT。
  • JWT 不仅可以用于认证,也可以用于交换信息。有效使用 JWT,可以降低服务器查询数据库的次数。
  • JWT 的最大缺点是,由于服务器不保存 session 状态,因此无法在使用过程中废止某个 token,或者更改 token 的权限。也就是说,一旦 JWT 签发了,在到期之前就会始终有效,除非服务器部署额外的逻辑。
  • JWT 本身包含了认证信息,一旦泄露,任何人都可以获得该令牌的所有权限。为了减少盗用,JWT 的有效期应该设置得比较短。对于一些比较重要的权限,使用时应该再次对用户进行认证。
  • 为了减少盗用,JWT 不应该使用 HTTP 协议明码传输,要使用 HTTPS 协议传输。

  它是一个很长的字符串,中间用点(.)分隔成三个部分。注意,JWT 内部是没有换行的,这里只是为了便于展示,将它写成了几行。JWT 的三个部分依次如下。

  • Header(头部)
  • Payload(负载)
  • Signature(签名)

   如下就是一个 JWT  :

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJ1c2VyX25hbWUiOiJhZG1pbiIsInNjb3BlIjpbImFsbCJdLCJjb21wYW55IjoiYWxpYmFiYSIsImV4cCI6MTU5NDEwMTA2OSwiYXV0aG9yaXRpZXMiOlsiYWRtaW4iLCJST0xFX1VTRVIiXSwianRpIjoiMzQ4MmM4YmEtYjdmYy00NDIxLWIwZmItYzVhYjhlOGUzYzY2IiwiY2xpZW50X2lkIjoid3V6ekNsaWVudElkIn0.
-DxGM5URWqHOZE5mmH4CgJI_bX-e9THA9WeQeT7Z5qU

  像这个 token 我们可以借助第三方进行解码 : https://www.jsonwebtoken.io/ .通过该网址就可以看到包含的所有信息。

1.注入 Jwt 相关类:

@Configuration
public class TokenStoreConfig {

    @Autowired
    private RedisConnectionFactory redisConnectionFactory;

    @Bean
    @ConditionalOnProperty(prefix = "wuzz", name = "storeType", havingValue = "redis")
    public TokenStore redisTokenStore() {
        return new RedisTokenStore(redisConnectionFactory);
    }

    @Configuration
    @ConditionalOnProperty(prefix = "wuzz", name = "storeType", havingValue = "jwt", matchIfMissing = true)
    public static class JwtTokenConfig {
        //自包含、可拓展、密签
        //https://www.jsonwebtoken.io/  解码
        //{
        // "exp": 1593785308,
        // "user_name": "admin",
        // "authorities": [
        //  "admin",
        //  "ROLE_USER"
        // ],
        // "jti": "e2e5e811-b235-49b8-8678-5bf22e265415",
        // "client_id": "wuzzClientId",
        // "scope": [
        //  "all"
        // ]
        //}
        @Bean// 注入 jwt 存储 token
        public TokenStore jwtTokenStore() {
            return new JwtTokenStore(jwtAccessTokenConverter());
        }

        @Bean// 注入转换器
        public JwtAccessTokenConverter jwtAccessTokenConverter() {
            JwtAccessTokenConverter accessTokenConverter = new JwtAccessTokenConverter();
            accessTokenConverter.setSigningKey("wuzz");//
            return accessTokenConverter;
        }

        @Bean//添加 token 包含信息
        @ConditionalOnMissingBean(name = "jwtTokenEnhancer")
        public TokenEnhancer jwtTokenEnhancer() {
            return new WuzzJwtTokenEnhancer();
        }
    }
}

2.配置文件新增:

wuzz.storeType=jwt

3.在 WuzzAuthorizationServerConfig 中配置:

// 自定义token存储类型
    @Autowired
    private TokenStore tokenStore;

    // jwt token
    @Autowired(required = false)
    private JwtAccessTokenConverter jwtAccessTokenConverter;

    //jwt token 附加信息
    @Autowired(required = false)
    private TokenEnhancer jwtTokenEnhancer;

    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {

        endpoints.userDetailsService(userDetailsService()) // 用户信息得服务,一版是都数据库
                .authenticationManager(authenticationManager())// 认证管理器。
                .allowedTokenEndpointRequestMethods(HttpMethod.GET, HttpMethod.POST)
                .tokenStore(tokenStore);
        if (jwtAccessTokenConverter != null && jwtTokenEnhancer != null) {
            TokenEnhancerChain tokenEnhancerChain = new TokenEnhancerChain();
            List<TokenEnhancer> enhancers = new ArrayList<>();
            enhancers.add(jwtTokenEnhancer);
            enhancers.add(jwtAccessTokenConverter);
            tokenEnhancerChain.setTokenEnhancers(enhancers);

            endpoints.tokenEnhancer(tokenEnhancerChain)
                    .accessTokenConverter(jwtAccessTokenConverter);
        }
    }

4. 自定义 token 附加信息实现:

public class WuzzJwtTokenEnhancer implements TokenEnhancer {

    @Override
    public OAuth2AccessToken enhance(OAuth2AccessToken oAuth2AccessToken, OAuth2Authentication oAuth2Authentication) {

        Map<String, Object> info = new HashMap<String, Object>();
        info.put("company", "alibaba");
        ((DefaultOAuth2AccessToken) oAuth2AccessToken).setAdditionalInformation(info);
        return oAuth2AccessToken;
    }
}

5.启动认证服务器用密码认证方式获取一下 access_token ,发现token已经发生了变化,而且我们在token里增加的属性也显示出来了:

  我们可以通过在资源服务器中写一个解析这个 token的方法:

@RequestMapping(value = "/me", method = {RequestMethod.GET})
    public Object me(Authentication user, HttpServletRequest request) throws UnsupportedEncodingException {
        String header = request.getHeader("Authorization");
        String token = StringUtils.substringAfter(header, "Bearer ");
        Claims claims = Jwts.parser().setSigningKey("wuzz".getBytes("UTF-8")).parseClaimsJws(token).getBody();
        String company = (String) claims.get("company");
        System.out.println(company);
        return user;
    }

   然后请求该接口可以获取到相关的信息。

 

整合 JdbcClientDetailsService :

  在上文中我们讲 client的信息都是写死在配置里面,显然在生产环境下是不合理的,OAuth2 提供了相应的配置。

1.导入依赖:

<dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-jdbc</artifactId>
        </dependency>

2.修改配置:

@Autowired
private DataSource dataSource;

@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
//        clients.inMemory().withClient("wuzzClientId")//客户端得ID,比如我们在QQ互联中心申请得。可以写多个。配置 循环
//                .secret(passwordEncoder().encode("wuzzSecret")) // 客户端密钥,需要进行加密
//                .accessTokenValiditySeconds(7200)// token 有效时常  0 永久有效
//                .authorizedGrantTypes("password", "implicit", "refresh_token", "authorization_code")// 支持得授权类型
//                .redirectUris("http://www.baidu.com")//回调地址
//                .scopes("all", "read", "write");//拥有的 scope  可选
  clients.withClientDetails(new JdbcClientDetailsService(dataSource));
}

3.新增数据库配置:

#解决springboot2.0 后内存数据库H2与actuator不能同时使用报datasource循环依赖
spring.cloud.refresh.refreshable=none
spring.datasource.driver-class-name=com.mysql.jdbc.Driver
spring.datasource.url=jdbc:mysql://192.168.1.101:3306/study?useUnicode=true&characterEncoding=utf-8
spring.datasource.username=root
spring.datasource.password=123456

4.数据库新增对应表,并添加一条数据:

-- ----------------------------
-- Table structure for oauth_client_details
-- ----------------------------
DROP TABLE IF EXISTS `oauth_client_details`;
CREATE TABLE `oauth_client_details`  (
  `client_id` varchar(48) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
  `resource_ids` varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
  `client_secret` varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
  `scope` varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
  `authorized_grant_types` varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
  `web_server_redirect_uri` varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
  `authorities` varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
  `access_token_validity` int(11) NULL DEFAULT NULL,
  `refresh_token_validity` int(11) NULL DEFAULT NULL,
  `additional_information` varchar(4096) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
  `autoapprove` varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
  PRIMARY KEY (`client_id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Compact;

-- ----------------------------
-- Records of oauth_client_details
-- ----------------------------
INSERT INTO `oauth_client_details` VALUES ('wuzzClientId', NULL, '$2a$10$L2juyPBc606/9xkmFWu5S.5PBjfz6IXxtUnl8Bk9B2s9Bbn1TPO.2', 'all', 'password', 'http://www.baidu.com', NULL, NULL, NULL, NULL, NULL);

 5.重启服务,按照原来的方式通过用户名密码进行授权,也是可以实现的。

posted @ 2020-07-07 11:03  吴振照  阅读(479)  评论(0编辑  收藏