OAuth2授权码、密码模式 + 结合JWT

1,026 阅读10分钟

文章来自 itboyhub.com/

授权码模式

新建 maven 父级工程,然后创建 3 个 springboot 子项目

image.png

auth-server 授权服务器

依赖

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.3.11.RELEASE</version>
    <relativePath/> <!-- lookup parent from repository -->
</parent>
<properties>
    <java.version>1.8</java.version>
    <spring-cloud.version>Hoxton.SR11</spring-cloud.version>
</properties>
<dependencies>
    <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.cloud</groupId>
        <artifactId>spring-cloud-starter-security</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>
<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-dependencies</artifactId>
            <version>${spring-cloud.version}</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

启动端口号设置 8080

新建 SecurityConfig 配置类,配置我们的用户信息

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Bean
    PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.inMemoryAuthentication()
                .withUser("southyin")
                .password(new BCryptPasswordEncoder().encode("123"))
                .roles("admin");
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable().formLogin();
    }
}

新建 AccessTokenConfig 类,配置 token 存放位置,这里先采取内存

@Configuration
public class AccessTokenConfig {

    /**
     * 配置 token 保存在哪
     * @return  InMemoryTokenStore 表示保存在内存中
     */
    @Bean
    TokenStore tokenStore() {
        return new InMemoryTokenStore();
    }
}

新建 AuthorizationServer 类,配置客户端、令牌、授权码等信息

@Configuration
@EnableAuthorizationServer
public class AuthorizationServer  extends AuthorizationServerConfigurerAdapter {
    @Autowired
    TokenStore tokenStore;
    @Autowired
    ClientDetailsService detailsService;

    /**
     * AuthorizationServerSecurityConfigurer 用来配置令牌端点的安全约束,也就是这个端点谁能访
     * 问,谁不能访问。
     * checkTokenAccess 是指一个 Token 校验的端点,这个端点我们设置为可以直接访问
     * (在后面,当资源服务器收到 Token 之后,需要去校验 Token 的合法性,就会访问这个端点)
     * @param security
     * @throws Exception
     */
    @Override
    public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
        security.checkTokenAccess("permitAll()")
                .allowFormAuthenticationForClients();
    }

    /**
     * ClientDetailsServiceConfigurer 用来配置客户端的详细信息
     * 授权服务器要做两方面的检验,一方面是校验客户端,另一方面则是校验用户,校验用户,我们前
     * 面已经配置了,这里就是配置校验客户端。客户端的信息我们可以存在数据库中,这其实也是比较
     * 容易的,和用户信息存到数据库中类似,但是这里为了简化代码,我还是将客户端信息存在内存中
     *
     * 这里我们分别配置了客户端的 id,secret、资源 id、授权类型、授权范围以及重定向 uri。授
     * 权类型我在上篇文章中和大家一共讲了四种,四种之中不包含 refresh_token 这种类型,但是在实
     * 际操作中,refresh_token 也被算作一种
     * @param clients
     * @throws Exception
     */
    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.inMemory()
                .withClient("southyin")
                .secret(new BCryptPasswordEncoder().encode("123"))
                .resourceIds("res1")
                .authorizedGrantTypes("authorization_code","refresh_token")
                .scopes("all")
                .redirectUris("http://localhost:8082/index.html");
    }

    /**
     * AuthorizationServerEndpointsConfigurer 这里用来配置令牌的访问端点和令牌服务。
     *
     * authorizationCodeServices用来配置授权码的存储,这里我们是存在在内存中
     *
     * tokenServices用来配置令牌的存储,即 access_token 的存储位置,这里我们也先存储在内存中
     * 授权码和令牌有什么区别?授权码是用来获取令牌的,使用一次就失效,令牌则是用来获取资源的
     * @param endpoints
     * @throws Exception
     */
    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        endpoints
                .authorizationCodeServices(authorizationCodeServices())
                .tokenServices(tokenServices());
    }

    /**
     *  authorizationCodeServices用来配置授权码的存储,这里我们是存在在内存中
     * @return
     */
    @Bean
    AuthorizationCodeServices authorizationCodeServices() {
        return new InMemoryAuthorizationCodeServices();
    }

    /**
     * tokenServices 这个 Bean 主要用来配置 Token 的一些基本信息,例如 Token 是否支持刷新、
     * Token 的存储位置、Token 的有效期以及刷新 Token 的有效期等等。Token 有效期这个好理解,
     * 刷新 Token 的有效期我说一下,当 Token 快要过期的时候,我们需要获取一个新的 Token,在获
     * 取新的 Token 时候,需要有一个凭证信息,这个凭证信息不是旧的 Token,而是另外一个
     * refresh_token,这个 refresh_token 也是有有效期的
     * @return
     */
    @Bean
    AuthorizationServerTokenServices tokenServices() {
        DefaultTokenServices services = new DefaultTokenServices();
        services.setClientDetailsService(detailsService);
        services.setSupportRefreshToken(true);
        services.setTokenStore(tokenStore);
        services.setAccessTokenValiditySeconds(60 * 60 * 2);
        services.setRefreshTokenValiditySeconds(60 * 60 * 24 * 3);
        return services;
    }
}

启动

user-server 资源服务器

依赖

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.3.11.RELEASE</version>
    <relativePath/> <!-- lookup parent from repository -->
</parent>
<properties>
    <java.version>1.8</java.version>
    <spring-cloud.version>Hoxton.SR11</spring-cloud.version>
</properties>
<dependencies>
    <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.cloud</groupId>
        <artifactId>spring-cloud-starter-security</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>
<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-dependencies</artifactId>
            <version>${spring-cloud.version}</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

启动端口号 8081

创建 ResourceServerConfig 类,配置资源服务器信息

@Configuration
@EnableResourceServer
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
    /**
     * tokenServices 我们配置了一个 RemoteTokenServices 的实例,这是因为资源服务器和授权服务
     * 器是分开的,资源服务器和授权服务器是放在一起的,就不需要配置 RemoteTokenServices 了
     * 
     * RemoteTokenServices 中我们配置了 access_token 的校验地址、client_id、client_secret 这三个
     * 信息,当用户来资源服务器请求资源时,会携带上一个 access_token,通过这里的配置,就能够
     * 校验出 token 是否正确等。
     * @return
     */
    @Bean
    RemoteTokenServices tokenServices() {
        RemoteTokenServices services = new RemoteTokenServices();
        services.setCheckTokenEndpointUrl("http://localhost:8080/oauth/check_token");
        services.setClientId("southyin");
        services.setClientSecret("123");
        return services;
    }

    @Override
    public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
        resources.resourceId("res1").tokenServices(tokenServices());
    }

    @Override
    public void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .antMatchers("/admin/**").hasRole("admin")
                .anyRequest().authenticated();
    }
}

创建资源接口

@RestController
public class HelloController {
    @GetMapping("/hello")
    public String hello1() {
        return "/hello";
    }

    @GetMapping("/admin/hello")
    public String hello2() {
        return "/admin/hello";
    }
}

启动

client-app 第三方应用

这个项目不是必须的,也可以利用 postman 来测,依赖

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-thymeleaf</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

启动端口号 8082 ,templates 文件夹下创建 index.html 模拟第三方网站页面

<!DOCTYPE html>
<html lang="en" xmlns:th="http//www/thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
    <a href="http://localhost:8080/oauth/authorize?client_id=southyin&response_type=code&scope=all&redirect_uri=http://localhost:8082/index.html">
        第三方登录
    </a>
    <h1 th:text="${msg}"></h1>
</body>
</html>

启动类

@SpringBootApplication
public class ClientAppApplication {

    public static void main(String[] args) {
        SpringApplication.run(ClientAppApplication.class, args);
    }

    @Bean
    RestTemplate restTemplate() {
        return new RestTemplate();
    }
}

访问页面接口

@Controller
public class HelloController {

    @Autowired
    RestTemplate restTemplate;

    @GetMapping("/index.html")
    public String hello(String code, Model model) {
        if (code != null) {
            MultiValueMap<String, String> map = new LinkedMultiValueMap<>();
            map.add("client_id", "southyin");
            map.add("client_secret", "123");
            map.add("redirect_uri", "http://localhost:8082/index.html");
            map.add("grant_type", "authorization_code");
            Map<String, String> resp = restTemplate.postForObject("http://localhost:8080/oauth/token", map, Map.class);
            String access_token = resp.get("access_token");
            System.out.println(access_token);
            HttpHeaders headers = new HttpHeaders();
            headers.add("Authorization", "Bearer " + access_token);
            HttpEntity<Object> httpEntity = new HttpEntity<>(headers);
            ResponseEntity<String> entity = restTemplate.exchange("http://localhost:8081/admin/hello", HttpMethod.GET, httpEntity, String.class);
            model.addAttribute("msg", entity.getBody());
        }
        return "index";
    }

}

参数中没有 code 则直接返回 index页面,若有 code(这里 code 是授权码,并不是 token),则向 授权服务器 申请 token,然后拿着返回的 access_token 再向 资源服务器 申请资源,然后放到 Model 中,返回给 index页面index页面 拿到资源信息,在页面显示,启动测试

  • 1.访问 http://localhost:8082/index.html

image.png

  • 2.点击超链接,登录用户申请授权,用户名和密码是之前在 授权服务器 中配置的 southyin123

image.png

image.png

image.png

密码模式

密码模式要求高度信任第三方,比如授权方和第三方都是自己的应用,是可以的

auth-server 授权服务器

授权服务器 进行改造,使其支持 password 模式

@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
    clients.inMemory()
            .withClient("southyin")
            .secret(new BCryptPasswordEncoder().encode("123"))
            .resourceIds("res1")
            .authorizedGrantTypes("authorization_code","password","refresh_token")
            .scopes("all")
            .redirectUris("http://localhost:8082/index.html");
}

这里其他地方都不变,主要是在 authorizedGrantTypes 中增加了 password 模式

由于使用了 password 模式之后,用户要进行登录,所以我们需要配置一个 AuthenticationManager, 还是在 AuthorizationServer 类中,具体配置如下

@Autowired
AuthenticationManager authenticationManager;

@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws
Exception {
    endpoints
           .authenticationManager(authenticationManager)
           .tokenServices(tokenServices());
}

注意,在授权码模式中,我们配置的 AuthorizationCodeServices 现在不需要了,取而代之的是 authenticationManager

SecurityConfig 中添加 AuthenticationManager 对象

client-app 第三方应用

修改 index.html

<!DOCTYPE html>
<html lang="en" xmlns:th="http//www/thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<form action="/login" method="post"> 
    <table>       
        <tr>         
            <td>用户名:</td>       
            <td><input name="username"></td>       
        </tr>       
        <tr>           
            <td>密码:</td>           
            <td><input name="password"></td>       
        </tr>
        <tr>
            <td><input type="submit" value="登录"></td>
        </tr>
    </table>
</form>
<h1 th:text="${msg}"></h1>
</body>
</html>

登录接口 /login,Post 请求

@Controller
public class HelloController {
    @Autowired
    RestTemplate restTemplate;

    @GetMapping("/index.html")
    public String index() {
        return "index";
    }
    
    @PostMapping("/login")
    public String hello(String username,String password, Model model) {
        MultiValueMap<String, String> map = new LinkedMultiValueMap<>();
        map.add("username", username);
        map.add("password", password);
        map.add("client_secret", "123");
        map.add("client_id", "southyin");
        map.add("grant_type", "password");
        Map<String,String> resp = restTemplate.postForObject("http://localhost:8080/oauth/token", map, Map.class);
        String access_token = resp.get("access_token");
        HttpHeaders headers = new HttpHeaders();
        headers.add("Authorization", "Bearer " + access_token);
        HttpEntity<Object> httpEntity = new HttpEntity<>(headers);
        ResponseEntity<String> entity = restTemplate.exchange("http://localhost:8081/admin/hello", HttpMethod.GET,httpEntity, String.class);
        model.addAttribute("msg", entity.getBody());
        return "index";
    }
}

在登录接口中,当收到一个用户名密码之后,我们通过 RestTemplate 发送一个 post 请求,注意 post 请求中,grant_type 参数的值为 password,通过这个请求,我们可以获取 auth-server 返回的 access_token

配置完成后,启动 client-app,访问 http://localhost:8082/index.html 页面进行测试。授权完成后,我们在项目首页可以看到如下内容

image.png

image.png

刷新token

以之前的 password 模式为例,使用 postman 测试

localhost:8080/oauth/token?client_id=southyin&client_secret=123&grant_type=password&username=southyin&password=123

得到数据如下

{
    "access_token": "3d3b5333-67fa-41b2-934a-0063a0f3eb8c",
    "token_type": "bearer",
    "refresh_token": "b0fb6c4e-31b2-4dd5-b049-3bbd15e2890b",
    "expires_in": 7199,
    "scope": "all"
}

/oauth/token 端点除了颁发令牌,还可以用来刷新令牌,这个 refresh_token 就是用来刷新令牌用的

再次 postman 发送请求

localhost:8080/oauth/token?client_id=southyin&client_secret=123&grant_type=refresh_token&refresh_token=b0fb6c4e-31b2-4dd5-b049-3bbd15e2890b

得到新的 token

{
    "access_token": "d5e88e07-0c28-4677-9f28-6f0e7c6f7e63",
    "token_type": "bearer",
    "refresh_token": "b0fb6c4e-31b2-4dd5-b049-3bbd15e2890b",
    "expires_in": 7199,
    "scope": "all"
}

之前的 token 就会失效

令牌存储

在我们配置授权码模式的时候,有两个东西当时存在了内存中:

  • InMemoryAuthorizationCodeServices 这个表授权码存在内存中。
  • InMemoryTokenStore 表示生成的令牌存在内存中

授权码用过一次就会失效,存在内存中没什么问题,但是令牌,还有其他的存储方案

InMemoryTokenStore 实现了 TokenStore 接口,我们来看下 TokenStore 接口的实现 类:

image.png

  1. InMemoryTokenStore,这是我们之前使用的,也是系统默认的,就是将 access_token 存到内存中,单机应用这个没有问题,但是在分布式环境下不推荐

  2. JdbcTokenStore,这种方式令牌会被保存到数据中,这样就可以方便的和其他应用共享令牌信息

  3. JwtTokenStore,这个其实不是存储,因为使用了 jwt 之后,在生成的 jwt 中就有用户的所有信息,服务端不需要保存,这也是无状态登录

  4. RedisTokenStore,这个很明显就是将 access_token 存到 redis 中。

  5. JwkTokenStore,将 access_token 保存到 JSON Web Key

其中 RedisTokenStoreJwtTokenStore 是常见的使用方案

RedisTokenStore 方案

首先我们启动一个 Redis 服务,然后给 auth-server 添加 Redis 依赖:

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

依赖添加成功后,在 application.properties 中添加 redis 配置

spring.redis.host=127.0.0.1
spring.redis.port=6379
spring.redis.password=12345

配置完成后,我们修改 TokenStore 的实例

@Configuration
public class AccessTokenConfig {
    @Autowired
    RedisConnectionFactory redisConnectionFactory;
    @Bean
    TokenStore tokenStore() {
        return new RedisTokenStore(redisConnectionFactory);
   }
}

然后分别启动 auth-serverclient-app 以及 user-server,走一遍第三方登录流程,然后我们发现,派发的 access_tokenredis 中也有一份

image.png

access_token 这个 keyRedis 中的有效期就是授权码的有效期。正是因为 Redis 中的这种过期机制,让它在存储 access_token 时具有天然的优势

Jwt方案

放后面

客户端信息入库

前面客户端信息我们是直接存储在内存中的

@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
    clients.inMemory()
           .withClient("southyin")
           .secret(new BCryptPasswordEncoder().encode("123"))
           .resourceIds("res1")
           .authorizedGrantTypes("authorization_code","refresh_token")
           .scopes("all")
           .redirectUris("http://localhost:8082/index.html");
}

在实际项目中,这种方式并不可取,一来客户端信息在代码中写死了,以后不好维护,而来我们的客户端信息可能量非常大,比如微信,有大量的第三方应用都接入了微信登录,微信不可能把所有的客户端信息都写死在代码中,所以我们要将客户端信息存入数据库中

客户端信息入库涉及到的接口主要是 ClientDetailsService,这个接口主要有两个实现类

image.png

InMemoryClientDetailsService 是存在内存中的。如果要存入数据库,很明显是 JdbcClientDetailsService ,来大概看下 JdbcClientDetailsService 的源码,就能分析出数据库的结构

public class JdbcClientDetailsService implements ClientDetailsService, 
ClientRegistrationService {
 private static final String CLIENT_FIELDS_FOR_UPDATE = "resource_ids, scope, 
"
 + "authorized_grant_types, web_server_redirect_uri, authorities, 
access_token_validity, "
 + "refresh_token_validity, additional_information, autoapprove";
 private static final String CLIENT_FIELDS = "client_secret, " +
CLIENT_FIELDS_FOR_UPDATE;
 private static final String BASE_FIND_STATEMENT = "select client_id, " +
CLIENT_FIELDS
 + " from oauth_client_details";
 private static final String DEFAULT_FIND_STATEMENT = BASE_FIND_STATEMENT + " 
order by client_id";
 private static final String DEFAULT_SELECT_STATEMENT = BASE_FIND_STATEMENT +
" where client_id = ?";
 private static final String DEFAULT_INSERT_STATEMENT = "insert into 
oauth_client_details (" + CLIENT_FIELDS
 + ", client_id) values (?,?,?,?,?,?,?,?,?,?,?)";
 private static final String DEFAULT_UPDATE_STATEMENT = "update 
oauth_client_details " + "set "
 + CLIENT_FIELDS_FOR_UPDATE.replaceAll(", ", "=?, ") + "=? where 
client_id = ?";
 private static final String DEFAULT_UPDATE_SECRET_STATEMENT = "update 
oauth_client_details "
 + "set client_secret = ? where client_id = ?";
 private static final String DEFAULT_DELETE_STATEMENT = "delete from 
oauth_client_details where client_id = ?";

分析得到表结构脚本

DROP TABLE IF EXISTS `oauth_client_details`;
CREATE TABLE `oauth_client_details` (
 `client_id` varchar(48) NOT NULL,
 `resource_ids` varchar(256) DEFAULT NULL,
 `client_secret` varchar(256) DEFAULT NULL,
 `scope` varchar(256) DEFAULT NULL,
 `authorized_grant_types` varchar(256) DEFAULT NULL,
 `web_server_redirect_uri` varchar(256) DEFAULT NULL,
 `authorities` varchar(256) DEFAULT NULL,
 `access_token_validity` int(11) DEFAULT NULL,
 `refresh_token_validity` int(11) DEFAULT NULL,
 `additional_information` varchar(4096) DEFAULT NULL,
 `autoapprove` varchar(256) DEFAULT NULL,
 PRIMARY KEY (`client_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

表中添加客户端信息

image.png

既然用到了数据库,依赖当然也要提供相应的支持,我们给 auth-server 添加如下依赖

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <version>5.1.47</version>
</dependency>
spring.datasource.driver-class-name=com.mysql.jdbc.Driver
spring.datasource.url=jdbc:mysql:///userserver?useUnicode=true&characterEncoding=UTF-8&useSSL=true
spring.datasource.password=root
spring.datasource.username=root
spring.main.allow-bean-definition-overriding=true

这里的配置多了最后一条,这是因为一会要创建自己的 ClientDetailsService,而系统已经创建了 ClientDetailsService,加了最后一条就允许我们自己的实例覆盖系统默认的实例

接下来,提供自己的实例即可

@Autowired
DataSource dataSource;
@Bean
ClientDetailsService clientDetailsService() {
    return new JdbcClientDetailsService(dataSource);
}
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
    clients.withClientDetails(clientDetailsService());
}

配置完成后,重启 auth-server,走一遍第三方登录流程,和前面效果一样

也可以将令牌有效期配置在数据库中,这样就不用在代码中配置了

image.png

授权码模式下,修改后的 AuthorizationServerTokenServices 实例:

@Bean
AuthorizationServerTokenServices tokenServices() {
    DefaultTokenServices services = new DefaultTokenServices();
    services.setClientDetailsService(clientDetailsService());
    services.setSupportRefreshToken(true);
    services.setTokenStore(tokenStore);
    return services;
}

client-app 第三方应用优化

首先定义一个专门的类 TokenTask 用来解决 Token 的管理问题

@Component
public class TokenTask {
    @Autowired
    RestTemplate restTemplate;
    public String access_token = "";
    public String refresh_token = "";
    public String getData(String code) {
        if ("".equals(access_token) && code != null) {
            MultiValueMap<String, String> map = new LinkedMultiValueMap<>();
            map.add("code", code);
            map.add("client_id", "southyin");
            map.add("client_secret", "123");
            map.add("redirect_uri", "http://localhost:8082/index.html");
            map.add("grant_type", "authorization_code,password");
            Map<String, String> resp =
restTemplate.postForObject("http://localhost:8080/oauth/token", map, Map.class);
            access_token = resp.get("access_token");
            refresh_token = resp.get("refresh_token");
            return loadDataFromResServer();
       } else {
            return loadDataFromResServer();
       }
   }
   
    private String loadDataFromResServer() {
        try {
            HttpHeaders headers = new HttpHeaders();
            headers.add("Authorization", "Bearer " + access_token);
            HttpEntity<Object> httpEntity = new HttpEntity<>(headers);
            ResponseEntity<String> entity =
restTemplate.exchange("http://localhost:8081/admin/hello", HttpMethod.GET, 
httpEntity, String.class);
            return entity.getBody();
       } catch (RestClientException e) {
            return "未加载";
       }
   }
   
    @Scheduled(cron = "0 55 0/1 * * ?")
    public void tokenTask() {
        MultiValueMap<String, String> map = new LinkedMultiValueMap<>();
        map.add("client_id", "southyin");
        map.add("client_secret", "123");
        map.add("refresh_token", refresh_token);
        map.add("grant_type", "refresh_token");
        Map<String, String> resp =
restTemplate.postForObject("http://localhost:8080/oauth/token", map, Map.class);
        access_token = resp.get("access_token");
        refresh_token = resp.get("refresh_token");
   }
}
    1. 首先在 getData 方法中,如果 access_token 为空字符串,并且 code 不为 null,表示这是刚刚拿到授权码的时候,准备去申请令牌了,令牌拿到之后,将 access_tokenrefresh_token 分别赋值给全局变量,然后调用 loadDataFromResServer 方法去资源服务器加载数据
    1. 另外有一个 tokenTask 方法,这是一个定时任务,每隔 115 分钟去刷新一下 access_token (access_token 有效期是 120 分钟)

改造完成后,再去 HelloController 中调整

@Controller
public class HelloController {
    @Autowired
    TokenTask tokenTask;
    @GetMapping("/index.html")
    public String hello(String code, Model model) {
        model.addAttribute("msg", tokenTask.getData(code));
        return "index";
   }
}

这样再去 index.html 刷新就不会报错了

OAuth2 + JWT

JWT 包含三部分数据:

1.Header:头部,通常头部有两部分信息:

  • 声明类型,这里是JWT
  • 加密算法,自定义

我们会对头部进行 Base64Url 编码(可解码),得到第一部分数据

2.Payload:载荷,就是有效数据,在官方文档中(RFC7519),这里给了 7 个示例信息:

  • iss (issuer):表示签发人
  • exp (expiration time):表示token过期时间
  • sub (subject):主题
  • aud (audience):受众
  • nbf (Not Before):生效时间
  • iat (Issued At):签发时间
  • jti (JWT ID):编号 这部分也会采用 Base64Url 编码,得到第二部分数据

3.Signature:签名,是整个数据的认证信息。一般根据前两步的数据,再加上服务的的密钥 secret(密钥保存在服务端,不能泄露给客户端),通过 Header 中配置的加密算法生成。用于验证整 个数据完整和可靠性

改造 AccessTokenConfig

@Configuration
public class AccessTokenConfig {
    private String SIGNING_KEY = "southyin";
    
    @Bean
    TokenStore tokenStore() {
        return new JwtTokenStore(tokenConverter());
    }

    @Bean
    JwtAccessTokenConverter tokenConverter() {
        JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
        converter.setSigningKey(SIGNING_KEY);
        return converter;
    }
}
  1. TokenStore 使用 JwtTokenStore 这个实例。之前将 access_token 无论是存储在内存中,还是存储在 Redis 中,都是要存下来的,客户端将 access_token 发来之后,还要校验看对不对。但是如果使用了 JWTaccess_token 实际上就不用存储了(无状态登录,服务端不需要保存信息),因为用户的所有信息都在 jwt 里边,所以这里配置的 JwtTokenStore 本质上并不是做存储

  2. JwtAccessTokenConverter 可以实现将用户信息和 JWT 进行转换(将用户信息转为 jwt 字符串,或者从 jwt 字符串提取出用户信息)

  3. JWT 字符串生成的时候,我们需要一个签名,这个签名需要自己保存好

JWT 默认生成的用户信息主要是用户角色、用户名等,如果我们希望在生成的 JWT 上面添加额外 的信息,可以按照如下方式添加

@Component
public class CustomAdditionalInformation implements TokenEnhancer {
    @Override
    public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, OAuth2Authentication  authentication) {
        Map<String, Object> map = accessToken.getAdditionalInformation();
        map.put("author", "southyin");
        ((DefaultOAuth2AccessToken) accessToken).setAdditionalInformation(map);
        return accessToken;
    }
}

自定义类 CustomAdditionalInformation 实现 TokenEnhancer 接口,并实现接口中的 enhance 方法。enhance 方法中的 OAuth2AccessToken 参数就是已经生成的 access_token 信息,我们可以从OAuth2AccessToken 中取出已经生成的额外信息,然后在此基础上追加自己的信息,之前配置的 JwtAccessTokenConverter 也是 TokenEnhancer 的一个实例

还需要在 AuthorizationServer 中修改 AuthorizationServerTokenServices 实例

@Autowired
JwtAccessTokenConverter jwtAccessTokenConverter;
@Autowired
CustomAdditionalInformation customAdditionalInformation;
@Bean
AuthorizationServerTokenServices tokenServices() {
    DefaultTokenServices services = new DefaultTokenServices();
    services.setClientDetailsService(clientDetailsService());
    services.setSupportRefreshToken(true);
    services.setTokenStore(tokenStore);
    TokenEnhancerChain tokenEnhancerChain = new TokenEnhancerChain();
    tokenEnhancerChain.setTokenEnhancers(Arrays.asList(jwtAccessTokenConverter, 
customAdditionalInformation));
    services.setTokenEnhancer(tokenEnhancerChain);
    return services;
}

这里主要是是在 DefaultTokenServices 中配置 TokenEnhancer,将之前的 JwtAccessTokenConverterCustomAdditionalInformation 两个实例注入进来即可

资源服务器改造

也就是 user-serverauth-server 中的 AccessTokenConfig 类拷贝到 user-server,然后在资源服务器配置中不再配置远程校验地址,而是配置一个 TokenStore 即可

@Configuration
@EnableResourceServer
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
    @Autowired
    TokenStore tokenStore;
    @Override
    public void configure(ResourceServerSecurityConfigurer resources) throws
Exception {
        resources.resourceId("res1").tokenStore(tokenStore);
   }
    @Override
    public void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
               .antMatchers("/admin/**").hasRole("admin")
               .anyRequest().authenticated();
   }
}

测试,这里采取了 password 模式测试 localhost:8080/oauth/token?client_id=southyin&client_secret=123&grant_type=password&username=southyin&password=123

image.png

拿返回的 access_tokenlocalhost:8080/oauth/check_token 解析看一看就可以看到 jwt 中保存的用户详细信息了。

image.png

同时也可以拿着 access_token 去资源服务器访问了

image.png