A9.Spring Security OAuth2 Authorization Server简明教程与微服务的融合实践
前言
这是一个基于Java 21 的 六边形架构与领域驱动设计的一个通用项目,并且结合现有的最新版本技术架构实现了 领域驱动设计模式和六边形架构模式组件定义. 并且结合微服务,介绍了领域层,领域事件,资源库,分布式锁,序列化,安全认证,日志等,并提供了实现功能. 并且我会以日常发布文章和更新代码的形式来完善它.
简介
上篇我们介绍了Spring Security 怎么认证,鉴权并且了解了OAuth2的认证,因为当前我们的系统的会员用户来源于各大集团,又在支撑各大业务线(商业,物业,地产,产业,公寓,酒店等等). 原统一会员系统是PHP团队做的,并且支撑多个业务系统, 但没有使用标准的认证方式。所以就有了这个模块的诞生。该模块的代码在 common-oauth2-authorization-server-with-spring-security
目录.
Spring Security OAuth2 Authorization Server
初探
首先我们来了解下 OAuth2.1的其中3种模式:
-
授权码模式 (Authorization Code): 是我们最常用的一种模式(WEB和APP端),它包含两个部分,一个是客户端,一个是用户。客户端向用户请求授权,用户同意授权,客户端再向服务端请求令牌。
-
设备码模式(Device Code): 是一种交互式模式,它包含两个部分,一个是客户端,一个是用户。客户端向用户请求授权,用户同意授权,客户端再向服务端请求令牌。
-
客户端模式(Client Credentials): 客户端向服务端请求令牌,服务端直接返回令牌。 适用于微服务内部调用时,可以使用这种模式。
授权码模式
然后我们搭建一个简单的OAuth2的认证服务器,并且使用Spring Security OAuth2 Authorization Server来实现。 application.yml 配置如下:
[source, yml
server: port: 8081 #spring: # security: # user: # name: "user" # password: "password" # roles: # - "USER" # oauth2: # authorizationserver: # client: # login-client: # registration: # client-id: "login-client" # client-secret: "{noop}openid-connect" # client-authentication-methods: # - "client_secret_basic" # authorization-grant-types: # - "authorization_code" # - "refresh_token" # redirect-uris: # - "https://www.iokays.com" # scopes: # - "openid" # - "profile" # require-authorization-consent: true
这样就可以直接启动一个OAuth2的认证服务器,无需其他Java配置。 建议大家看看 AuthorizationServerSettings.Builder
这个类,熟悉对外提供的默认端口名。
-
当处在客户端的浏览器登录跳转到该链接: http://localhost:8081/oauth2/authorize?client_id=login-client&response_type=code&scope=openid&state=abc&redirect_uri=https://www.iokays.com 相当于从 客户端发起请求,请求授权。
-
拿到
code
,通过客户端[不是浏览器]内部,执行(oauth2/token)POST请求,提交给授权服务器,拿到令牌。 在上面的配置client-authentication-method= client_secret_basic. 所以我们需要在请求头中添加 Authorization: Basic . 而不是POST表单代入(client-id, client-secret)的方式。
package com.iokays;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestTemplate;
import java.util.Base64;
/**
* 这不是测试类(一个严格且具有断言,可重复执行测试用例的类),只是使用@Test 来方便运行方法。
*/
@Slf4j
class TokenSample {
private final RestTemplate restTemplate = new RestTemplate();
private final ObjectMapper objectMapper = new ObjectMapper();
/**
* <a href="http://localhost:8080/oauth2/token"/>
* client_secret_basic, authorization_code
*/
@Test
@DisplayName("获取令牌")
void testToken() {
final var code = ConfigProperties.value("code");
// 设置请求头 (因为client-authentication-method配置的是: client_secret_basic)
final var headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
headers.add(HttpHeaders.AUTHORIZATION, "Basic " + Base64.getEncoder().encodeToString(("login-client:openid-connect").getBytes()));
// 设置请求参数
MultiValueMap<String, String> map = new LinkedMultiValueMap<>();
map.add("grant_type", "authorization_code");
map.add("code", code);
map.add("redirect_uri", "https://www.iokays.com");
map.add("scope", "openid");
final var requestEntity = new HttpEntity<>(map, headers);
final String url = "http://localhost:8080/oauth2/token";
final var response = restTemplate.postForObject(url, requestEntity, String.class);
log.info("response: {}", response);
}
@Test
@DisplayName("刷新令牌, 和获取令牌的接口具有相同的返回结构")
void testRefreshToken() {
// 设置请求头 (因为client-authentication-method配置的是: client_secret_basic)
final var headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
headers.add(HttpHeaders.AUTHORIZATION, "Basic " + Base64.getEncoder().encodeToString(("login-client:openid-connect").getBytes()));
// 设置请求参数
MultiValueMap<String, String> map = new LinkedMultiValueMap<>();
map.add("grant_type", "refresh_token");
map.add("refresh_token", ConfigProperties.value("refresh_token"));
final var requestEntity = new HttpEntity<>(map, headers);
final String url = "http://localhost:8080/oauth2/token";
final var response = restTemplate.postForObject(url, requestEntity, String.class);
log.info("response: {}", response);
}
}
其返回的结果
{"access_token":"访问令牌,用于客户端向资源服务器请求受保护资源",
"refresh_token":"刷新令牌,用于获取新的访问令牌。",
"scope":"访问令牌的权限范围。",
"id_token":"是一个 JWT(JSON Web Token),其中包含了用户的身份信息和一些其他元数据",
"token_type":"访问令牌的类型: Bearer",
"expires_in": "访问令牌的权限范围: openid"
}
我们可以解析id_token的数据,来获取用户的信息,而不必调用资源服务器所提供的API。
package com.iokays;
import java.util.Base64;
public class IdTokenDecoder {
/**
* <a href="https://jwt.io/">校验jwt</a>
*
* @param args
*/
public static void main(String[] args) {
final var idToken = ConfigProperties.value("code");
String[] parts = idToken.split("\\.");
if (parts.length == 3) {
String header = new String(Base64.getUrlDecoder().decode(parts[0]));
String payload = new String(Base64.getUrlDecoder().decode(parts[1]));
System.out.println("Header: " + header);
/**
* {
* "sub": "user", // 主体标识符,通常是用户ID
* "aud": "login-client", // 接收令牌的受众,通常是客户端ID
* "azp": "login-client", // 授权方,通常是客户端ID
* "auth_time": 1731390028, // 认证时间,Unix时间戳
* "iss": "http://localhost:8080", // 发行者,通常是授权服务器的URL
* "exp": 1731393365, // 过期时间,Unix时间戳
* "iat": 1731391565, // 签发时间,Unix时间戳
* "jti": "60e4105a-efff-4258-9c85-b5e28a0772bd", // 令牌唯一标识符
* "sid": "NQCryYJRihZRpxRUFuB7gZx8tOggii0zbSOVunJw_HI" // 会话ID
* }
*/
System.out.println("Payload: " + payload);
} else {
System.out.println("Invalid JWT format.");
}
}
}
这样我们就得到了令牌,客户端(非用户代理)就可以调用受保护的资源,比如资源服务器的用户信息,这样就可以结合客户端自己的用户体系完成用户登录认证。并将授权服务器返回的令牌存储在客户端并与用户绑定,以便后续调用受保护的资源。
现在我们看看Spring Security OAuth2 Authorization Server提供的一些默认端口,并一一来验证。
authorizationEndpoint("/oauth2/authorize") // 授权端点, 已调用 tokenEndpoint("/oauth2/token") // 令牌端点, 已调用
tokenRevocationEndpoint("/oauth2/revoke") // 令牌吊销端点 tokenIntrospectionEndpoint("/oauth2/introspect") // 令牌rospect端点
package com.iokays;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestTemplate;
import java.util.Base64;
/**
* 这不是测试类(一个严格且具有断言,可重复执行测试用例的类),只是使用@Test 来方便运行方法。
*/
@Slf4j
class TokenOtherSample {
private final RestTemplate restTemplate = new RestTemplate();
@Test
@DisplayName("吊销令牌, 返回状态码200")
void testRefreshToken() {
// 设置请求头 (因为client-authentication-method配置的是: client_secret_basic)
final var headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
headers.add(HttpHeaders.AUTHORIZATION, "Basic " + Base64.getEncoder().encodeToString(("login-client:openid-connect").getBytes()));
// 设置请求参数
MultiValueMap<String, String> map = new LinkedMultiValueMap<>();
map.add("token", ConfigProperties.value("accessToken"));
final var requestEntity = new HttpEntity<>(map, headers);
final String url = "http://localhost:8080/oauth2/revoke";
final var response = restTemplate.postForObject(url, requestEntity, String.class);
log.info("response: {}", response);
}
/**
* {
* "active": true, // Token 的活跃状态,true 表示该令牌是有效的
* "sub": "user", // Subject(用户标识符),通常为用户的唯一标识
* "aud": ["login-client"], // Audience(受众),此令牌颁发的目标客户端(这里是 "login-client")
* "nbf": 1731390102, // Not Before,UNIX 时间戳,表示该令牌在此时间之前无效
* "scope": "openid", // 权限范围,指令牌授予的权限(这里是 "openid",用于获取用户身份信息)
* "iss": "http://localhost:8080",// Issuer(发布者),签发该令牌的授权服务器地址
* "exp": 1731390402, // Expiration,UNIX 时间戳,指该令牌的过期时间
* "iat": 1731390102, // Issued At,UNIX 时间戳,指该令牌的签发时间
* "jti": "f5718312-2d93-40fa-b51d-5ef157e028fd", // + JWT ID,用于唯一标识该令牌的标识符
* "client_id": "login-client", // 客户端 ID,表示令牌授权的客户端
* "token_type": "Bearer" // Token 类型,Bearer 表示该令牌为承载令牌,客户端需在请求头中携带它进行授权
* }
*/
@Test
@DisplayName("检查访问令牌")
void testIntrospectToken() {
// 设置请求头 (因为client-authentication-method配置的是: client_secret_basic)
final var headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
headers.add(HttpHeaders.AUTHORIZATION, "Basic " + Base64.getEncoder().encodeToString(("login-client:openid-connect").getBytes()));
// 设置请求参数
MultiValueMap<String, String> map = new LinkedMultiValueMap<>();
map.add("token", ConfigProperties.value("accessToken"));
final var requestEntity = new HttpEntity<>(map, headers);
final String url = "http://localhost:8080/oauth2/introspect";
final var response = restTemplate.postForObject(url, requestEntity, String.class);
log.info("response: {}", response);
}
}
基于最简单的application.yml(是放在OAuth2AuthorizationServerConfiguration加载的)配置的演示就告一段落,现在我们使用Java 配置来演示。并添加一些自定义的配置。如果直接映射过来会少掉一些默认的配置,我们可以查下看 Spring Boot 提供的OAuth2AuthorizationServerWebSecurityConfiguration.authorizationServerSecurityFilterChain. 还原配置或者查看这个类的注解(ConditionalOnBean),怎么实现可以触发自动化配置部分, 然后将application.yml配置改为Java配置。
package com.iokays;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
import org.springframework.security.oauth2.core.oidc.OidcScopes;
import org.springframework.security.oauth2.server.authorization.client.InMemoryRegisteredClientRepository;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings;
import org.springframework.security.oauth2.server.authorization.settings.ClientSettings;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import java.util.UUID;
/**
* 该部分的用例配置是和application.yml文件中的配置一一对应最小配置。
*/
@Configuration
@EnableWebSecurity
public class AuthorizationServerConfig {
@Bean
public UserDetailsService userDetailsService() {
var user = User.withUsername("user")
.password("{noop}password")
.roles("USER")
.build();
return new InMemoryUserDetailsManager(user);
}
@Bean
public RegisteredClientRepository registeredClientRepository() {
RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("login-client")
.clientSecret("{noop}openid-connect")
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
.redirectUri("https://www.iokays.com")
.redirectUri("http://localhost:8082/login/oauth2/code/local") //本机联调使用
.scope(OidcScopes.OPENID)
.scope(OidcScopes.PROFILE)
// 是否需要用户授权,当只需要openid scope时,不会出现授权页面
.clientSettings(ClientSettings.builder().requireAuthorizationConsent(true).build())
.build();
return new InMemoryRegisteredClientRepository(registeredClient);
}
@Bean
public AuthorizationServerSettings authorizationServerSettings() {
return AuthorizationServerSettings.builder().build();
}
}
这样,我们就保持了和原application.yml配置相同的效果。
上面我们演示了怎么使用Spring Security OAuth2 Authorization Server来实现OAuth2.1的认证服务器和Token的操作。 现在我们看看怎么获取用户信息,来支撑业务。
自定义客户端
客户端 Spring Security OAuth2 Service 也提供了JdbcRegisteredClientRepository的实现,我们可以使用这个来管理客户端或者自定义它,就不用多说了。
获取用户信息
我们注意到scope=openid,这个scope是OAuth2.1中定义的一种权限范围,当授权成功后,我们就可以额外得到id_token,这个id_token中包含了用户的唯一标识(sub). 如果我们需要用户的其他信息, 有基本的两种方式:
-
通过id_token解析,获取用户信息。
-
通过
userinfo
端口,调用资源服务器提供的API来获取用户信息。
通过id_token,其实官网已经有介绍,这里只需要只需要如下配置即可:
package com.iokays;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.core.oidc.OidcUserInfo;
import org.springframework.security.oauth2.core.oidc.endpoint.OidcParameterNames;
import org.springframework.security.oauth2.server.authorization.token.JwtEncodingContext;
import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenCustomizer;
/**
* 该部分是扩展配置,注释掉该类,系统也能以最小的配置正常运行
*/
@Configuration
public class IdTokenCustomizerConfig {
/**
* @param userInfoService 改为我们自定义的用户信息服务
* @return
*/
@Bean
public OAuth2TokenCustomizer<JwtEncodingContext> tokenCustomizer(OidcUserInfoService userInfoService) {
return (context) -> {
if (OidcParameterNames.ID_TOKEN.equals(context.getTokenType().getValue())) {
OidcUserInfo userInfo = userInfoService.loadUser(
context.getPrincipal().getName());
context.getClaims().claims(claims ->
claims.putAll(userInfo.getClaims()));
}
};
}
}
通过`userinfo`端口,其端口的位置可以设置在授权服务器或者资源服务器两个位置。但是配置起来就比较麻烦,需要自定义配置,要将以前自动配置以手动的形式在配置一遍。
package com.iokays;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.http.MediaType;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.oauth2.core.oidc.OidcUserInfo;
import org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers.OAuth2AuthorizationServerConfigurer;
import org.springframework.security.oauth2.server.authorization.oidc.authentication.OidcUserInfoAuthenticationContext;
import org.springframework.security.oauth2.server.authorization.oidc.authentication.OidcUserInfoAuthenticationToken;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
import org.springframework.security.web.util.matcher.MediaTypeRequestMatcher;
import org.springframework.security.web.util.matcher.RequestMatcher;
import java.util.Set;
import java.util.function.Function;
import static org.springframework.security.config.Customizer.withDefaults;
/**
* JWT UserInfo Mapper, authorizationServerSecurityFilterChain 这里就是重新了Spring boot的默认配置+userinfo功能
*/
@Configuration(proxyBeanMethods = false)
public class JwtUserInfoMapperSecurityConfig {
private static RequestMatcher createRequestMatcher() {
MediaTypeRequestMatcher requestMatcher = new MediaTypeRequestMatcher(MediaType.TEXT_HTML);
requestMatcher.setIgnoredMediaTypes(Set.of(MediaType.ALL));
return requestMatcher;
}
@Bean
@Order(1)
public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http, OidcUserInfoService userInfoService) throws Exception {
OAuth2AuthorizationServerConfigurer authorizationServerConfigurer =
new OAuth2AuthorizationServerConfigurer();
RequestMatcher endpointsMatcher = authorizationServerConfigurer
.getEndpointsMatcher();
Function<OidcUserInfoAuthenticationContext, OidcUserInfo> userInfoMapper = (context) -> {
OidcUserInfoAuthenticationToken authentication = context.getAuthentication();
JwtAuthenticationToken principal = (JwtAuthenticationToken) authentication.getPrincipal();
//return new OidcUserInfo(principal.getToken().getClaims());
//加载为业务的用户信息
return userInfoService.loadUser(principal.getName());
};
authorizationServerConfigurer
.oidc((oidc) -> oidc
.userInfoEndpoint((userInfo) -> userInfo
.userInfoMapper(userInfoMapper)
)
);
http
.securityMatcher(endpointsMatcher)
.authorizeHttpRequests((authorize) -> authorize
.anyRequest().authenticated()
)
.csrf(csrf -> csrf.ignoringRequestMatchers(endpointsMatcher))
.oauth2ResourceServer(resourceServer -> resourceServer
.jwt(Customizer.withDefaults())
)
.exceptionHandling((exceptions) -> exceptions
.defaultAuthenticationEntryPointFor(
new LoginUrlAuthenticationEntryPoint("/login"), createRequestMatcher())
)
.apply(authorizationServerConfigurer);
return http.build();
}
@Bean
@Order(2)
SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests((authorize) -> authorize.anyRequest().authenticated()).formLogin(withDefaults());
return http.build();
}
}
请求: http://localhost:8081/oauth2/authorize?client_id=login-client&response_type=code&scope=openid&state=abc&redirect_uri=https://www.iokays.com, 一路走下去,直到获取access_token,调用下面的用例即可:
package com.iokays;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.web.client.RestTemplate;
/**
* 这不是测试类(一个严格且具有断言,可重复执行测试用例的类),只是使用@Test 来方便运行方法。
*/
@Slf4j
class UserInfoSample {
private final RestTemplate restTemplate = new RestTemplate();
@Test
@DisplayName("访问用户信息")
void testUserInfo() {
final String accessToken = ConfigProperties.value("accessToken");
final var headers = new HttpHeaders();
headers.setBearerAuth(accessToken);
final String url = "http://localhost:8080/userinfo";
final var response = restTemplate.exchange(url, HttpMethod.GET, new HttpEntity<>(headers), String.class);
/**
* <200 OK OK,
* {"sub":"user-iokays","name":"First Last","given_name":"First","family_name":"Last","middle_name":"Middle",
* "nickname":"User","preferred_username":"user-iokays","profile":"https://example.com/user-iokays",
* "picture":"https://example.com/user-iokays.jpg","website":"https://example.com","email":"user-iokays@example.com",
* "email_verified":true,"gender":"female","birthdate":"1970-01-01","zoneinfo":"Europe/Paris","locale":"en-US",
* "phone_number":"+1 (604) 555-1234;ext=5678","phone_number_verified":false,"address":{"formatted":"Champ de Mars\n5 Av.
* Anatole France\n75007 Paris\nFrance"},"updated_at":"1970-01-01T00:00:00Z"},[X-Content-Type-Options:"nosniff",
* X-XSS-Protection:"0", Cache-Control:"no-cache, no-store, max-age=0, must-revalidate", Pragma:"no-cache", Expires:"0",
* X-Frame-Options:"DENY", Content-Type:"application/json", Transfer-Encoding:"chunked", Date:"Thu, 14 Nov 2024 09:20:34 GMT",
* Keep-Alive:"timeout=60", Connection:"keep-alive"]
* >
*/
log.info("response: {}", response);
}
}
授权码模式就这样简单的介绍完了。 然后我们在简单了解下客户端模式。
客户端模式
适用于服务器到服务器的场景,其中用户不直接参与授权过程。客户端模式通常用于应用程序(客户端)代表自己,而非代表某个用户,去请求访问资源(例如,API)。
OAuth 2.0 客户端模式通常用于以下场景:
后台服务通信:客户端模式适用于两个系统之间的通信,其中一个系统(客户端)代表自己请求访问另一个系统的资源,无需用户的交互。例如,后台服务或微服务之间的 API 调用。 机器到机器通信:这种模式适用于自动化服务之间的通信,比如定时任务,数据同步服务等。
优点
简洁性:客户端模式不涉及用户交互,简化了流程。 适用性:适合服务器端应用程序与资源服务器之间的通信。 安全性:通过使用客户端密钥来保证客户端身份,避免暴露用户凭据。