OAuth 2.0 如果只停留在授权码、Access Token、Refresh Token 等概念层面,很容易出现“流程看懂了,真正写代码还是不会”的情况。本文以 Spring Boot、Spring Security OAuth 与 JWT 为基础,完整搭建 Authorization Server、Resource Server 和 OAuth Client 三个应用,从数据库、JWT 非对称签名、授权码存储、Scope 与 Authority 权限控制,到 Password、Client Credentials、Authorization Code 三种流程、SSO 和远程资源调用,给出一套可以顺着代码真正跑通 OAuth 2.0 的工程实践。
先说明版本:这是一个经典 Spring Security OAuth 实战 本文重点是把 OAuth 2.0 的三个角色真正落到代码中,因此采用资料中的经典技术栈:
1 2 3 4 5 6 7 Java 8 Spring Boot 2.2.1.RELEASE Spring Cloud Greenwich.SR4 Spring Security OAuth MySQL JWT Maven
核心组件是:
1 2 3 4 <dependency > <groupId > org.springframework.cloud</groupId > <artifactId > spring-cloud-starter-oauth2</artifactId > </dependency >
它可以非常直观地展示:
Authorization Server 如何签发 Token;
Resource Server 如何校验 Token;
OAuth Client 如何自动完成授权码流程;
JWT 如何进行非对称签名;
用户权限如何映射到 Resource Server;
Authorization Code 如何存储和消费;
Client Credentials 与用户授权有什么区别;
OAuth 如何实现 SSO。
资料中的完整示例本身也是围绕 Authorization Server、Resource Server 和 Client 三个模块展开,并分别演示三种 Grant、方法级权限控制、SSO 以及 OAuth2RestTemplate 自动调用资源。
这套 API 主要适合学习经典 Spring Security OAuth 的实现模型。文章的重点是理解 OAuth 2.0 怎样真正落到 Spring Security 代码,而不是把旧 API 当成所有新项目的唯一技术选型。
最终要搭建什么 创建一个 Maven 父工程:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 spring-security-oauth2-demo ├── pom.xml │ ├── oauth2-server │ ├── Authorization Server │ ├── 用户认证 │ ├── Client 管理 │ ├── Authorization Code │ └── JWT Token 签发 │ ├── oauth2-resource-server │ ├── Resource Server │ ├── JWT 公钥验签 │ └── API 权限控制 │ └── oauth2-client ├── OAuth Client ├── Authorization Code ├── SSO └── OAuth2RestTemplate
三个应用分别监听:
1 2 3 4 Authorization Server : 8080 Resource Server : 8081 OAuth Client A : 8082 OAuth Client B : 8083
整体关系如下:
flowchart LR
U[用户浏览器]
C[OAuth Client<br/>8082 / 8083]
AS[Authorization Server<br/>8080]
RS[Resource Server<br/>8081]
DB[(OAuth Database)]
U --> C
C -->|Authorization Request| AS
AS --> DB
AS -->|Authorization Code| C
C -->|Code 换 Token| AS
AS -->|JWT Access Token| C
C -->|Bearer JWT| RS
RS -->|Public Key Verify| RS
其中:
1 2 3 4 用户 = Resource Owner oauth2-client = Client oauth2-server = Authorization Server oauth2-resource = Resource Server
OAuth 2.0 实战真正要完成的链路 最终我们需要打通:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 用户 ↓ OAuth Client ↓ Authorization Server ↓ Authorization Code ↓ OAuth Client Backend ↓ Access Token ↓ Resource Server ↓ 业务 API
Authorization Code 流程:
sequenceDiagram
autonumber
actor U as 用户
participant C as OAuth Client
participant AS as Authorization Server
participant RS as Resource Server
U->>C: 请求安全页面
C-->>U: 重定向 Authorization Server
U->>AS: GET /oauth/authorize
AS-->>U: 登录页面
U->>AS: 用户名 + 密码
AS-->>U: Consent 授权确认
U->>AS: 同意授权
AS-->>C: redirect_uri?code=xxx
C->>AS: POST /oauth/token<br/>code + client_id + client_secret
AS-->>C: access_token + refresh_token
C->>RS: Authorization: Bearer token
RS-->>C: Protected Resource
下面开始真正搭项目。
创建父 Maven 工程 根目录创建:
1 spring-security-oauth2-demo/
父 pom.xml:
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 <?xml version="1.0" encoding="UTF-8" ?> <project xmlns ="http://maven.apache.org/POM/4.0.0" xmlns:xsi ="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation =" http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd" > <modelVersion > 4.0.0</modelVersion > <groupId > com.example</groupId > <artifactId > spring-security-oauth2-demo</artifactId > <version > 1.0.0-SNAPSHOT</version > <packaging > pom</packaging > <parent > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-parent</artifactId > <version > 2.2.1.RELEASE</version > <relativePath /> </parent > <modules > <module > oauth2-server</module > <module > oauth2-resource-server</module > <module > oauth2-client</module > </modules > <properties > <java.version > 1.8</java.version > <project.build.sourceEncoding > UTF-8</project.build.sourceEncoding > <project.reporting.outputEncoding > UTF-8</project.reporting.outputEncoding > </properties > <dependencies > <dependency > <groupId > org.projectlombok</groupId > <artifactId > lombok</artifactId > <optional > true</optional > </dependency > </dependencies > <dependencyManagement > <dependencies > <dependency > <groupId > org.springframework.cloud</groupId > <artifactId > spring-cloud-dependencies</artifactId > <version > Greenwich.SR4</version > <type > pom</type > <scope > import</scope > </dependency > </dependencies > </dependencyManagement > <build > <plugins > <plugin > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-maven-plugin</artifactId > </plugin > </plugins > </build > </project >
数据库准备 创建数据库 1 2 3 4 5 CREATE DATABASE oauth DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE oauth;
经典 Spring Security OAuth JDBC 模型会用到:
1 2 3 4 5 users authorities oauth_client_details oauth_code oauth_approvals
资料中的项目也是围绕这 5 张表保存用户、权限、Client、Authorization Code 与授权确认状态。JWT 本身不需要存入 Token 表,因为 Resource Server 可以利用公钥在本地验证 JWT。
users:用户账号 1 2 3 4 5 6 7 8 9 DROP TABLE IF EXISTS users;CREATE TABLE users ( username VARCHAR (50 ) NOT NULL , password VARCHAR (100 ) NOT NULL , enabled TINYINT(1 ) NOT NULL , PRIMARY KEY (username) ) ENGINE= InnoDB DEFAULT CHARSET= utf8mb4;
authorities:用户权限 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 DROP TABLE IF EXISTS authorities;CREATE TABLE authorities ( username VARCHAR (50 ) NOT NULL , authority VARCHAR (50 ) NOT NULL , UNIQUE KEY ix_auth_username ( username, authority ), CONSTRAINT fk_authorities_users FOREIGN KEY (username) REFERENCES users(username) ) ENGINE= InnoDB DEFAULT CHARSET= utf8mb4;
oauth_client_details:OAuth Client 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 DROP TABLE IF EXISTS oauth_client_details;CREATE TABLE oauth_client_details ( client_id VARCHAR (255 ) NOT NULL , resource_ids VARCHAR (255 ) DEFAULT NULL , client_secret VARCHAR (255 ) DEFAULT NULL , scope VARCHAR (255 ) DEFAULT NULL , authorized_grant_types VARCHAR (255 ) DEFAULT NULL , web_server_redirect_uri VARCHAR (255 ) DEFAULT NULL , authorities VARCHAR (255 ) DEFAULT NULL , access_token_validity INT DEFAULT NULL , refresh_token_validity INT DEFAULT NULL , additional_information VARCHAR (4096 ) DEFAULT NULL , autoapprove VARCHAR (255 ) DEFAULT NULL , PRIMARY KEY (client_id) ) ENGINE= InnoDB DEFAULT CHARSET= utf8mb4;
oauth_code:Authorization Code 1 2 3 4 5 6 7 8 9 10 DROP TABLE IF EXISTS oauth_code;CREATE TABLE oauth_code ( code VARCHAR (255 ) DEFAULT NULL , authentication BLOB ) ENGINE= InnoDB DEFAULT CHARSET= utf8mb4;
Authorization Code 在这里存储。
例如:
客户端拿到后,会使用:
1 2 grant_type=authorization_code code=XKkHGY
兑换 Access Token。
oauth_approvals:授权确认记录 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 DROP TABLE IF EXISTS oauth_approvals;CREATE TABLE oauth_approvals ( userId VARCHAR (256 ) DEFAULT NULL , clientId VARCHAR (256 ) DEFAULT NULL , partnerKey VARCHAR (32 ) DEFAULT NULL , scope VARCHAR (256 ) DEFAULT NULL , status VARCHAR (10 ) DEFAULT NULL , expiresAt DATETIME DEFAULT NULL , lastModifiedAt DATETIME DEFAULT NULL ) ENGINE= InnoDB DEFAULT CHARSET= utf8mb4;
搭建 Authorization Server 创建:
目录:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 oauth2-server ├── pom.xml └── src └── main ├── java │ └── com.example.oauth.server │ ├── OAuth2ServerApplication.java │ ├── OAuth2ServerConfiguration.java │ ├── WebSecurityConfig.java │ └── CustomTokenEnhancer.java │ └── resources ├── application.yml ├── jwt.jks └── templates └── login.html
Authorization Server Maven 依赖 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 <?xml version="1.0" encoding="UTF-8" ?> <project xmlns ="http://maven.apache.org/POM/4.0.0" xmlns:xsi ="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation =" http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd" > <modelVersion > 4.0.0</modelVersion > <parent > <groupId > com.example</groupId > <artifactId > spring-security-oauth2-demo</artifactId > <version > 1.0.0-SNAPSHOT</version > </parent > <artifactId > oauth2-server</artifactId > <dependencies > <dependency > <groupId > org.springframework.cloud</groupId > <artifactId > spring-cloud-starter-oauth2</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-jdbc</artifactId > </dependency > <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-thymeleaf</artifactId > </dependency > <dependency > <groupId > mysql</groupId > <artifactId > mysql-connector-java</artifactId > </dependency > </dependencies > </project >
application.yml 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 server: port: 8080 spring: application: name: oauth2-server datasource: url: jdbc:mysql://127.0.0.1:3306/oauth?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=UTF-8 username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver thymeleaf: cache: false
启动类 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 package com.example.oauth.server;import org.springframework.boot.SpringApplication;import org.springframework.boot.autoconfigure.SpringBootApplication;@SpringBootApplication public class OAuth2ServerApplication { public static void main (String[] args) { SpringApplication.run( OAuth2ServerApplication.class, args ); } }
生成 JWT RSA 密钥 Authorization Server 要使用私钥:
签名 JWT。
Resource Server 使用:
验证 JWT。
关系是:
1 2 3 4 5 6 7 8 9 Authorization Server private key ↓ sign JWT ↓ verify Resource Server public key
生成 JKS 执行:
1 2 3 4 5 6 7 keytool \ -genkeypair \ -alias jwt \ -keyalg RSA \ -keysize 2048 \ -keystore jwt.jks \ -validity 3650
示例密码:
将:
复制到:
1 oauth2-server/src/main/resources/
查看并导出公钥 可以执行:
1 2 3 4 5 keytool -list -rfc \ --keystore jwt.jks \ | openssl x509 \ -inform pem \ -pubkey
得到类似:
1 2 3 4 -----BEGIN PUBLIC KEY----- MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A... ... -----END PUBLIC KEY-----
保存到:
后面 Resource Server 要使用。
Authorization Server 核心配置 @EnableAuthorizationServer创建:
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 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 package com.example.oauth.server;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import org.springframework.core.io.ClassPathResource;import org.springframework.jdbc.core.JdbcTemplate;import org.springframework.security.authentication.AuthenticationManager;import org.springframework.security.crypto.password.NoOpPasswordEncoder;import org.springframework.security.oauth2.config.annotation.configurers.ClientDetailsServiceConfigurer;import org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerConfigurerAdapter;import org.springframework.security.oauth2.config.annotation.web.configuration.EnableAuthorizationServer;import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerEndpointsConfigurer;import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerSecurityConfigurer;import org.springframework.security.oauth2.provider.approval.JdbcApprovalStore;import org.springframework.security.oauth2.provider.code.AuthorizationCodeServices;import org.springframework.security.oauth2.provider.code.JdbcAuthorizationCodeServices;import org.springframework.security.oauth2.provider.token.TokenEnhancer;import org.springframework.security.oauth2.provider.token.TokenEnhancerChain;import org.springframework.security.oauth2.provider.token.TokenStore;import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter;import org.springframework.security.oauth2.provider.token.store.JwtTokenStore;import org.springframework.security.rsa.crypto.KeyStoreKeyFactory;import javax.sql.DataSource;import java.util.Arrays;@Configuration @EnableAuthorizationServer public class OAuth2ServerConfiguration extends AuthorizationServerConfigurerAdapter { @Autowired private DataSource dataSource; @Autowired private AuthenticationManager authenticationManager; @Override public void configure ( ClientDetailsServiceConfigurer clients ) throws Exception { clients.jdbc(dataSource); } @Override public void configure ( AuthorizationServerSecurityConfigurer security ) { security .checkTokenAccess("permitAll()" ) .allowFormAuthenticationForClients() .passwordEncoder( NoOpPasswordEncoder.getInstance() ); } @Override public void configure ( AuthorizationServerEndpointsConfigurer endpoints ) { TokenEnhancerChain enhancerChain = new TokenEnhancerChain (); enhancerChain.setTokenEnhancers( Arrays.asList( tokenEnhancer(), jwtAccessTokenConverter() ) ); endpoints .approvalStore(approvalStore()) .authorizationCodeServices( authorizationCodeServices() ) .tokenStore(tokenStore()) .tokenEnhancer(enhancerChain) .authenticationManager( authenticationManager ); } @Bean public AuthorizationCodeServices authorizationCodeServices () { return new JdbcAuthorizationCodeServices ( dataSource ); } @Bean public JdbcApprovalStore approvalStore () { return new JdbcApprovalStore (dataSource); } @Bean public TokenStore tokenStore () { return new JwtTokenStore ( jwtAccessTokenConverter() ); } @Bean public TokenEnhancer tokenEnhancer () { return new CustomTokenEnhancer (); } @Bean public JwtAccessTokenConverter jwtAccessTokenConverter () { KeyStoreKeyFactory factory = new KeyStoreKeyFactory ( new ClassPathResource ("jwt.jks" ), "123456" .toCharArray() ); JwtAccessTokenConverter converter = new JwtAccessTokenConverter (); converter.setKeyPair( factory.getKeyPair("jwt" ) ); return converter; } }
这是整个 Authorization Server 最核心的配置。
资料里的 Authorization Server 也正是在这里完成 Client JDBC 存储、JWT TokenStore、RSA 签名、Authorization Code JDBC 存储和 ApprovalStore 配置。
clients.jdbc(dataSource) 是干什么的1 clients.jdbc(dataSource);
意味着 Client 不再写死:
而是查询:
因此以后新增 Client:
1 2 3 userservice1 userservice2 userservice3
不需要改 Java 代码。
TokenStore 为什么使用 JWT 1 2 3 4 5 6 @Bean public TokenStore tokenStore () { return new JwtTokenStore ( jwtAccessTokenConverter() ); }
采用 JWT 后:
不需要每次请求:
查询 Token。
而是:
1 2 3 4 5 JWT ↓ public key ↓ local verify
完成本地验签。
自定义 JWT Claims 资料中专门使用 TokenEnhancer 把用户信息放入 JWT。
创建:
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 package com.example.oauth.server;import org.springframework.security.core.Authentication;import org.springframework.security.oauth2.common.DefaultOAuth2AccessToken;import org.springframework.security.oauth2.common.OAuth2AccessToken;import org.springframework.security.oauth2.provider.OAuth2Authentication;import org.springframework.security.oauth2.provider.token.TokenEnhancer;import java.util.HashMap;import java.util.Map;public class CustomTokenEnhancer implements TokenEnhancer { @Override public OAuth2AccessToken enhance ( OAuth2AccessToken accessToken, OAuth2Authentication authentication) { Authentication userAuthentication = authentication.getUserAuthentication(); if (userAuthentication == null ) { return accessToken; } Map<String, Object> additional = new HashMap <>(); additional.put( "username" , userAuthentication.getName() ); additional.put( "authorities" , userAuthentication.getAuthorities() ); ((DefaultOAuth2AccessToken) accessToken) .setAdditionalInformation(additional); return accessToken; } }
最终 JWT Payload 会包含类似:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 { "user_name" : "writer" , "scope" : [ "FOO" ] , "username" : "writer" , "authorities" : [ { "authority" : "READ" } , { "authority" : "WRITE" } ] , "client_id" : "userservice1" }
生产环境不要把整个:
对象塞进 Token。
这里为了方便演示,只增加:
配置用户认证 Authorization Server 不仅负责 OAuth Client,还需要完成:
的认证。
创建:
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 package com.example.oauth.server;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import org.springframework.security.authentication.AuthenticationManager;import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;import org.springframework.security.config.annotation.web.builders.HttpSecurity;import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;import javax.sql.DataSource;@Configuration public class WebSecurityConfig extends WebSecurityConfigurerAdapter { @Autowired private DataSource dataSource; @Bean @Override public AuthenticationManager authenticationManagerBean () throws Exception { return super .authenticationManagerBean(); } @Override protected void configure ( AuthenticationManagerBuilder auth ) throws Exception { auth .jdbcAuthentication() .dataSource(dataSource) .passwordEncoder( new BCryptPasswordEncoder () ); } @Override protected void configure ( HttpSecurity http ) throws Exception { http .authorizeRequests() .antMatchers( "/login" , "/oauth/authorize" ) .permitAll() .anyRequest() .authenticated() .and() .formLogin() .loginPage("/login" ) .permitAll(); } }
注意:
非常重要。
因为 Password Grant:
需要使用它验证:
创建登录页面 放到:
1 src/main/resources/templates/login.html
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 <!DOCTYPE html > <html xmlns:th ="http://www.thymeleaf.org" > <head > <meta charset ="UTF-8" /> <title > OAuth Login</title > </head > <body > <h1 > OAuth 2.0 Login</h1 > <div th:if ="${param.error}" > Username or password error.</div > <form method ="post" th:action ="@{/login}" > <div > <label > Username</label > <input type ="text" name ="username" value ="reader" /> </div > <div > <label > Password</label > <input type ="password" name ="password" value ="reader" /> </div > <button type ="submit" > Login </button > </form > </body > </html >
还需要配置 View Controller:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 package com.example.oauth.server;import org.springframework.context.annotation.Configuration;import org.springframework.web.servlet.config.annotation.ViewControllerRegistry;import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;@Configuration public class MvcConfiguration implements WebMvcConfigurer { @Override public void addViewControllers ( ViewControllerRegistry registry ) { registry .addViewController("/login" ) .setViewName("login" ); } }
Authorization Server 到这里完成。
创建 Resource Server 项目:
目录:
1 2 3 4 5 6 7 8 9 10 11 12 13 oauth2-resource-server ├── pom.xml └── src/main ├── java │ └── com.example.oauth.resource │ ├── ResourceServerApplication.java │ ├── ResourceServerConfiguration.java │ ├── HelloController.java │ └── UserController.java │ └── resources ├── application.yml └── public.cert
Maven 依赖 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 <?xml version="1.0" encoding="UTF-8" ?> <project xmlns ="http://maven.apache.org/POM/4.0.0" xmlns:xsi ="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation =" http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd" > <modelVersion > 4.0.0</modelVersion > <parent > <groupId > com.example</groupId > <artifactId > spring-security-oauth2-demo</artifactId > <version > 1.0.0-SNAPSHOT</version > </parent > <artifactId > oauth2-resource-server</artifactId > <dependencies > <dependency > <groupId > org.springframework.cloud</groupId > <artifactId > spring-cloud-starter-oauth2</artifactId > </dependency > <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-web</artifactId > </dependency > </dependencies > </project >
application.yml 1 2 3 4 5 6 server: port: 8081 spring: application: name: oauth2-resource-server
启动类 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 package com.example.oauth.resource;import org.springframework.boot.SpringApplication;import org.springframework.boot.autoconfigure.SpringBootApplication;@SpringBootApplication public class ResourceServerApplication { public static void main (String[] args) { SpringApplication.run( ResourceServerApplication.class, args ); } }
Resource Server 核心配置 创建:
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 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 package com.example.oauth.resource;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import org.springframework.core.io.ClassPathResource;import org.springframework.core.io.Resource;import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;import org.springframework.security.config.annotation.web.builders.HttpSecurity;import org.springframework.security.oauth2.config.annotation.web.configuration.EnableResourceServer;import org.springframework.security.oauth2.config.annotation.web.configuration.ResourceServerConfigurerAdapter;import org.springframework.security.oauth2.config.annotation.web.configurers.ResourceServerSecurityConfigurer;import org.springframework.security.oauth2.provider.token.TokenStore;import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter;import org.springframework.security.oauth2.provider.token.store.JwtTokenStore;import org.springframework.util.FileCopyUtils;import java.io.IOException;import java.nio.charset.StandardCharsets;@Configuration @EnableResourceServer @EnableGlobalMethodSecurity( prePostEnabled = true ) public class ResourceServerConfiguration extends ResourceServerConfigurerAdapter { @Override public void configure ( ResourceServerSecurityConfigurer resources ) { resources .resourceId("userservice" ) .tokenStore(tokenStore()); } @Bean public TokenStore tokenStore () { return new JwtTokenStore ( jwtAccessTokenConverter() ); } @Bean public JwtAccessTokenConverter jwtAccessTokenConverter () { JwtAccessTokenConverter converter = new JwtAccessTokenConverter (); Resource resource = new ClassPathResource ( "public.cert" ); try { String publicKey = new String ( FileCopyUtils .copyToByteArray( resource.getInputStream() ), StandardCharsets.UTF_8 ); converter.setVerifierKey( publicKey ); } catch (IOException e) { throw new IllegalStateException ( "Load public key failed" , e ); } return converter; } @Override public void configure ( HttpSecurity http ) throws Exception { http .authorizeRequests() .antMatchers("/user/**" ) .authenticated() .anyRequest() .permitAll(); } }
Resource Server 不需要私钥。
只需要:
因为:
1 2 3 4 5 6 7 8 9 Authorization Server Private Key ↓ Sign JWT ↓ Resource Server Public Key ↓ Verify JWT
创建匿名接口 1 2 3 4 5 6 7 8 9 10 11 12 13 14 package com.example.oauth.resource;import org.springframework.web.bind.annotation.GetMapping;import org.springframework.web.bind.annotation.RestController;@RestController public class HelloController { @GetMapping("/hello") public String hello () { return "Hello OAuth 2.0" ; } }
直接访问:
1 http://localhost:8081/hello
应该返回:
不需要 Token。
创建需要权限的 API 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 package com.example.oauth.resource;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.security.access.prepost.PreAuthorize;import org.springframework.security.oauth2.common.OAuth2AccessToken;import org.springframework.security.oauth2.provider.OAuth2Authentication;import org.springframework.security.oauth2.provider.authentication.OAuth2AuthenticationDetails;import org.springframework.security.oauth2.provider.token.TokenStore;import org.springframework.web.bind.annotation.*;@RestController @RequestMapping("/user") public class UserController { @Autowired private TokenStore tokenStore; @PreAuthorize( "hasAuthority('READ') or hasAuthority('WRITE')" ) @GetMapping("/name") public String name ( OAuth2Authentication authentication) { return authentication.getName(); } @PreAuthorize( "hasAuthority('READ') or hasAuthority('WRITE')" ) @GetMapping public OAuth2Authentication read ( OAuth2Authentication authentication) { return authentication; } @PreAuthorize( "hasAuthority('WRITE')" ) @PostMapping public Object write ( OAuth2Authentication authentication) { OAuth2AuthenticationDetails details = (OAuth2AuthenticationDetails) authentication.getDetails(); OAuth2AccessToken token = tokenStore.readAccessToken( details.getTokenValue() ); return token .getAdditionalInformation(); } }
现在有三个接口:
API
权限
GET /user/name
READ 或 WRITE
GET /user
READ 或 WRITE
POST /user
WRITE
初始化测试用户 用户密码使用:
所以不能直接插入:
需要 BCrypt。
为了方便测试,可以临时执行:
1 2 3 4 5 6 7 8 9 10 11 12 13 public static void main (String[] args) { BCryptPasswordEncoder encoder = new BCryptPasswordEncoder (); System.out.println( encoder.encode("reader" ) ); System.out.println( encoder.encode("writer" ) ); }
然后插入:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 INSERT INTO users ( username, password, enabled )VALUES ( 'reader' , '$2a$10$替换为reader的BCrypt结果' , 1 );INSERT INTO users ( username, password, enabled )VALUES ( 'writer' , '$2a$10$替换为writer的BCrypt结果' , 1 );
初始化权限 创建:
只有:
创建:
拥有:
SQL:
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 INSERT INTO authorities ( username, authority )VALUES ( 'reader' , 'READ' );INSERT INTO authorities ( username, authority )VALUES ( 'writer' , 'READ' );INSERT INTO authorities ( username, authority )VALUES ( 'writer' , 'WRITE' );
这里特意将 writer 拆成两条 Authority。
因为 Spring Security 的:
匹配的是单个 Authority。
如果数据库里写:
作为一整条字符串,那么它实际上可能被理解成:
1 authority = "READ,WRITE"
而不是两个 Authority。
初始化三个 OAuth Client 我们创建三个 Client:
1 2 3 userservice1 userservice2 userservice3
分别测试:
1 2 3 Password Grant Client Credentials Grant Authorization Code Grant
Password Client 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 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 ( 'userservice1' , 'userservice' , '1234' , 'FOO' , 'password,refresh_token' , NULL , 'READ,WRITE' , 7200 , 2592000 , NULL , 'true' );
Client Credentials Client 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 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 ( 'userservice2' , 'userservice' , '1234' , 'FOO' , 'client_credentials' , NULL , 'READ,WRITE' , 7200 , NULL , NULL , 'true' );
Authorization Code Client 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 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 ( 'userservice3' , 'userservice' , '1234' , 'FOO' , 'authorization_code,refresh_token' , 'http://localhost:8082/ui/login' , 'READ,WRITE' , 7200 , 2592000 , NULL , 'false' );
资料中的实验也是通过三个 Client 分别演示 Password、Client Credentials 和 Authorization Code,并让它们指向同一个 userservice Resource ID。
启动服务 先启动:
确认:
再启动:
确认:
1 http://localhost:8081/hello
返回:
实战一:Password Grant 虽然这个模式适合用来理解历史 OAuth 流程,但这里主要把它当做学习 Spring Security OAuth Token Endpoint 的实验。
请求:
1 2 3 4 5 6 7 curl -X POST \ "http://localhost:8080/oauth/token" \ -d "grant_type=password" \ -d "client_id=userservice1" \ -d "client_secret=1234" \ -d "username=writer" \ -d "password=writer"
完整参数:
1 2 3 4 5 grant_type=password client_id=userservice1 client_secret=1234 username=writer password=writer
Authorization Server 会经历:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 client_id ↓ ClientDetailsService client_secret ↓ Client Authentication username/password ↓ AuthenticationManager Authentication ↓ TokenEnhancer JWT ↓ Private Key Sign
返回类似:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 { "access_token" : "eyJhbGciOiJSUzI1NiIs..." , "token_type" : "bearer" , "refresh_token" : "eyJhbGciOiJSUzI1NiIs..." , "expires_in" : 7199 , "scope" : "FOO" , "username" : "writer" , "authorities" : [ { "authority" : "READ" } , { "authority" : "WRITE" } ] }
使用 Token 调 Resource Server 拿到:
然后:
1 2 3 curl \ -H "Authorization: Bearer $ACCESS_TOKEN " \ http://localhost:8081/user/name
返回:
请求:
1 2 3 curl \ -H "Authorization: Bearer $ACCESS_TOKEN " \ http://localhost:8081/user
可以看到:
1 2 3 4 OAuth2Authentication authorities client_id userAuthentication
测试 WRITE 权限 请求:
1 2 3 curl -X POST \ -H "Authorization: Bearer $ACCESS_TOKEN " \ http://localhost:8081/user
writer 应该成功。
返回可能类似:
1 2 3 4 5 6 7 8 9 10 11 { "username" : "writer" , "authorities" : [ { "authority" : "READ" } , { "authority" : "WRITE" } ] }
使用 reader Token 测试 403 获取 reader Token:
1 2 3 4 5 6 7 curl -X POST \ "http://localhost:8080/oauth/token" \ -d "grant_type=password" \ -d "client_id=userservice1" \ -d "client_secret=1234" \ -d "username=reader" \ -d "password=reader"
然后:
1 2 3 curl -X POST \ -H "Authorization: Bearer $READER_TOKEN " \ http://localhost:8081/user
因为:
而 API 要求:
1 2 3 @PreAuthorize( "hasAuthority('WRITE')" )
所以应该返回:
注意这个区别:
1 2 3 4 5 6 7 8 401 ↓ Token 无效 / 没有认证 403 ↓ Token 合法 但权限不够
实战二:Client Credentials Client Credentials 没有最终用户。
关系是:
1 2 3 4 5 6 7 Client ↓ Authorization Server ↓ Access Token ↓ Resource Server
请求:
1 2 3 curl -X POST \ -u userservice2:1234 \ "http://localhost:8080/oauth/token?grant_type=client_credentials"
或者由于前面打开了:
1 allowFormAuthenticationForClients()
也可以:
1 2 3 4 5 curl -X POST \ "http://localhost:8080/oauth/token" \ -d "grant_type=client_credentials" \ -d "client_id=userservice2" \ -d "client_secret=1234"
返回:
1 2 3 4 5 6 { "access_token" : "eyJhbGciOiJSUzI1NiIs..." , "token_type" : "bearer" , "expires_in" : 7199 , "scope" : "FOO" }
这里通常不会出现:
1 2 3 username userDetails refresh_token
原因非常简单。
Client Credentials 表达的是:
自身身份。
并不存在:
这样的最终用户。
资料中的实验也特别指出,Client Credentials 没有用户概念,因此不会产生用于避免用户重新登录的 Refresh Token,也无法注入用户相关的额外 Claims。
Client Credentials 在微服务中的意义 例如:
1 2 3 Order Service ↓ Inventory Service
Order Service 可以注册成:
1 client_id = order-service
Scope:
获得 Token:
1 2 sub/client_id = order-service scope = inventory.read
然后:
1 2 GET /inventory/10001Authorization : Bearer xxx
这就是典型的:
1 2 Service-to-Service Machine-to-Machine
认证授权。
实战三:Authorization Code 这是最值得真正跑一遍的流程。
访问:
1 2 3 4 http://localhost:8080/oauth/authorize ?response_type=code &client_id=userservice3 &redirect_uri=http://localhost:8082/ui/login
浏览器完整地址:
1 http://localhost:8080/oauth/authorize?response_type=code&client_id=userservice3&redirect_uri=http://localhost:8082/ui/login
此时会跳转到:
使用 reader 登录 输入:
1 2 username = reader password = reader
然后出现授权确认:
1 2 3 4 5 6 7 Do you authorize userservice3 to access your protected resources? scope.FOO Approve Deny
因为数据库:
所以会显示 Consent 页面。
同意授权 用户批准:
后,Authorization Server 创建:
同时:
中会出现数据。
然后浏览器被重定向:
1 http://localhost:8082/ui/login?code=XXXXXX
例如:
1 http://localhost:8082/ui/login?code=XKkHGY
使用 Code 换 Access Token 1 2 3 4 5 6 7 curl -X POST \ "http://localhost:8080/oauth/token" \ -d "grant_type=authorization_code" \ -d "client_id=userservice3" \ -d "client_secret=1234" \ -d "code=XKkHGY" \ -d "redirect_uri=http://localhost:8082/ui/login"
返回:
1 2 3 4 5 6 7 { "access_token" : "eyJhbGciOiJSUzI1NiIs..." , "token_type" : "bearer" , "refresh_token" : "eyJhbGciOiJSUzI1NiIs..." , "expires_in" : 7199 , "scope" : "FOO" }
因为登录用户:
所以最终权限也是 reader 权限。
这体现了一个很重要的模型:
1 2 3 4 5 OAuth Client 能申请的权限 ∩ 当前用户拥有的权限 ↓ 最终 Token Authority
Client 自己拥有:
并不代表:
突然获得:
权限。
Authorization Code 为什么只能使用一次 再次拿:
请求:
1 2 3 4 5 6 7 curl -X POST \ "http://localhost:8080/oauth/token" \ -d "grant_type=authorization_code" \ -d "client_id=userservice3" \ -d "client_secret=1234" \ -d "code=XKkHGY" \ -d "redirect_uri=http://localhost:8082/ui/login"
应该失败。
Authorization Code 是:
而不是 Access Token。
这也是为什么:
1 2 3 Authorization Code ≠ Access Token
使用 Refresh Token 如果响应中:
1 2 3 { "refresh_token" : "xxx" }
可以:
1 2 3 4 5 6 curl -X POST \ "http://localhost:8080/oauth/token" \ -d "grant_type=refresh_token" \ -d "client_id=userservice3" \ -d "client_secret=1234" \ -d "refresh_token=$REFRESH_TOKEN "
得到新的:
客户端不需要再次让用户输入密码。
JWT 本地验签到底是怎么工作的 Authorization Server 使用:
里面的:
签名:
得到:
JWT:
Resource Server 收到:
1 Authorization : Bearer eyJhbGciOiJSUzI1NiIs...
之后:
1 2 3 4 5 6 7 8 9 10 11 12 13 JWT ↓ JwtTokenStore ↓ JwtAccessTokenConverter ↓ public.cert ↓ Verify Signature ↓ OAuth2Authentication ↓ SecurityContext
因此不需要:
1 2 3 Resource Server ↓ 每次请求 Authorization Server
做远程校验。
如果不用 JWT 怎么办 资料还总结了两个典型方法。
共享 TokenStore 1 2 3 4 5 6 7 Authorization Server ↓ Redis Resource Server ↓ Redis
例如:
1 2 RedisTokenStore JdbcTokenStore
Resource Server 查询共享状态。
RemoteTokenServices Resource Server 请求:
例如:
1 2 3 4 curl -X POST \ -u userservice1:1234 \ -d "token=$ACCESS_TOKEN " \ http://localhost:8080/oauth/check_token
Authorization Server 返回:
1 2 3 4 5 6 7 8 { "active" : true , "user_name" : "writer" , "client_id" : "userservice1" , "scope" : [ "FOO" ] }
三种方案可以理解成:
1 2 3 4 5 6 7 8 JWT -> 本地验证 Redis/JDBC TokenStore -> 共享状态验证 RemoteTokenServices -> 远程验证
搭建真正的 OAuth Client 到现在为止,我们一直:
手动完成 OAuth 流程。
接下来创建:
让 Spring 自动完成:
1 2 3 4 5 6 Authorization Code 获取 Token 保存 Token Bearer Token 远程调用 SSO
目录:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 oauth2-client ├── pom.xml └── src/main ├── java │ └── com.example.oauth.client │ ├── OAuthClientApplication.java │ ├── WebMvcConfig.java │ ├── WebSecurityConfig.java │ ├── OAuthClientConfig.java │ └── DemoController.java │ └── resources ├── application.yml └── templates ├── index.html └── securedPage.html
Client Maven 依赖 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 <?xml version="1.0" encoding="UTF-8" ?> <project xmlns ="http://maven.apache.org/POM/4.0.0" xmlns:xsi ="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation =" http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd" > <modelVersion > 4.0.0</modelVersion > <parent > <groupId > com.example</groupId > <artifactId > spring-security-oauth2-demo</artifactId > <version > 1.0.0-SNAPSHOT</version > </parent > <artifactId > oauth2-client</artifactId > <dependencies > <dependency > <groupId > org.springframework.cloud</groupId > <artifactId > spring-cloud-starter-oauth2</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-thymeleaf</artifactId > </dependency > </dependencies > </project >
Client application.yml 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 server: port: 8082 servlet: context-path: /ui security: oauth2: client: client-id: userservice3 client-secret: 1234 access-token-uri: http://localhost:8080/oauth/token user-authorization-uri: http://localhost:8080/oauth/authorize scope: - FOO resource: jwt: key-value: | -----BEGIN PUBLIC KEY----- 把 public.cert 中的 RSA 公钥放在这里 -----END PUBLIC KEY----- spring: thymeleaf: cache: false
资料中特别提到,本地运行 Client 和 Authorization Server 时,为 Client 增加独立的 context-path 可以避免 Cookie 相互干扰导致 CSRF 防护被触发,这类问题日志默认又不明显,是一个实际排错时很容易踩的坑。
Client 启动类 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 package com.example.oauth.client;import org.springframework.boot.SpringApplication;import org.springframework.boot.autoconfigure.SpringBootApplication;@SpringBootApplication public class OAuthClientApplication { public static void main (String[] args) { SpringApplication.run( OAuthClientApplication.class, args ); } }
MVC 配置 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 package com.example.oauth.client;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import org.springframework.web.context.request.RequestContextListener;import org.springframework.web.servlet.config.annotation.ViewControllerRegistry;import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;@Configuration public class WebMvcConfig implements WebMvcConfigurer { @Bean public RequestContextListener requestContextListener () { return new RequestContextListener (); } @Override public void addViewControllers ( ViewControllerRegistry registry ) { registry .addViewController("/" ) .setViewName("forward:/index" ); registry .addViewController("/index" ); } }
Client Security 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 package com.example.oauth.client;import org.springframework.context.annotation.Configuration;import org.springframework.core.annotation.Order;import org.springframework.security.config.annotation.web.builders.HttpSecurity;import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;@Configuration @Order(200) public class WebSecurityConfig extends WebSecurityConfigurerAdapter { @Override protected void configure ( HttpSecurity http ) throws Exception { http .authorizeRequests() .antMatchers( "/" , "/index" , "/login**" ) .permitAll() .anyRequest() .authenticated(); } }
开启 OAuth2 SSO 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 package com.example.oauth.client;import org.springframework.boot.autoconfigure.security.oauth2.client.EnableOAuth2Sso;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import org.springframework.security.oauth2.client.OAuth2ClientContext;import org.springframework.security.oauth2.client.OAuth2RestTemplate;import org.springframework.security.oauth2.client.resource.OAuth2ProtectedResourceDetails;@Configuration @EnableOAuth2Sso public class OAuthClientConfig { @Bean public OAuth2RestTemplate oauth2RestTemplate ( OAuth2ClientContext context, OAuth2ProtectedResourceDetails details) { return new OAuth2RestTemplate ( details, context ); } }
@EnableOAuth2Sso 会帮助 Client 建立 OAuth SSO 流程。
创建安全页面 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 package com.example.oauth.client;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.http.ResponseEntity;import org.springframework.security.oauth2.client.OAuth2RestTemplate;import org.springframework.security.oauth2.provider.OAuth2Authentication;import org.springframework.stereotype.Controller;import org.springframework.web.bind.annotation.GetMapping;import org.springframework.web.bind.annotation.ResponseBody;import org.springframework.web.servlet.ModelAndView;@Controller public class DemoController { @Autowired private OAuth2RestTemplate restTemplate; @GetMapping("/securedPage") public ModelAndView securedPage ( OAuth2Authentication authentication) { return new ModelAndView ( "securedPage" ) .addObject( "authentication" , authentication ); } @ResponseBody @GetMapping("/remoteCall") public String remoteCall () { ResponseEntity<String> response = restTemplate.getForEntity( "http://localhost:8081/user/name" , String.class ); return response.getBody(); } }
这里有两个功能。
securedPage
必须登录才能访问。
显示:
remoteCall
内部使用:
自动完成:
1 2 3 4 5 获取 Access Token ↓ 添加 Authorization Header ↓ 调用 Resource Server
不需要手写:
1 2 3 4 request.setHeader( "Authorization" , "Bearer " + accessToken );
创建首页 templates/index.html:
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 <!DOCTYPE html > <html > <head > <meta charset ="UTF-8" /> <title > OAuth Client</title > </head > <body > <h1 > Spring Security OAuth Client</h1 > <a href ="securedPage" > Login</a > <br /> <br /> <a href ="remoteCall" > Call Resource Server</a > </body > </html >
创建安全页面 templates/securedPage.html:
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 <!DOCTYPE html > <html xmlns:th ="http://www.thymeleaf.org" > <head > <meta charset ="UTF-8" /> <title > Secured Page</title > </head > <body > <h1 > Secured Page</h1 > <p > Welcome: <span th:text ="${authentication.name}" > </span > </p > <p > Authorities: <span th:text ="${authentication.authorities}" > </span > </p > </body > </html >
运行 Authorization Code Client 启动:
1 2 3 oauth2-server :8080 oauth2-resource :8081 oauth2-client :8082
浏览器打开:
1 http://localhost:8082/ui/securedPage
此时不是直接访问成功。
而是发生:
1 2 3 4 5 8082 ↓ 8080 /oauth/authorize ↓ 8080 /login
登录:
批准授权。
然后:
1 2 3 4 5 Authorization Server ↓ Authorization Code ↓ http://localhost:8082/ui/login?code=xxx
Client 自动:
1 2 3 4 5 Code ↓ /oauth/token ↓ Access Token
最终回到:
显示:
1 2 Welcome reader Authorities [READ]
背后的 302 跳转 实际发生的大致链路是:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 http://localhost:8082/ui/securedPage ↓ http://localhost:8082/ui/login ↓ http://localhost:8080/oauth/authorize ?client_id=userservice3 &redirect_uri=http://localhost:8082/ui/login ... ↓ http://localhost:8082/ui/login ?code=XXXXXX &state=XXXXXX ↓ http://localhost:8082/ui/securedPage
你平时看到的:
背后实际发生的是多次:
资料中的 SSO 演示也直接展示了这组跳转链路。
测试自动调用 Resource Server 访问:
1 http://localhost:8082/ui/remoteCall
代码只有:
1 2 3 4 restTemplate.getForEntity( "http://localhost:8081/user/name" , String.class );
但背后:
1 2 3 4 5 6 7 8 9 OAuth2RestTemplate ↓ OAuth2ClientContext ↓ Access Token ↓ Authorization: Bearer xxx ↓ Resource Server
返回:
这时整个 OAuth Client 流程已经真正跑通。
测试 SSO 现在复制一套 Client。
或者简单地:
1 把 Client 端口从 8082 改成 8083
再启动一个实例。
数据库为 Client 增加第二个回调地址的实际项目中需要单独注册;为了理解 SSO,这里重点观察 Authorization Server 的登录状态。
先登录:
1 http://localhost:8082/ui/securedPage
已经在:
建立 Session。
然后访问:
1 http://localhost:8083/ui/securedPage
第二个 Client 仍然会发起:
但是到:
以后发现:
因此不需要再次输入用户名密码。
这就是 SSO 的关键:
1 2 3 4 5 6 7 8 9 不是两个 Client 共享自己的 Session 而是: 两个 Client ↓ 同一个 Authorization Server ↓ Authorization Server 已有认证 Session
SSO 的完整模型 sequenceDiagram
actor U as 用户
participant A as Client A
participant B as Client B
participant AS as Authorization Server
U->>A: 访问 Client A
A-->>U: Redirect AS
U->>AS: Login
AS->>AS: 建立 AS Session
AS-->>A: Authorization Code
A->>AS: Code 换 Token
AS-->>A: Access Token
Note over U,A: Client A 登录成功
U->>B: 访问 Client B
B-->>U: Redirect AS
U->>AS: Authorization Request
AS->>AS: 已有 Login Session
AS-->>B: Authorization Code
B->>AS: Code 换 Token
AS-->>B: Access Token
Note over U,B: 不需要重新输入密码
权限控制到底发生在哪 OAuth 项目中最容易混在一起的东西是:
1 2 3 4 Client Scope Authority Resource
可以拆成:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 client_id ↓ 这个 Client 是谁 scope ↓ 这个 Client 被允许访问什么范围 authority ↓ 当前主体在 Spring Security 中有什么权限 resource_id ↓ Token 面向哪个 Resource Server
例如:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 Client: userservice3 Scope: FOO User: reader Authority: READ Resource: userservice
最终:
1 2 3 4 5 6 7 reader + userservice3 + FOO + READ
共同构成授权上下文。
Spring Security 方法级权限 Resource Server:
1 2 3 4 5 6 7 8 9 @PreAuthorize( "hasAuthority('READ') or hasAuthority('WRITE')" ) @GetMapping("/name") public String name ( OAuth2Authentication authentication) { return authentication.getName(); }
以及:
1 2 3 4 5 6 7 8 9 @PreAuthorize( "hasAuthority('WRITE')" ) @PostMapping public Object write ( OAuth2Authentication authentication) { }
执行过程:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 HTTP Request ↓ Bearer Token ↓ Resource Server Filter ↓ JWT Verify ↓ OAuth2Authentication ↓ SecurityContext ↓ @PreAuthorize ↓ Controller
因此:
不等于:
还需要:
真实业务里还必须做对象级权限 OAuth Token 有:
也不能这样:
1 2 3 4 5 6 7 8 @GetMapping("/orders/{id}") public Order get ( @PathVariable Long id) { return orderRepository .findById(id) .orElseThrow(); }
否则:
只要猜到:
就可能读到 B 的订单。
正确模型应该是:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 @GetMapping("/orders/{id}") public Order getOrder ( @PathVariable Long id, OAuth2Authentication authentication) { String merchantId = authentication.getName(); return orderRepository .findByIdAndMerchantId( id, merchantId ) .orElseThrow(); }
或者 SQL:
1 2 3 4 SELECT * FROM ordersWHERE id = :orderId AND merchant_id = :currentMerchantId;
这正是 OAuth 实践中常见的水平越权问题:网关或 Resource Server 即使已经校验 Token,也不能替代业务数据的归属校验。
完整请求调用链 整个系统跑通以后:
sequenceDiagram
autonumber
actor U as User
participant C as OAuth Client
participant AS as Authorization Server
participant DB as OAuth DB
participant RS as Resource Server
U->>C: GET /securedPage
C-->>U: 302 /oauth/authorize
U->>AS: Authorization Request
AS->>DB: 查询 client_id
DB-->>AS: ClientDetails
AS-->>U: Login
U->>AS: username/password
AS->>DB: 查询 User + Authority
DB-->>AS: reader + READ
AS-->>U: Consent
U->>AS: Approve
AS->>DB: 保存 Approval
AS->>DB: 保存 Authorization Code
AS-->>C: code
C->>AS: code + client authentication
AS->>DB: 查询并消费 Code
AS->>AS: 创建 JWT
AS->>AS: RSA Private Key Sign
AS-->>C: Access Token
C->>RS: Bearer JWT
RS->>RS: Public Key Verify
RS->>RS: 创建 OAuth2Authentication
RS->>RS: @PreAuthorize
RS-->>C: Protected Resource
C-->>U: Response
这张图基本就是整个经典 Spring Security OAuth 2.0 实现的核心。
常见问题:401 和 403 怎么区分 401 Unauthorized 常见原因:
1 2 3 4 5 6 没有 Token Token 格式错误 Token 过期 JWT 签名错误 公钥不正确 Resource ID 不匹配
排查:
1 2 3 4 5 6 7 8 9 Authorization Header ↓ Bearer Token ↓ JWT Signature ↓ exp ↓ Resource ID
403 Forbidden 常见原因:
例如:
1 2 3 @PreAuthorize( "hasAuthority('WRITE')" )
当前 Token:
所以:
常见问题:JWT 校验失败 如果出现:
1 Cannot convert access token to JSON
或者:
检查:
1 2 Authorization Server 的 private key Resource Server 的 public key
是否来自同一个 KeyPair。
模型必须是:
1 2 3 4 5 6 7 private key A ↓ sign public key A ↓ verify
而不是:
1 2 3 4 private key A ↓ public key B
常见问题:Client Secret 不对 如果:
检查:
1 oauth_client_details.client_secret
和:
是否一致。
本文为了复现实验使用:
所以数据库直接:
生产环境不要这么处理。
常见问题:Authorization Code 兑换失败 如果出现:
检查:
1 2 3 4 code 是否已经使用 code 是否过期 redirect_uri 是否一致 Client 是否一致
尤其是:
Authorization Request:
1 http://localhost:8082/ui/login
Token Request 也应该:
1 http://localhost:8082/ui/login
常见问题:为什么 Client Credentials 没有用户 因为:
1 grant_type=client_credentials
认证的是:
不是:
模型:
1 2 3 4 5 Order Service ↓ Authorization Server ↓ Inventory Token
而不是:
1 2 3 4 5 张三 ↓ Order Service ↓ Inventory Service
所以:
可能就是:
这也是前面的:
必须先判断:
1 if (userAuthentication == null )
的原因。
常见问题:为什么 JWT 登出比较麻烦 JWT 的优势:
但这也是它的代价。
假设:
用户:
Token 可能仍然写着:
如果 Resource Server 只本地验签:
1 2 Token signature valid Token not expired
那么它并不知道:
这就是:
与:
1 中心化 Session / Opaque Token
之间最典型的设计取舍。
实战项目中建议增加的安全措施 虽然本文重点是把代码跑通,但真实系统还应该至少增加:
1 2 3 4 5 6 7 8 9 10 11 12 HTTPS Authorization Code 一次性 Redirect URI 严格匹配 state PKCE Access Token 短有效期 Refresh Token 管理 Client Secret 安全存储 JWT Claims 最小化 Object-Level Authorization Token Revocation 安全审计
尤其不要把:
放到:
1 2 3 4 5 JavaScript SPA Mobile App APK Git 前端配置文件
里面。
从 Demo 演进到真实微服务架构 当系统只有:
的时候,每个 Resource Server 自己验证 JWT 很简单。
但如果变成:
1 2 3 4 5 6 API Gateway Order Service Product Service Payment Service User Service Inventory Service
通常会演进为:
flowchart LR
C[Client]
G[API Gateway]
AS[Authorization Server]
O[Order Service]
P[Product Service]
I[Inventory Service]
C -->|Access Token| G
G -->|Token Verify| AS
G --> O
G --> P
G --> I
Gateway 做:
1 2 3 4 5 6 Token 基础验证 Client 校验 Scope 校验 路由 限流 日志
业务服务继续做:
1 2 3 Resource Ownership Tenant Permission Business Authorization
OAuth 2.0/JWT 微服务参考架构资料同样将 IDP、Gateway、BFF 和领域服务分层,并让 Gateway 负责 Token 校验和粗粒度权限判断,后端服务利用可信身份上下文继续完成领域逻辑。
实战项目结构回顾 最终工程:
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 spring-security-oauth2-demo │ ├── pom.xml │ ├── oauth2-server │ ├── OAuth2ServerApplication │ ├── OAuth2ServerConfiguration │ ├── WebSecurityConfig │ ├── MvcConfiguration │ ├── CustomTokenEnhancer │ ├── jwt.jks │ └── login.html │ ├── oauth2-resource-server │ ├── ResourceServerApplication │ ├── ResourceServerConfiguration │ ├── HelloController │ ├── UserController │ └── public.cert │ └── oauth2-client ├── OAuthClientApplication ├── WebMvcConfig ├── WebSecurityConfig ├── OAuthClientConfig ├── DemoController ├── index.html └── securedPage.html
建议按照这个顺序亲手验证 如果真的准备把这套 Demo 跑起来,不建议三个项目一把启动然后看结果。
最好按下面的顺序来。
第一阶段:
先测试:
能不能成功产生 Token。
第二阶段:
把 Token 解开,检查:
1 2 3 4 5 client_id user_name scope authorities exp
第三阶段:
分别测试:
1 2 3 GET /hello GET /user POST /user
确认:
分别在什么情况下出现。
第四阶段:
1 2 3 Password Grant Client Credentials Authorization Code
手动把三种流程跑一遍。
第五阶段:
再让:
自动完成同样的工作。
第六阶段:
同时启动两个 Client,观察 SSO。
这样你会发现 OAuth 2.0 不再是一堆:
1 2 3 4 code token scope grant_type
而是一条非常清晰的执行链。
总结 这套实战最有价值的地方,并不是“记住几个 Spring Security OAuth 配置类”,而是可以真正观察 OAuth 2.0 中各个组件如何协作。
Authorization Server 负责:
1 2 3 4 5 6 7 用户认证 Client Authentication Authorization Authorization Code Token Refresh Token JWT Sign
Resource Server 负责:
1 2 3 4 5 Bearer Token JWT Verify Authentication Authority Protected API
OAuth Client 负责:
1 2 3 4 5 6 Authorization Request Redirect 接收 Authorization Code 交换 Access Token 维护授权上下文 调用 Resource Server
而数据库则承担:
1 2 3 4 5 User Authority Client Authorization Code Approval
当我们把代码全部串起来以后,Authorization Code 流程实际上就是:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 用户访问 Client ↓ Client 跳转 Authorization Server ↓ 用户认证 ↓ 用户授权 ↓ Authorization Code ↓ Client 后端换 Access Token ↓ Resource Server 校验 Token ↓ Spring Security 权限判断 ↓ 业务资源授权
Client Credentials 又把其中的“用户”拿掉,变成:
1 2 3 4 5 6 7 Service ↓ Authorization Server ↓ Machine Access Token ↓ Another Service
JWT 则解决:
1 2 3 Authorization Server 签发 ↓ Resource Server 本地验证
真正掌握了这三个角色和这几条链路以后,再去学习 API Gateway、微服务服务间鉴权、OIDC、SSO、PKCE、Token Exchange,甚至迁移到新的 Spring Authorization Server,都会容易很多。因为框架的 API 会更新,但 OAuth 的角色、授权关系和 Token 流转边界不会因为一个注解被废弃就突然改变。