Spring Authorization Server 1.1.1 学习记录
该版本所推荐的认证模式都有哪些?
- 客户端模式(Client Credentials)
- 授权码模式(Authorization Code)
- 授权码模式 + PKCE (Authorization Code + PKCE)
- 刷新码模式(refresh token)(这种就不做记录了)
- 设备码模式(Device Code)
准备:首先搭建好认证服务端 Authorization Server 。这里使用Spring Boot 整合 pom
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-authorization-server</artifactId>
</dependency>
往数据库中插入三种客户端记录。我这使用Java进行插入的:
@Bean
@Primary
public RegisteredClientRepository registeredClientRepository(JdbcTemplate jdbcTemplate) {
JdbcRegisteredClientRepository registeredClientRepository = new JdbcRegisteredClientRepository(jdbcTemplate);
BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
registeredClientRepository.save(RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("client-client")
.clientName("client-client")
.clientIdIssuedAt(Instant.now())
.clientSecret(passwordEncoder.encode("client-client"))
.clientSecretExpiresAt(Instant.now().plus(14, ChronoUnit.DAYS))
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST)
.authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
.postLogoutRedirectUri("http://127.0.0.1:8080/logout")
.scope("message.read")
.clientSettings(ClientSettings.builder().requireAuthorizationConsent(false).build())
.tokenSettings(TokenSettings.builder()
//access token 有效期
.accessTokenTimeToLive(Duration.ofMinutes(60))
.build())
.build());
// 授权码模式
registeredClientRepository.save(RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("code-client")
.clientName("code-client")
.clientIdIssuedAt(Instant.now())
.clientSecret(passwordEncoder.encode("code-client"))
.clientIdIssuedAt(Instant.now())
.clientSecretExpiresAt(Instant.now().plus(14, ChronoUnit.DAYS))
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
.redirectUri("http://127.0.0.1:8080/callback")
.postLogoutRedirectUri("http://127.0.0.1:8080/logout")
.scope(OidcScopes.OPENID)
.scope(OidcScopes.PROFILE)
.scope("message.read")
.scope("message.write")
.clientSettings(ClientSettings.builder().requireAuthorizationConsent(true).build())
.tokenSettings(TokenSettings.builder()
//access token 有效期
.accessTokenTimeToLive(Duration.ofMinutes(60))
//refresh token 有效期
.refreshTokenTimeToLive(Duration.ofDays(7))
//执行刷新token时, 是否返回新的refresh token (默认true 既是重用 refresh token; false 则生成新的 refresh token 及有效期)
.reuseRefreshTokens(true)
.build())
.build());
//
// // 授权码 + PKCE 模式
registeredClientRepository.save(RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("oidc-client")
.clientIdIssuedAt(Instant.now())
.clientSecret(passwordEncoder.encode("oidc-client"))
.clientSecretExpiresAt(Instant.now().plus(14, ChronoUnit.DAYS))
.clientName("oidc-client")
.redirectUri("http://127.0.0.1:8080/callback")
.postLogoutRedirectUri("http://127.0.0.1:8080/logout")
//客户端认证方 none - 若开启PKCE 认证, 则需要添加 none,认证方法
.clientAuthenticationMethod(ClientAuthenticationMethod.NONE)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
.scope(OidcScopes.OPENID)
.scope(OidcScopes.PHONE)
.scope(OidcScopes.EMAIL)
.scope(OidcScopes.PROFILE)
.scope(OidcScopes.ADDRESS)
.scope("offline_access")
.clientSettings(ClientSettings.builder()
.requireAuthorizationConsent(true)
//是否需要开启PKCE模式
.requireProofKey(true)
.build())
.tokenSettings(TokenSettings.builder()
//access token 有效期
.accessTokenTimeToLive(Duration.ofMinutes(60))
.build())
.build());
//设备码模式
registeredClientRepository.save(RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("device-client")
.clientIdIssuedAt(Instant.now())
.clientName("device-client")
.postLogoutRedirectUri("http://127.0.0.1:8080/logout")
.clientAuthenticationMethod(ClientAuthenticationMethod.NONE)
.authorizationGrantType(AuthorizationGrantType.DEVICE_CODE)
.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
.scope("message.read")
.clientSettings(ClientSettings.builder()
.requireAuthorizationConsent(true)
.build())
.tokenSettings(TokenSettings.builder()
.accessTokenTimeToLive(Duration.ofMinutes(60))
//refresh token 有效期
.refreshTokenTimeToLive(Duration.ofDays(7))
//执行刷新token时, 是否返回新的refresh token (默认true 既是重用 refresh token; false 则生成新的 refresh token 及有效期)
.reuseRefreshTokens(true)
.build())
.build()
);
return registeredClientRepository;
}
1. 客户端模式(client_credentials)
该模式最为简单。需要后端服务作为支持,可以存储 client_id
和 client_secret
,认证流程为携带client_id, client_secret 加上基本参数grant_type
(该模式下为: client_credentials
) 直接进行访问。样例为下面所示:
备注:客户端模式不支持 refresh token
,所以响应数据中没有refresh token
2. 授权码模式(Authorization Code)
该模式的流程不过多进行赘述了,可以自行查看文档或者百度有很多流程介绍,下面的PKCE在做详细介绍。引导客户端做认证,回调给客户端携带 code
,客户端再携带code,client_secret 等请求access token
, 该模式支持 refresh token
- 先调用
/oauth2/authorize
接口,服务端引导客户到认证界面进行身份认证。
127.0.0.1:8080/oauth2/authorize?response_type=code&client_id=code-client&scope=message.read&state=code&redirect_uri=http://127.0.0.1:8080/callback
- 登录成功后进入授权确认界面
- 确认后回携带code回调到回调地址
- 接下来使用 code 请求
access token
,这里要把回调地址再次传递给服务端,grant_type 为:authorization_code
,这次client_secret 使用client_secret_basic
来传输
3. 授权码模式 + PKCE(Authorization Code + PKCE)
该模式需要做一个详细的说明:PKCE (Proof Key for Code Exchange),授权码模式需要客户端保存client_id 和 client_secret 认证流程虽然完善,但是无法给公开的客户端进行使用。因为公开的客户端没办法保存好client_secret。PKCE就是解决这个问题的。所以该模式下获取access token 时不再传递client_secret, 而是使用 code_verifier 代替,code_verfier 是由公共客户端临时生成的随机字符串。
因此该模式的流程就是首先公共客户端生成 code_verifier
, 然后使用加密方式对 code_verifier 进行加密,再转Base64,生成 code_challenge
,
在访问 /oauth2/authorize 的时候将 code_challenge
和 加密方法 code_challenge_method
如 SHA256 则传入S256。然后服务端引导用户做登录授权,之后服务端回调到回调地址携带生成的 code
, 这里就和授权码模式一样了,接下来,再访问服务端 /oauth2/token 来获取token,需要携带 code
,code_verifier
, 具体流程如图所示:
说明:Base64(SHA256(code_verifier)) 被称作为code_challenge
。code_verifier
是43-128位的随机字符,字符范围是/A-Z/a-z/0-9/"."/"-"/"_"/"~"
接下来演示下认证流程:
- 先放个生成 code_verifier 的 Java 工具类
@Getter
public class RandomCodeVerifier {
private static final Random r = new Random();
private static final char[] lowerChar = { 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'm', 'n', 'p', 'q',
'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z' };
private static final char[] upperChar = { 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'J', 'K', 'L', 'M', 'N', 'P', 'Q',
'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z' };
private static final char[] numberChar = { '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' };
private static final char[] symbolChar = { '.', '-', '_', '~'};
private static final int MAX_CODE_VERIFIER_LENGTH = 128;
private static final int MIN_CODE_VERIFIER_LENGTH = 43;
/**
* 获取code verifier
* @return code verifier
*/
public static String getCodeVerifier() {
Random random = new Random();
int i = random.nextInt(MAX_CODE_VERIFIER_LENGTH - MIN_CODE_VERIFIER_LENGTH + 1) + MIN_CODE_VERIFIER_LENGTH;
return getRandomString(i);
}
/**
* 获取随机字符串,包含大小写字母和数字,可以有重复字符
*
* @param strLength 字符串长度
*/
public static String getRandomString(int strLength) {
return getRandomString(strLength, true);
}
/**
* 获取随机字符串
*
* @param strLength 字符串长度
* @param repeat 是否可以有重复字符,true表示可以重复,false表示不允许重复。如果生成字符长度大于可用字符数量则默认采用true值。
*/
public static String getRandomString(int strLength, boolean repeat) {
StringBuilder result = new StringBuilder();
char[] validChar = null;// 可用字符数组
validChar = Arrays.copyOf(lowerChar, lowerChar.length + upperChar.length + numberChar.length + symbolChar.length);
System.arraycopy(upperChar, 0, validChar, lowerChar.length, upperChar.length);
System.arraycopy(numberChar, 0, validChar, lowerChar.length + upperChar.length, numberChar.length);
System.arraycopy(symbolChar, 0, validChar, lowerChar.length + upperChar.length + numberChar.length, symbolChar.length);
if (strLength > validChar.length) {// 字符串长度大于可用字符数量
repeat = true;// 字符可重复
}
if (repeat) {
for (int i = 0; i < strLength; i++) {
result.append(validChar[r.nextInt(validChar.length)]);
}
} else {
HashSet<Integer> indexset = new HashSet<Integer>();
int index = 0;
for (int i = 0; i < strLength; i++) {
do {
index = r.nextInt(validChar.length);// 随机获得一个字符的索引
} while (indexset.contains(index));// 如果已经使用过了,则重新获得
result.append(validChar[index]);
indexset.add(index);// 记录已使用的字符索引
}
}
return result.toString();
}
/**
* 获取随机字符串
*
* @param strLength 字符串长度
* @param repeat 是否可以存在重复的字符
* @param ch 自定义字符集,可传入多个字符数组
*/
public static String getRandomString(int strLength, boolean repeat, char[]... ch) {
StringBuilder result = new StringBuilder();
HashSet<Character> validChar = new HashSet<>();
for (char[] chars : ch) {
for (char aChar : chars) {
validChar.add(aChar);
}
}
if (validChar.isEmpty()) {
return "";
}
if (strLength > validChar.size()) {// 字符串长度大于可用字符数量
repeat = true;// 字符可重复
}
List<Character> list = new LinkedList<>(validChar);
for (int i = 0; i < strLength; i++) {
if (repeat) {
result.append(list.get(r.nextInt(list.size())));
} else {
result.append(list.remove(r.nextInt(list.size())));
}
}
return result.toString();
}
}
- 生成 code_verifier 访问
/oauth2/authorize
http://127.0.0.1:8080/oauth2/authorize?response_type=code&client_id=oidc-client&redirect_uri=http://127.0.0.1:8080/callback&state=oidc&scope=openid+profile+email+phone&code_challenge_method=S256&code_challenge=_A4xg9nNxk0hlwpOlBnldncuQZG6S_ebxNjxT6j6gfM
其格式大概是这样
服务端引导用户做用户认证
这里确认授权
回调携带code参数, 我们把 code 复制下来 ,用 postman 来访问 /oauth2/token
接口
到这里就访问结束了。我们能看到,返回了 除 access_token 外的 id_token
id_token
: 身份令牌(JWT),用于认证,标识用户身份已经认证,可用于获取用户身份信息(如用户名、头像等),同时客户端需对获取到的ID Token进行签名验证、属性验证(aud、azp、nonce等)。
问题:该模式下貌似无法使用 refresh_token
, 在找了一些资料发现,说是可以支持, 需要访问/oauth2/authorize
scope 中传递 offline_access
才可以获取到 refresh_token,在经过多次尝试后,发现不能获取。再看下Spring Authorization Server 源码中发现,client属性中 client_authentication_methods
如果为none
则不签发 refresh_token, 和网上很多人都不太一样, 但是OIDC模式该字段必须是none,因为没有 client_secret。源码如图所示:
在github 上 issue 上 确实也发布相关的说明,考虑到 公共客户端模式 refresh token 泄露风险, 默认是不开放该模式下签发 refresh token, 这里说明了不支持, 并且说明了针对 Public Client 也没有计划实现 refresh tokens
Spring Authorization Server issue-297 中回复了 公共客户端 refresh token 的问题
由此看来,想要公共客户端签发 refresh token 只能自定义OAuth2AuthorizationCodeAuthenticationProvider
。 这个话题可以以后有时间试试。
4. 设备码模式(Device Code)
OAuth2.0 Device Code 模式官方文档
Spring Authorization Server Demo
设备码模式是为了可以连接网络的设备但是缺少浏览器去引导用户授权或者输入受限。让用户去输入文本是不切实际的。该模式支持像智能电视,媒体控制台,打印机等,获得用户授权才能访问一些资源。具体搭建该模式的方式请参考上述Spring Authorization Server 的 Demo 基本流程如下:
- A: 客户端携带
client_id
,scope
, 访问/oauth2/device_authorization
来获取deivce code
,user code
,verification uri
- B: 服务端生成 device code,user code,verification uri 返回给客户端
- C: 客户端拿到user code ,verification uri 引导用户去访问,然后认证(例如生成二维码)
- D: 用户登录并授权
- E: 在C,D 步骤期间,客户端轮询访问服务端
/oauth2/token
接口 来获取access_token
,失败则返回未认证,成功则返回相关token - F: 返回
access_token
和可选的refresh_token
接下来实践一下:
先访问 /oauth2/device_authorization
参数说明:
- user_code: 生成的用户编码,访问
verification_uri
时需要填写进去(如果访问verification_uri_complete
则不需要手动拼接)一般来说直接用verification_uri_complete生成二维码直接让用户用手机扫描即可,具体看业务情况而定。 - device_code: 由客户端保留,在访问token时需要作为参数传给服务端。
- verification_uri:让用户直接访问的地址。
访问 verification_uri
跳转到登录
登录成功跳转授权界面
授权成功
接下来我们就可以访问 /oauth2/token
接口来获取token了
到这里我们就获取到了 token,我们在试验一下 没有通过授权时获取 token 的返回结果
可以看到返回结果是这样子的,authorization_pending,下面是文档地址。源码GitHub
总结
到这里我们除了 refresh token 来获取 access token 外的所有方式都学习完毕,有疑惑的可以提出来,一起探讨一下。