Spring Security OAuth2登錄
概述
OAuth 2.0 不是身份認證協(xié)議。
什么是身份認證?身份認證是解決“你是誰?”的問題。身份認證會告訴應用當前用戶是誰以及是否在使用此應用。實際中可能還會告訴你用戶的名稱,郵箱,手機號等。
如果對 OAuth 2.0 進行擴展,使得授權服務器和受保護資源發(fā)出的信息能夠傳達與用戶以及他們的身份認證上下文有關的信息,我們就可以為客戶端提供用于用戶安全登錄的所有信息。這種基于OAuth 2.0授權協(xié)議而構建的身份認證方式主要優(yōu)點:
- 用戶在授權服務器上執(zhí)行身份認證, 最終用戶的原始憑據(jù)不會通過 OAuth 2.0 協(xié)議傳送到客戶端應用。
- 允許用戶在運行時執(zhí)行同意決策。
- 用戶還可以將其他受保護 API 與他的身份信息的訪問權限一起授權出去。通過一個調(diào)用,應用就可以知道用戶是否已登錄,如何稱呼用戶,用戶的手機號,郵箱等。
本文我們將通過OAuth 2.0 授權碼模式安全的傳遞授權服務用戶信息,并登錄到客戶端應用。
本文您將學到:
搭建基本的授權服務和客戶端服務
自定義授權服務器訪問令牌,添加角色信息
自定義授權服務器用戶信息端點
客戶端服務使用
GrantedAuthoritiesMapper做權限映射客戶端服務自定義OAuth2UserService實現(xiàn)解析多層Json數(shù)據(jù)
OAuth2授權服務器
本節(jié)我們將使用Spring Authorization Server搭建一個授權服務器。除此之外我們還將會自定義access_token和自定義用戶信息端點。
maven
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
<version>2.6.7</version>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-authorization-server</artifactId>
<version>0.3.1</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>2.6.7</version>
</dependency>
配置
首先通過application.yml配置服務端口8080:
server:
port: 8080
接下來我們將創(chuàng)建OAuth2ServerConfig配置類,定義OAuth2 授權服務所需特定Bean。首先我們注冊一個OAuth2客戶端:
@Bean
public RegisteredClientRepository registeredClientRepository() {
RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("relive-client")
.clientSecret("{noop}relive-client")
.clientAuthenticationMethods(s -> {
s.add(ClientAuthenticationMethod.CLIENT_SECRET_POST);
s.add(ClientAuthenticationMethod.CLIENT_SECRET_BASIC);
})
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
.redirectUri("http://127.0.0.1:8070/login/oauth2/code/messaging-client-authorization-code")
.scope(OidcScopes.PROFILE)
.clientSettings(ClientSettings.builder()
.requireAuthorizationConsent(true)
.requireProofKey(false)
.build())
.tokenSettings(TokenSettings.builder()
.accessTokenFormat(OAuth2TokenFormat.SELF_CONTAINED)
.idTokenSignatureAlgorithm(SignatureAlgorithm.RS256)/
.accessTokenTimeToLive(Duration.ofSeconds(30 * 60))
.refreshTokenTimeToLive(Duration.ofSeconds(60 * 60))
.reuseRefreshTokens(true)
.build())
.build();
return new InMemoryRegisteredClientRepository(registeredClient);
}
以上將OAuth2客戶端存儲在內(nèi)存中,如果您需要使用數(shù)據(jù)庫持久化,請參考文章將JWT與Spring Security OAuth2結合使用。指定OAuth2客戶端信息如下:
- clientId: relive-client
- clientSecret: relive-client
- redirectUri: http://127.0.0.1:8070/login/oauth2/code/messaging-client-authorization-code
- scope: profile
接下來讓我們配置OAuth2授權服務其他默認配置,并對未認證的授權請求重定向到登錄頁面:
@Bean
@Order(Ordered.HIGHEST_PRECEDENCE)
public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
return http
.exceptionHandling(exceptions -> exceptions.
authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint("/login")))
.build();
}
授權服務器token令牌格式使用JWT RFC 7519,所以我們需要用于令牌的簽名密鑰,讓我們生成一個RSA密鑰:
@Bean
public JWKSource<SecurityContext> jwkSource() {
RSAKey rsaKey = Jwks.generateRsa();
JWKSet jwkSet = new JWKSet(rsaKey);
return (jwkSelector, securityContext) -> jwkSelector.select(jwkSet);
}
static class Jwks {
private Jwks() {
}
public static RSAKey generateRsa() {
KeyPair keyPair = KeyGeneratorUtils.generateRsaKey();
RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
return new RSAKey.Builder(publicKey)
.privateKey(privateKey)
.keyID(UUID.randomUUID().toString())
.build();
}
}
static class KeyGeneratorUtils {
private KeyGeneratorUtils() {
}
static KeyPair generateRsaKey() {
KeyPair keyPair;
try {
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
keyPairGenerator.initialize(2048);
keyPair = keyPairGenerator.generateKeyPair();
} catch (Exception ex) {
throw new IllegalStateException(ex);
}
return keyPair;
}
}
接下來我們將自定義access_token 訪問令牌,并在令牌中添加角色信息:
@Configuration(proxyBeanMethods = false)
public class AccessTokenCustomizerConfig {
@Bean
public OAuth2TokenCustomizer<JwtEncodingContext> tokenCustomizer() {
return (context) -> {
if (OAuth2TokenType.ACCESS_TOKEN.equals(context.getTokenType())) {
context.getClaims().claims(claim -> {
claim.put("role", context.getPrincipal().getAuthorities().stream()
.map(GrantedAuthority::getAuthority).collect(Collectors.toSet()));
});
}
};
}
}
可以看到Spring Security為我們提供了OAuth2TokenCustomizer用于擴展令牌信息,我們從OAuth2TokenContext獲取到當前用戶信息,并從中提取Authorities權限信息添加到JWT的claim。
下面我們將創(chuàng)建Spring Security配置類,配置授權服務基本的認證能力。
@Configuration(proxyBeanMethods = false)
public class DefaultSecurityConfig {
@Bean
public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/userInfo")
.access("hasAnyAuthority('SCOPE_profile')")
.mvcMatchers("/userInfo")
.access("hasAuthority('SCOPE_profile')")
.anyRequest().authenticated()
.and()
.formLogin(Customizer.withDefaults())
.oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt);
return http.build();
}
@Bean
public JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) {
return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource);
}
@Bean
public UserDetailsService users() {
UserDetails user = User.withDefaultPasswordEncoder()
.username("admin")
.password("password")
.roles("ADMIN")
.build();
return new InMemoryUserDetailsManager(user);
}
}
在上述配置類中,我們做了以下幾件事。1.啟用Form認證方式;2.配置登錄用戶名密碼;3.使用oauth2ResourceServer()配置JWT驗證,并聲明JwtDecoder;4.保護/userInfo端點需要profile權限進行訪問。
此時我們還需要創(chuàng)建Controller類,用于提供給OAuth2客戶端服務獲取用戶信息:
@RestController
public class UserInfoController {
@PostMapping("/userInfo")
public Map<String, Object> getUserInfo(@AuthenticationPrincipal Jwt jwt) {
return Collections.singletonMap("data", jwt.getClaims());
}
}
我們將用戶信息使用以下JSON格式返回:
{
"data":{
"sub":"admin"
...
}
}
OAuth2客戶端服務
本節(jié)將使用Spring Security配置OAuth2客戶端登錄;并且我們將使用GrantedAuthoritiesMapper映射權限信息;還將通過自定義實現(xiàn)OAuth2UserService替換原有DefaultOAuth2UserService,用于解析多層JSON 用戶信息數(shù)據(jù)。
maven
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>2.6.7</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
<version>2.6.7</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
<version>2.6.7</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
<version>2.6.7</version>
</dependency>
配置
首先我們指定客戶端服務端口號8070,并配置OAuth2客戶端相關信息:
server:
port: 8070
servlet:
session:
cookie:
name: CLIENT-SESSION
spring:
security:
oauth2:
client:
registration:
messaging-client-authorization-code:
provider: client-provider
client-id: relive-client
client-secret: relive-client
authorization-grant-type: authorization_code
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
scope: profile
client-name: messaging-client-authorization-code
provider:
client-provider:
authorization-uri: http://127.0.0.1:8080/oauth2/authorize
token-uri: http://127.0.0.1:8080/oauth2/token
user-info-uri: http://127.0.0.1:8080/userInfo
user-name-attribute: data.sub
user-info-authentication-method: form
接下來配置Spring Security相關Bean,首先我們先啟用Form表單認證和OAuth2登錄能力:
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests()
.anyRequest()
.authenticated()
.and()
.formLogin(from -> {
from.defaultSuccessUrl("/home");
})
.oauth2Login(Customizer.withDefaults())
.csrf().disable();
return http.build();
}
這里我們指定認證成功后重定向到/home路徑下。
下面我們使用GrantedAuthoritiesMapper映射用戶權限:
@Bean
GrantedAuthoritiesMapper userAuthoritiesMapper() {
//角色映射關系,授權服務器ADMIN角色對應客戶端OPERATION角色
Map<String, String> roleMapping = new HashMap<>();
roleMapping.put("ROLE_ADMIN", "ROLE_OPERATION");
return (authorities) -> {
Set<GrantedAuthority> mappedAuthorities = new HashSet<>();
authorities.forEach(authority -> {
if (OAuth2UserAuthority.class.isInstance(authority)) {
OAuth2UserAuthority oauth2UserAuthority = (OAuth2UserAuthority) authority;
Map<String, Object> userAttributes = oauth2UserAuthority.getAttributes();
List<String> role = (List) userAttributes.get("role");
role.stream().map(roleMapping::get)
.filter(StringUtils::hasText)
.map(SimpleGrantedAuthority::new)
.forEach(mappedAuthorities::add);
}
});
return mappedAuthorities;
};
}
上述將OAuth2授權服務ADMIN角色映射為客戶端角色OPERATION。當然你同樣可以擴展為數(shù)據(jù)庫操作,那么需要你維護授權服務角色與客戶端服務角色映射表,這里將不展開。
GrantedAuthoritiesMapper作為權限映射器在OAuth2登錄,CAS登錄,SAML和LDAP多方使用。
GrantedAuthoritiesMapper在OAuth2LoginAuthenticationProvider中源碼如下:
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
OAuth2LoginAuthenticationToken loginAuthenticationToken = (OAuth2LoginAuthenticationToken) authentication;
//...省略部分源碼
/* map authorities */
Collection<? extends GrantedAuthority> mappedAuthorities = this.authoritiesMapper
.mapAuthorities(oauth2User.getAuthorities());
/* map authorities */
OAuth2LoginAuthenticationToken authenticationResult = new OAuth2LoginAuthenticationToken(
loginAuthenticationToken.getClientRegistration(), loginAuthenticationToken.getAuthorizationExchange(),
oauth2User, mappedAuthorities, accessToken, authorizationCodeAuthenticationToken.getRefreshToken());
authenticationResult.setDetails(loginAuthenticationToken.getDetails());
return authenticationResult;
}
所以當我們自定義實現(xiàn)GrantedAuthoritiesMapper后,OAuth2 登錄成功后將映射后的權限信息存儲在認證信息Authentication的子類OAuth2LoginAuthenticationToken中,在后續(xù)流程中需要時獲取。
接下來將實現(xiàn)OAuth2UserService自定義DefaultJsonOAuth2UserService類。當然Spring Security提供了DefaultOAuth2UserService,那么為什么不使用它呢?原因很簡單,首先讓我們回顧授權服務器返回用戶信息格式:
{
"data":{
"sub":"admin"
...
}
}
不錯,用戶信息嵌套data字段中,而DefaultOAuth2UserService處理用戶信息響應時并沒有處理這個格式,以下是DefaultOAuth2UserService源碼:
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
Assert.notNull(userRequest, "userRequest cannot be null");
if (!StringUtils.hasText(userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUri())) {
OAuth2Error oauth2Error = new OAuth2Error("missing_user_info_uri", "Missing required UserInfo Uri in UserInfoEndpoint for Client Registration: " + userRequest.getClientRegistration().getRegistrationId(), (String)null);
throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
} else {
String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName();
if (!StringUtils.hasText(userNameAttributeName)) {
OAuth2Error oauth2Error = new OAuth2Error("missing_user_name_attribute", "Missing required \"user name\" attribute name in UserInfoEndpoint for Client Registration: " + userRequest.getClientRegistration().getRegistrationId(), (String)null);
throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
} else {
RequestEntity<?> request = (RequestEntity)this.requestEntityConverter.convert(userRequest);
/* 獲取用戶信息 */
ResponseEntity<Map<String, Object>> response = this.getResponse(userRequest, request);
//在這里直接獲取響應體信息,默認此userAttributes包含相關用戶信息,并沒有解析多層JSON
Map<String, Object> userAttributes = (Map)response.getBody();
/* 獲取用戶信息 */
Set<GrantedAuthority> authorities = new LinkedHashSet();
authorities.add(new OAuth2UserAuthority(userAttributes));
OAuth2AccessToken token = userRequest.getAccessToken();
Iterator var8 = token.getScopes().iterator();
while(var8.hasNext()) {
String authority = (String)var8.next();
authorities.add(new SimpleGrantedAuthority("SCOPE_" + authority));
}
return new DefaultOAuth2User(authorities, userAttributes, userNameAttributeName);
}
}
}
而最后創(chuàng)建DefaultOAuth2User時,你可能會收到以下錯誤信息
Missing attribute 'sub' in attributes
通過上面源碼,Spring Security 所希望返回的用戶信息格式:
{
"sub":"admin",
...
}
但是實際中,我們開發(fā)時通常會統(tǒng)一返回響應格式。例如:
{
"code":200,
"message":"success",
"data":{
"sub":"admin",
...
}
}
下面我們是我們通過以userNameAttributeName以 . 為分割符,提取用戶信息實現(xiàn),以下只展示部分代碼,其余代碼和DefaultOAuth2UserServicey源碼相同。
首先我們新建工具類JsonHelper用于解析Json
@Slf4j
public class JsonHelper {
private static final JsonHelper.MapTypeReference MAP_TYPE = new JsonHelper.MapTypeReference();
private static ObjectMapper mapper;
private JsonHelper() {
}
static {
mapper = new ObjectMapper();
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
}
public static JsonNode getFirstNode(final JsonNode node, final String path) {
JsonNode resultNode = null;
if (path != null) {
resultNode = getElement(node, path);
}
return resultNode;
}
public static JsonNode getElement(final JsonNode json, final String name) {
if (json != null && name != null) {
JsonNode node = json;
for (String nodeName : name.split("\\.")) {
if (node != null) {
if (nodeName.matches("\\d+")) {
node = node.get(Integer.parseInt(nodeName));
} else {
node = node.get(nodeName);
}
}
}
if (node != null) {
return node;
}
}
return null;
}
public static Map<String, Object> parseMap(String json) {
try {
return mapper.readValue(json, MAP_TYPE);
} catch (JsonProcessingException e) {
log.error("Cannot convert json to map");
}
return null;
}
private static class MapTypeReference extends TypeReference<Map<String, Object>> {
private MapTypeReference() {
}
}
}
新建DefaultJsonOAuth2UserService實現(xiàn)OAuth2UserService,添加多層JSON提取用戶信息邏輯:
public class DefaultJsonOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
//...
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
//...省略部分代碼
RequestEntity<?> request = this.requestEntityConverter.convert(userRequest);
ResponseEntity<JsonNode> response = getResponse(userRequest, request);
JsonNode responseBody = response.getBody();
//多層JSON提取用戶信息屬性
Map<String, Object> userAttributes = new HashMap<>();
if (userNameAttributeName.contains(".")) {
String firstNodePath = userNameAttributeName.substring(0, userNameAttributeName.lastIndexOf("."));
userAttributes = this.extractUserAttribute(responseBody, firstNodePath);
userNameAttributeName = userNameAttributeName.substring(firstNodePath.length() + 1);
} else {
userAttributes = JsonHelper.parseMap(responseBody.toString());
}
//...省略部分代碼
}
}
如您需要參考詳細代碼,請查閱文末源碼鏈接獲取。
最后我們創(chuàng)建Controller類,使用thymeleaf引擎構建首頁信息,不同權限信息看到首頁列表結果不同:
@Controller
public class HomeController {
private static Map<String, List<String>> articles = new HashMap<>();
static {
articles.put("ROLE_OPERATION", Arrays.asList("Java"));
articles.put("ROLE_SYSTEM", Arrays.asList("Java", "Python", "C++"));
}
@GetMapping("/home")
public String home(Authentication authentication, Model model) {
String authority = authentication.getAuthorities().iterator().next().getAuthority();
model.addAttribute("articles", articles.get(authority));
return "home";
}
}
測試
我們啟動服務后,訪問http://127.0.0.1:8070/login, 首先使用用戶名密碼登錄,您將會看到:

之后我們退出登錄使,用OAuth2 登錄,您將會看到不同信息:

結論
我們使用OAuth2.0 授權協(xié)議上構建身份認證證明是可行的。但是我們不能忽略在這之間的陷阱。
令牌本身并不傳遞有關身份認證事件的信息。令牌可能是直接頒發(fā)給客戶端的,使用的是無須用戶交互的 OAuth 2.0 客戶端憑據(jù)模式。
客戶端都無法從訪問令牌中得到關于用戶及其登錄狀態(tài)的信息。OAuth 2.0 訪問令牌的目標受眾是資源服務器。(在本文中我們使用JWT訪問令牌,通過自定義訪問令牌信息使客戶端服務獲取用戶權限等信息,但是OAuth2.0 協(xié)議中并沒有定義訪問令牌格式,我們僅是使用了JWT的特性來做到這一點。)
客戶端可以出示訪問令牌給資源服務獲取用戶信息,所以很容易就認為只要擁有一個有效的訪問令牌,就能證明用戶已登錄,這一思路僅在某些情況下是正確的,即用戶在授權服務器上完成身份認證,剛生成訪問令牌的時候。(因為訪問令牌有效期可能遠長與身份認證會話有效期)
基于OAuth2.0的用戶信息API的最大問題是,不同身份提供者實現(xiàn)用戶信息API必然不同。用戶的唯一標識可能是“user_id",也可能是“sub”。
所以我們需要統(tǒng)一的OAuth2.0為基礎的標準身份認證協(xié)議。OpenID Connect 是一個開放標準,它定義了一種使用 OAuth 2.0 執(zhí)行用戶身份認證的互通方式。這將在后續(xù)文章中介紹它。
與往常一樣,本文中使用的源代碼可在 GitHub 上獲得。