Dra-M Dra-M
首页
技术
冥思
哲学
收藏
  • 分类
  • 标签
  • 归档
GitHub (opens new window)

莫小龙

保持理智,相信未来。
首页
技术
冥思
哲学
收藏
  • 分类
  • 标签
  • 归档
GitHub (opens new window)
  • Java

  • Golang

  • 编程思想

  • 微服务

    • SpringCloud

      • SpringBoot RabbitMQ 快速启动
      • Spring Task fixedDelayString不能随着配置中心动态变化的问题
      • 新版SpringCloudSteam+Kafka 更加简单的配置消息收发
    • Kubernetes

    • Oauth2+Gateway+JWT+RSA 解决微服务单点登录问题
    • 中间件

    • Python

    • 运维

    • 技术
    • 微服务
    莫小龙
    2020-04-04
    目录

    Oauth2+Gateway+JWT+RSA 解决微服务单点登录问题

    注意

    2021-01-06日更新:

    本文中同时使用了jwt+redis来解决分布式会话问题,实际应用中,这属于多此一举,jwt的特点就是不依赖服务端做会话管理,如果出于安全考虑需要服务端管理,直接使用redis+session方案即可,不需要使用jwt。

    解决微服务单点登录问题,涉及使用JWT存储令牌,RSA加密,Redis存储短令牌,网关权限控制。 模型: 1585725704958

    # 准备工作

    redis:已安装到192.168.200.128:6379

    mysql:安装到192.168.200.128:3306

    建表:oauth_client_details

    这个表是固定格式的,oauth规定的。

    /*
    SQLyog Ultimate v12.08 (32 bit)
    MySQL - 5.7.26-log : Database - test
    *********************************************************************
    */
    
    
    /*!40101 SET NAMES utf8 */;
    
    /*!40101 SET SQL_MODE=''*/;
    
    /*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;
    /*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
    /*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
    /*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;
    CREATE DATABASE /*!32312 IF NOT EXISTS*/`test` /*!40100 DEFAULT CHARACTER SET utf8 */;
    
    USE `test`;
    
    /*Table structure for table `oauth_client_details` */
    
    DROP TABLE IF EXISTS `oauth_client_details`;
    
    CREATE TABLE `oauth_client_details` (
      `client_id` varchar(48) NOT NULL COMMENT '客户端ID,主要用于标识对应的应用',
      `resource_ids` varchar(256) DEFAULT NULL,
      `client_secret` varchar(256) DEFAULT NULL COMMENT '客户端秘钥,BCryptPasswordEncoder加密',
      `scope` varchar(256) DEFAULT NULL COMMENT '对应的范围',
      `authorized_grant_types` varchar(256) DEFAULT NULL COMMENT '认证模式',
      `web_server_redirect_uri` varchar(256) DEFAULT NULL COMMENT '认证后重定向地址',
      `authorities` varchar(256) DEFAULT NULL,
      `access_token_validity` int(11) DEFAULT NULL COMMENT '令牌有效期',
      `refresh_token_validity` int(11) DEFAULT NULL COMMENT '令牌刷新周期',
      `additional_information` varchar(4096) DEFAULT NULL,
      `autoapprove` varchar(256) DEFAULT NULL,
      PRIMARY KEY (`client_id`) USING BTREE
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8 ROW_FORMAT=DYNAMIC;
    
    /*Data for the table `oauth_client_details` */
    
    insert  into `oauth_client_details`(`client_id`,`resource_ids`,`client_secret`,`scope`,`authorized_grant_types`,`web_server_redirect_uri`,`authorities`,`access_token_validity`,`refresh_token_validity`,`additional_information`,`autoapprove`) values ('dram',NULL,'$2a$10$T5Q3szoTbBw77LgyzdFVou9AocVaNgAlJQwiW7o7JlngwouB3KuaS','app','authorization_code,password,refresh_token,client_credentials','http://localhost',NULL,43200,43200,NULL,NULL);
    
    /*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
    /*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;
    /*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;
    /*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46

    字段详解:

    字段名 字段约束 详细描述 范例
    client_id 主键,必须唯一,不能为空 用于唯一标识每一个客户端(client);注册时必须填写(也可以服务端自动生成),这个字段是必须的,实际应用也有叫app_key dram
    resource_ids 不能为空,用逗号分隔 客户端能访问的资源id集合,注册客户端时,根据实际需要可选择资源id,也可以根据不同的额注册流程,赋予对应的额资源id order-resource,pay-resource
    client_secret 必须填写 注册填写或者服务端自动生成,实际应用也有叫app_secret $2a$10$T5Q3szoTbBw77LgyzdFVou9AocVaNgAlJQwiW7o7JlngwouB3KuaS
    scope 不能为空,用逗号分隔 指定client的权限范围,比如读写权限,比如移动端还是web端权限 read,write / web,mobile
    authorized_grant_types 不能为空 可选值 授权码模式:authorization_code,密码模式:password,刷新token: refresh_token, 隐式模式: implicit: 客户端模式: client_credentials。支持多个用逗号分隔 password,refresh_token
    web_server_redirect_uri 可为空 客户端重定向uri,authorization_code和implicit需要该值进行校验,注册时填写 http:/localhost
    authorities 可为空 指定用户的权限范围,如果授权的过程需要用户登陆,该字段不生效,implicit和client_credentials需要 ROLE_ADMIN,ROLE_USER
    access_token_validity 可空 设置access_token的有效时间(秒),默认(606012,12小时) 3600
    refresh_token_validity 可空 设置refresh_token有效期(秒),默认(606024*30, 30填) 7200
    additional_information 可空 值必须是json格式 {"key", "value"}
    autoapprove false/true/read/write 默认false,适用于authorization_code模式,设置用户是否自动approval操作,设置true跳过用户确认授权操作页面,直接跳到redirect_uri false

    生成AES秘钥对

    keytool -genkeypair -alias dram -keyalg RSA -keypass dram.com -keystore dram.jks -storepass dram.com 
    
    1

    -alias:密钥的别名 -keyalg:使用的hash算法 -keypass:密钥的访问密码 -keystore:密钥库文件名,changgou.jks保存了生成的证书 -storepass:密钥库的访问密码

    用openssl导出公钥

    keytool -list -rfc --keystore dram.jks | openssl x509 -inform pem -pubkey
    
    1

    # 搭建springboot项目

    # Ureka 服务中心

    配置文件:

    server:
      port: 6868
    eureka:
      client:
        register-with-eureka: false #是否将自己注册到eureka中
        fetch-registry: false #是否从eureka中获取信息
        service-url:
          defaultZone: http://127.0.0.1:${server.port}/eureka/
    
    1
    2
    3
    4
    5
    6
    7
    8

    pom:

    <dependency>   
        <groupId>org.springframework.cloud</groupId>    
        <artifactId>spring-cloud-starter-netflix-eureka-server</artifactId>
    </dependency>
    
    1
    2
    3
    4

    # 搭建 Oauth 认证中心服务

    pom依赖:

     <!--MySQL数据库驱动-->
            <dependency>
                <groupId>mysql</groupId>
                <artifactId>mysql-connector-java</artifactId>
            </dependency>
            <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>
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-starter-netflix-hystrix</artifactId>
            </dependency>
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
            </dependency>
    
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-jdbc</artifactId>
            </dependency>
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34

    配置文件:

    *encrypt下的信息和上面生成秘钥时保持一致

    spring:
      application:
        name: auth-service
      datasource:
        url: jdbc:mysql://192.168.200.128:3306/test?serverTimezone=GMT%2B8&useUnicode=true&characterEncoding=utf8&autoReconnect=true&allowMultiQueries=true&useServerPrepStmts=true&cachePrepStmts=true
        username: root
        password: root
      redis:
        host: 192.168.200.128
    server:
      port: 6001
    eureka:
      client:
        service-url:
          defaultZone: http://127.0.0.1:6868/eureka
    encrypt:
      key-store:
        location: classpath:/dram.jks 
        secret: dram.com
        alias: dram
        password: dram.com
    auth:
      ttl: 3600
      clientId: dram
      clientSecret: 1234
      cookieDomain: localhost
      cookieMaxAge: -1
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    # 自定义身份验证转换器,用来在jwt载荷添加自定义信息

    默认的转换器不能携带id,这里自定义了身份转换器,就可以携带自定义的载荷了。

    /**
     * @author by Dragon
     * @Classname aa
     * @Description 自定义身份验证转换器,用来在jwt载荷添加自定义信息
     * @Date 2020/4/3 19:58
     * @Version 1.0
     */
    @Component
    public class MyUserAuthenticationConverter extends DefaultUserAuthenticationConverter {
    
        @Autowired
        UserDetailsService userDetailsServiceImpl;
    
        /**
         * 转换身份验证
         *
         * @param authentication
         * @return
         */
        @Override
        public Map<String, ?> convertUserAuthentication(Authentication authentication) {
            Map<String, Object> response = new LinkedHashMap();
            //添加自定义信息
            response.put("info", "自定义信息");
            String name = authentication.getName();
            //添加用户名
            response.put("username", name);
    
            //如果要获取用户ID可以在UserDetailsService中封装自定义的UserDetail对象,存放ID值
            //可以在这里通过当前身份的主体拿出对象,取出ID设置到结果中
            Object principal = authentication.getPrincipal();
            if (principal instanceof MyUser) {
                String id = ((MyUser) principal).getId();
                response.put("id", id);
            }
    
            //如果权限集合不为空 重新拼装权限集合  如果没有这一步出来的JWT不携带权限信息
            if (authentication.getAuthorities() != null && !authentication.getAuthorities().isEmpty()) {
                response.put("authorities", AuthorityUtils.authorityListToSet(authentication.getAuthorities()));
            }
            return response;
        }
    
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    # JwtTokenConfig 配置jwt令牌

    主要是设置了自定义身份转换器,和读取了私钥做jwt加密。

    *注意要在resources中添加dram.jks文件 这是准备工作中生成的

    @Configuration
    public class JwtTokenConfig {
    
        /**
         *     会读取配置文件封装:
         *     encrypt:
         *       key-store:
         *         location: classpath:/dram.jks
         *         secret: dram.com
         *         alias: dram
         *         password: dram.com
         * @return 秘钥对象
         */
        @Bean(name = "keyProp")
        KeyProperties keyProperties() {
            return new KeyProperties();
        }
    
        @Resource(name = "keyProp")
        private KeyProperties keyProperties;
    
        /**
         * @param jwtAccessTokenConverter 自定义令牌转换器
         * @return 令牌仓库
         */
        @Bean
        public TokenStore jwtTokenStore(JwtAccessTokenConverter jwtAccessTokenConverter) {
            return new JwtTokenStore(jwtAccessTokenConverter);
        }
    
        /**
         * @param myUserAuthenticationConverter 自定义身份验证转换器
         * @return JWT令牌转换器
         */
        @Bean
        public JwtAccessTokenConverter jwtAccessTokenConverter(MyUserAuthenticationConverter myUserAuthenticationConverter) {
            JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
            //读取私钥
            KeyStoreKeyFactory keyStoreKeyFactory = new KeyStoreKeyFactory(
                    keyProperties.getKeyStore().getLocation(),
                    keyProperties.getKeyStore().getSecret().toCharArray()
            );
            KeyPair keyPair = keyStoreKeyFactory.getKeyPair(
                    keyProperties.getKeyStore().getAlias(),
                    keyProperties.getKeyStore().getPassword().toCharArray()
            );
            //设置用RSA私钥加密
            converter.setKeyPair(keyPair);
    
            //设置自定义转换器 用来在jwt载荷添加自定义信息
            DefaultAccessTokenConverter accessTokenConverter = (DefaultAccessTokenConverter) converter.getAccessTokenConverter();
            accessTokenConverter.setUserTokenConverter(myUserAuthenticationConverter);
    
            return converter;
        }
    
    
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    # WebSecurityConfig 继承 WebSecurityConfigurerAdapter

    配置访问规则

    • WebSecurity 全局请求忽略规则配置(比如说静态文件,注册页面)
    • HttpSecurity 具体的权限控制规则配置。一个这个配置相当于xml配置中的一个标签。
    @EnableWebSecurity
    public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    
        @Bean
        public PasswordEncoder passwordEncoder() {
            return new BCryptPasswordEncoder();
        }
    
        @Bean
        @Override
        public AuthenticationManager authenticationManagerBean() throws Exception {
            return super.authenticationManagerBean();
        }
    
    
        @Override
        public void configure(WebSecurity web) throws Exception {
            //忽略权限认证,否则操作将需要验证basic auth的客户端信息
            web.ignoring().antMatchers(
                    "/oauth/login",
                    "/oauth/logout");
        }
    
        /**
         * 允许匿名访问所有接口 主要是 oauth 接口
         *
         * @param http
         * @throws Exception
         */
        @Override
        protected void configure(HttpSecurity http) throws Exception {
            http
                    .requestMatchers().anyRequest()
                    .and()
                    .authorizeRequests()
                    //验证所有/oauth/下的访问
                    .antMatchers("/oauth/*").permitAll();
    
        }
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    • HttpSecurity 各种具体的认证机制的相关配置:
    方法 说明
    openidLogin() 用于基于 OpenId 的验证
    headers() 将安全标头添加到响应
    cors() 配置跨域资源共享( CORS )
    sessionManagement() 允许配置会话管理
    portMapper() 允许配置一个PortMapper(HttpSecurity#(getSharedObject(class))),其他提供SecurityConfigurer的对象使用 PortMapper 从 HTTP 重定向到 HTTPS 或者从 HTTPS 重定向到 HTTP。默认情况下,Spring Security使用一个PortMapperImpl映射 HTTP 端口8080到 HTTPS 端口8443,HTTP 端口80到 HTTPS 端口443
    jee() 配置基于容器的预认证。 在这种情况下,认证由Servlet容器管理
    x509() 配置基于x509的认证
    rememberMe 允许配置“记住我”的验证
    authorizeRequests() 允许基于使用HttpServletRequest限制访问
    requestCache() 允许配置请求缓存
    exceptionHandling() 允许配置错误处理
    securityContext() 在HttpServletRequests之间的SecurityContextHolder上设置SecurityContext的管理。 当使用WebSecurityConfigurerAdapter时,这将自动应用
    servletApi() 将HttpServletRequest方法与在其上找到的值集成到SecurityContext中。 当使用WebSecurityConfigurerAdapter时,这将自动应用
    csrf() 添加 CSRF 支持,使用WebSecurityConfigurerAdapter时,默认启用
    logout() 添加退出登录支持。当使用WebSecurityConfigurerAdapter时,这将自动应用。默认情况是,访问URL”/ logout”,使HTTP Session无效来清除用户,清除已配置的任何#rememberMe()身份验证,清除SecurityContextHolder,然后重定向到”/login?success”
    anonymous() 允许配置匿名用户的表示方法。 当与WebSecurityConfigurerAdapter结合使用时,这将自动应用。 默认情况下,匿名用户将使用org.springframework.security.authentication.AnonymousAuthenticationToken表示,并包含角色 “ROLE_ANONYMOUS”
    formLogin() 指定支持基于表单的身份验证。如果未指定FormLoginConfigurer#loginPage(String),则将生成默认登录页面
    oauth2Login() 根据外部OAuth 2.0或OpenID Connect 1.0提供程序配置身份验证
    requiresChannel() 配置通道安全。为了使该配置有用,必须提供至少一个到所需信道的映射
    httpBasic() 配置 Http Basic 验证
    addFilterAt() 在指定的Filter类的位置添加过滤器
    # OAuth2Config 继承 AuthorizationServerConfigurerAdapter

    重写三个configure

    • AuthorizationServerEndpointsConfigurer 配置授权服务器的Token存储方式、Token配置、授权模式
    • ClientDetailsServiceConfigurer clients 配置客户端认证方式。
    • AuthorizationServerSecurityConfigurer 用来配置令牌端点(Token Endpoint)的安全约束.
    @Configuration
    @EnableAuthorizationServer
    public class OAuth2Config extends AuthorizationServerConfigurerAdapter {
    
        @Autowired
        public PasswordEncoder passwordEncoder;
        @Autowired
        public UserDetailsService userDetailsServiceImpl;
        /**
         * 从WebSecurityConfig中注入
         */
        @Autowired
        private AuthenticationManager authenticationManager;
        @Autowired
        private DataSource dataSource;
        @Autowired
        private TokenStore jwtTokenStore;
        @Autowired
        private JwtAccessTokenConverter jwtAccessTokenConverter;
        @Autowired
        MyUserAuthenticationConverter myUserAuthenticationConverter;
    
    
        @Override
        public void configure(final AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
            endpoints.tokenStore(jwtTokenStore)//配置令牌仓库
                    .userDetailsService(userDetailsServiceImpl)//配置用户加载类
                    .authenticationManager(authenticationManager)//配置身份验证管理器
                    .accessTokenConverter(jwtAccessTokenConverter);//配置令牌转换器
    
    
        }
    
        @Override
        public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
            clients.jdbc(dataSource);//配置客户端验证为数据源模式
        }
    
        @Override
        public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
            security.allowFormAuthenticationForClients();//允许客户端用表单校验
            security.checkTokenAccess("isAuthenticated()");
            security.tokenKeyAccess("isAuthenticated()");
        }
    }
    
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    # 自定义的User类 继承spring提供的User 用于封装自定义字段
    public class MyUser extends User {
        private String id;
    
        public String getId() {
            return id;
        }
    
        public void setId(String id) {
            this.id = id;
        }
    
        public MyUser(String id, String username, String password, Collection<? extends GrantedAuthority> authorities) {
            super(username, password, authorities);
            this.id = id;
        }
    }
    
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    # UserDetailsService

    实现loadUserByUsername 接收一个字符串用户名,返回一个 UserDetails对象

    @Component(value = "userDetailsServiceImpl")
    public class UserDetailsServiceImpl implements UserDetailsService {
        @Override
        public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
            //模拟查询数据库
            String usernameFromDB = "admin";
            String userRoleFromDB = "ROLE_ADMIN";
            //需要一个BCrypt格式的密码
            String userPasswordFromDB = "$2a$10$QJ05fTPm64lUdvF6MKEs3.fI5iBc1Wy459nNK3pUV5NIIA/q9J4A2";
            String userIdFromDB="dddddd";
            if (!username.equals(usernameFromDB)) {
                throw new UsernameNotFoundException("the user is not found");
            } else {
                return new MyUser(userIdFromDB,username, userPasswordFromDB,
                        //逗号分隔的权限列表 如:"ADD,UPDATE,DELETE"
                        AuthorityUtils.commaSeparatedStringToAuthorityList(userRoleFromDB));
            }
        }
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19

    # 测试1:到这里可以直接对OAuth接口测试:

    oauth2密码模式申请令牌的接口:get http://localhost:6001/oauth/token

    需要参数 grant_type=password&username=admin&password=1234

    需要在请求头中携带 BasicAuth 格式是 [Basic 客户端密码:客户端凭证的base64]

    Authorization:Basic ZHJhbToxMjM0 。可以直接在postman中设置:

    1585973954736

    响应结果如下:

    1585976512578

    # 搭建用户登录的入口

    service

    public interface AuthService {
    
        /**
         * 申请令牌
         * @param clientId 客户端id
         * @param clientSecret 客户端凭证
         * @param username 用户名
         * @param password 用户密码
         * @return
         */
        String applyToken(String clientId, String clientSecret, String username, String password);
    }
    
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    @Service
    public class AuthServiceImpl implements AuthService {
        @Autowired
        LoadBalancerClient loadBalancerClient;
        @Autowired
        RestTemplate restTemplate;
        @Autowired
        StringRedisTemplate stringRedisTemplate;
        @Value("${auth.ttl}")
        private Long ttl;
    
        /**
         * 申请令牌
         * @param clientId 客户端id
         * @param clientSecret 客户端凭证
         * @param username 用户名
         * @param password 用户密码
         * @return
         */
        @Override
        public String applyToken(String clientId, String clientSecret, String username, String password) {
    
            //从负载均衡中获取服务,基于熔断器
            ServiceInstance authService = loadBalancerClient.choose("auth-service");
            if (authService == null) {
                throw new RuntimeException("授权服务不在线");
            }
            //拼接OAuth2申请令牌的地址
            String url = authService.getUri() + "/oauth/token";
    
            //封装请求头  客户id和客户凭证 封装成basicAuth格式
            LinkedMultiValueMap<String, String> headers = new LinkedMultiValueMap<>();
            headers.add("Authorization", getBasic(clientId, clientSecret));
    
            //封装请求体 设置授权模式,用户名密码
            MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
            body.add("grant_type", "password"); //授权模式:密码模式
            body.add("username", username); //用户名
            body.add("password", password); //用户密码
            //构建请求对象
            HttpEntity<MultiValueMap<String, String>> httpEntity = new HttpEntity<>(body, headers);
            //发送请求
            restTemplate.setErrorHandler(new DefaultResponseErrorHandler() {
                //400和401响应码不抛出异常
                @Override
                public void handleError(ClientHttpResponse response) throws IOException {
                    if (response.getRawStatusCode() != 400 && response.getRawStatusCode() != 401) {
                        super.handleError(response);
                    }
                }
            });
            //拿到响应信息
            ResponseEntity<Map> response = restTemplate.exchange(url, HttpMethod.POST, httpEntity, Map.class);
    
            //获取响应体
            Map respMap = response.getBody();
            if (respMap == null || respMap.get("access_token") == null || respMap.get("refresh_token") == null || respMap.get("jti") == null) {
                throw new RuntimeException("申请令牌失败");
            }
            //拿到令牌和短令牌
            String access_token = String.valueOf(respMap.get("access_token"));
            String jti = String.valueOf(respMap.get("jti"));
    
            //保存短令牌和全令牌到redis 方便通过短令牌查找长令牌
            stringRedisTemplate.opsForValue().set(jti, access_token, ttl, TimeUnit.SECONDS);
            return jti;
        }
    
    
        /**
         * 获取basic Auth头
         * @param clientId     客户端id
         * @param clientSecret 客户端凭证
         * @return basic Auth
         */
        private String getBasic(String clientId, String clientSecret) {
            String basic = clientId + ":" + clientSecret;
            return "Basic " + Base64Utils.encodeToString(basic.getBytes(Charset.defaultCharset()));
        }
    }
    
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81

    这里主要做了两件事,一个是封装了标准的申请令牌的请求,把配置文件中的客户端凭证封装到basicauth中,

    一个是在申请令牌成功后,把短令牌和全令牌对应关系存到了redis中,只把短令牌返回。

    这样就实现了不把客户端凭证暴露给用户,也不把完整jwt令牌暴露给用户的令牌申请。

    controller

    这个入口我们已经在上面的配置中忽略了权限验证,如果不忽略的话需要携带basicauth头才能访问。

    主要是把用户的短令牌存到了cookies中,后面我们可以拿到短令牌去redis中查对应的全令牌,并在服务转发前拼接到请求头,实现权限信息的传递。

    @Controller
    @RequestMapping("/oauth")
    public class OAuthLoginController {
        @Autowired
        private AuthService authService;
        @Value("${auth.clientId}")
        private String clientId;
        @Value("${auth.clientSecret}")
        private String clientSecret;
        @Value("${auth.cookieDomain}")
        private String cookieDomain;
        @Value("${auth.cookieMaxAge}")
        private Integer cookieMaxAge;
    
        /**
         * 用户登录接口
         *
         * @param username  用户名
         * @param password  密码
         * @param ReturnUrl 登陆成功后跳转的地址
         * @return 重定向到ReturnUrl
         */
        @PostMapping("/login")
        public String login(
                @RequestParam("username") String username,
                @RequestParam("password") String password,
                @RequestParam(name = "ReturnUrl", required = false, defaultValue = "https://dra-m.com") String ReturnUrl) {
            if (StringUtils.isEmpty(username) || StringUtils.isEmpty(password)) {
                return "用户名及密码必须填写";
            }
            //调用登录服务 把短令牌和全令牌存到了redis里 返回短令牌
            String jti = authService.applyToken(clientId, clientSecret, username, password);
    
            //把短令牌存储到用户端cookie,后面会在网关通过cookie中取短令牌,
            //再到redis中取对应的全令牌,在网关转发服务的时候为请求头拼接全令牌
            //这样做是为了在jwt过期前能够强制剔除用户授权信息
            saveJtiToCookie(jti);
    
            //登录成功之后,重定向到ReturnUrl
            return "redirect:" + ReturnUrl;
        }
    
    
        @GetMapping("/login")
        @ResponseBody
        public String login() {
            return "还没有登录页面";
        }
    
        /**
         * 保存jti到cookie
         * @param jti jti
         */
        void saveJtiToCookie(String jti) {
            //拿到当前的响应对象
            ServletRequestAttributes requestAttributes =
                    (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
            HttpServletResponse response = requestAttributes.getResponse();
    
            Cookie cookie = new Cookie("jti", jti);
            cookie.setDomain(cookieDomain);
            cookie.setPath("/");
            cookie.setMaxAge(cookieMaxAge);
            //设置只读
            cookie.setHttpOnly(true);
            response.addCookie(cookie);
        }
    }
    
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69

    # 测试2:到这里我们可以访问该接口,看看在cookies中是不是存了一个jti

    post http://localhost:6001/oauth/login?username=admin&password=1234

    其实这个接口就是把只有username和password的请求,封装成了测试1的样子发送给了oauth接口。

    响应结果:这里请求成功后会跳转到ReturnUrl,如果不填的话会跳转到dra-m.com

    # 搭建资源服务器

    建一个user-service作为资源服务器

    pom:

        <dependencies>
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter</artifactId>
            </dependency>
    
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-starter-oauth2</artifactId>
                <version>2.2.1.RELEASE</version>
            </dependency>
    
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-web</artifactId>
            </dependency>
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-data-redis</artifactId>
            </dependency>
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
                <version>2.2.2.RELEASE</version>
            </dependency>
            <dependency>
                <groupId>com.alibaba</groupId>
                <artifactId>fastjson</artifactId>
                <version>1.2.67</version>
            </dependency>
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30

    yml:

    spring:
      application:
        name: user-service
    server:
      port: 6101
    auth:
      ttl: 3600
    eureka:
      client:
        service-url:
          defaultZone: http://127.0.0.1:6868/eureka
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    # 资源服务器配置类

    这里主要配置了用公钥解密

    *注意,要把准备中导出的公钥文件放在resources下

    @Configuration
    @EnableResourceServer
    @EnableGlobalMethodSecurity(prePostEnabled = true)//开启全局权限验证
    public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
        //公钥
        private static final String PUBLIC_KEY = "pub.key";
    
        @Bean
        public TokenStore jwtTokenStore() {
            return new JwtTokenStore(jwtAccessTokenConverter());
        }
    
        /**
         * 配置用公钥解密
         * @return
         */
        @Bean
        public JwtAccessTokenConverter jwtAccessTokenConverter() {
            JwtAccessTokenConverter accessTokenConverter = new JwtAccessTokenConverter();
            accessTokenConverter.setVerifierKey(getPubKey());
            return accessTokenConverter;
        }
    
        @Autowired
        private TokenStore jwtTokenStore;
    
        @Override
        public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
            resources.tokenStore(jwtTokenStore);//设置令牌仓库
        }
    
    
        /**
         * 获取公钥
         * @return 公钥
         */
        private String getPubKey() {
            Resource resource = new ClassPathResource(PUBLIC_KEY);
            try {
                InputStreamReader inputStreamReader = new InputStreamReader(resource.getInputStream());
                BufferedReader br = new BufferedReader(inputStreamReader);
                return br.lines().collect(Collectors.joining("\n"));
            } catch (IOException ioe) {
                return null;
            }
        }
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47

    自定义jwt解析器

    因为spring提供的authentication不能解析载荷信息,所以我们要自己解析jwt才能拿到载荷。

    @Component
    public class TokenDecode {
        //公钥
        private static final String PUBLIC_KEY = "pub.key";
    
        private static String publickey = "";
    
        /***
         * 获取用户信息
         * @return
         */
        public Map<String, String> getUserInfo() {
            //获取授权信息
            OAuth2AuthenticationDetails details = (OAuth2AuthenticationDetails) SecurityContextHolder.getContext().getAuthentication().getDetails();
            //令牌解码
            return dcodeToken(details.getTokenValue());
        }
    
        /***
         * 读取令牌数据
         */
        public Map<String, String> dcodeToken(String token) {
            //校验Jwt
            Jwt jwt = JwtHelper.decodeAndVerify(token,new RsaVerifier(getPubKey()));
            //获取Jwt原始内容
            String claims = jwt.getClaims();
            return JSON.parseObject(claims, Map.class);
        }
    
    
        /**
         * 获取非对称加密公钥 Key
         * @return 公钥 Key
         */
        public String getPubKey() {
            if (!StringUtils.isEmpty(publickey)) {
                return publickey;
            }
            Resource resource = new ClassPathResource(PUBLIC_KEY);
            try {
                InputStreamReader inputStreamReader = new InputStreamReader(resource.getInputStream());
                BufferedReader br = new BufferedReader(inputStreamReader);
                publickey = br.lines().collect(Collectors.joining("\n"));
                return publickey;
            } catch (IOException ioe) {
                return null;
            }
        }
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49

    controller

    @RestController
    @RequestMapping("/user")
    public class UserController {
    
        @Autowired
        TokenDecode tokenDecode;
    
        @GetMapping("/get")
        @PreAuthorize("hasAnyRole('ROLE_ADMIN')")
        public String get(Authentication authentication) {
            //用Spring提供的权限对象获取当前权限信息中的用户名
            String name = authentication.getName();
            //用自定义的jwt解析 解析当前用户的令牌 拿到自定义信息
            Map<String, String> userInfo = tokenDecode.getUserInfo();
            String info = userInfo.get("info");
            String id = userInfo.get("id");
    
            return String.format("你好:%s,你留下的信息是:%s,你的id是:%s", name, info, id);
        }
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20

    # 测试3:到这里,我们可以通过自己携带oauth2生成的token来访问接口

    用测试1中返回的token,携带到请求头,

    Authorization: Bearer 全令牌(注意空格)。

    也可以让postman生成。

    1585975639006

    响应结果:1585976713198

    # 搭建 Gateway 网关服务

    Oauth2权限控制需要在请求服务的请求头中携带tocken证明自己的身份,可以写一个过滤器封装一个请求头。

    而且我们之前在用户cookies中存入了短令牌,我们去redis把全令牌查询出来,封装到请求头的Authorization: Bearer格式中。

    pom:

    <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-starter-gateway</artifactId>
            </dependency>
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-starter-netflix-hystrix</artifactId>
            </dependency>
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
            </dependency>
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-data-redis-reactive</artifactId>
            </dependency>
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16

    yml:

    spring:
      application:
        name: gateway-service
      cloud:
        gateway:
          globalcors:
            cors-configurations:
              '[/**]': # 匹配所有请求
                allowedOrigins: "*" #跨域处理 允许所有的域
                allowedMethods: # 支持的方法
                  - GET
                  - POST
                  - PUT
                  - DELETE
          routes:
            - id: user_service
              uri: lb://user-service
              predicates:
                - Path=/user/**
            - id: auth-service
              uri: lb://auth-service
              predicates:
                - Path=/oauth/**
      redis:
        host: 192.168.200.128
    server:
      port: 8001
    eureka:
      client:
        service-url:
          defaultZone: http://127.0.0.1:6868/eureka
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31

    权限过滤器:

    这里就是从用户的cookies中取出我们存入的短令牌,再从redis中取出对应的全令牌,

    拼装成测试2的样子发给资源服务器(user-service)

    @Component
    public class AuthFilter implements GlobalFilter, Ordered {
    
        @Autowired
        StringRedisTemplate stringRedisTemplate;
    
        @Override
        public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
            ServerHttpResponse response = exchange.getResponse();
            ServerHttpRequest request = exchange.getRequest();
            //获取请求路径,如果是登录请求就直接放行
            String path = request.getURI().getPath();
            if (path.equals("oauth/login")) {
                return chain.filter(exchange);
            }
            //获取cookie 判断是否有jti
            String jti = request.getCookies().getFirst("jti").getValue();
            if (StringUtils.isEmpty(jti)) {
                //重定向到登录页面,并拼接当前访问地址为
                response.setStatusCode(HttpStatus.SEE_OTHER);
                response.getHeaders().set("Location", "oauth/login?ReturnUrl" + request.getURI());
                return response.setComplete();
            }
            //根据jti查询完整令牌
            String token = getTokenFromRedis(jti);
            if (StringUtils.isEmpty(token)) {
                //如果获取不到 也跳转到登录页面
                response.setStatusCode(HttpStatus.SEE_OTHER);
                response.getHeaders().set("Location", "oauth/login?ReturnUrl" + request.getURI());
                return response.setComplete();
            }
    
            //拼接令牌到请求头 放行到服务
            request.mutate().header("Authorization","bearer "+ token);
            return chain.filter(exchange);
        }
    
        /**
         * 从Redis中拿到完整令牌
         * @param jti 短令牌
         * @return 令牌
         */
        public String getTokenFromRedis(String jti) {
            String token = stringRedisTemplate.opsForValue().get(jti);
            return token;
        }
    
        @Override
        public int getOrder() {
            return 0;
        }
    }
    
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53

    因为我们通过网关直接放行了登录接口,所以现在也可以直接对网关请求测试2中的接口。

    http://localhost:8001/oauth/login?username=admin&password=1234

    # 测试4

    在完成测试2后,已存在cookies的情况下,通过网关访问资源服务器:

    http://localhost:8001/user/get

    响应结果:1585976721348

    # 扩展:服务之间用feign调用

    用feign调用其他资源服务时不带请求头,需要写一个feign拦截器,拼接当前的权限请求头后请求。

    /**
     * 自定义拦截器, 拦截所有请求
     * 每次微服务调用之前都先检查下头文件,将请求的头文件中的令牌数据再放入到header中
     */
    @Component
    public class FeignInterceptor implements RequestInterceptor {
    
        @Override
        public void apply(RequestTemplate requestTemplate) {
    
            RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
    
            if (requestAttributes!=null){
    
                HttpServletRequest request = ((ServletRequestAttributes) requestAttributes).getRequest();
                if (request!=null){
                    Enumeration<String> headerNames = request.getHeaderNames();
                    if (headerNames!=null){
                        while (headerNames.hasMoreElements()){
                            String headerName = headerNames.nextElement();
                            if (headerName.equals("authorization")){
                                String headerValue = request.getHeader(headerName);
                                requestTemplate.header(headerName,headerValue);
                            }
                        }
                    }
                }
            }
            }
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30

    # git

    https://github.com/moxiaolong/oauth2_springcloud


    #Java#SpringCloud#Oauth2#Gateway
    上次更新: 10/23/2024
    K8S远程调用小记
    Spring Data ElasticSearch 快速起步

    ← K8S远程调用小记 Spring Data ElasticSearch 快速起步→

    最近更新
    01
    mosquito配置ws协议
    10-23
    02
    Pip包的离线下载和安装
    10-23
    03
    stable diffusion 相关收藏
    02-24
    更多文章>
    Theme by Vdoing | Copyright © 2019-2024 Dra-M
    • 跟随系统
    • 浅色模式
    • 深色模式
    • 阅读模式