概述
OAuth 2是行业标准的授权协议。OAuth 2专注于客户端开发人员的简单性,同时为 Web 应用程序、桌面应用程序、移动电话和客厅设备提供特定的授权流程。
下面我们将使用OAuth2实现接口鉴权。
名词定义
在详细讲解OAuth 2之前,需要了解几个专用名词。它们对读懂后面的讲解,尤其是几张图,至关重要。
1、Third-party application:第三方应用程序。
2、HTTP service:HTTP服务提供商。
3、Resource Owner:资源所有者,本文中又称”用户”(user)。
4、User Agent:用户代理,本文中就是指浏览器。
5、Authorization server:认证服务器,即服务提供商专门用来处理认证的服务器。
6、Resource server:资源服务器,即服务提供商存放用户生成的资源的服务器。它与认证服务器,可以是同一台服务器,也可以是不同的服务器。
知道了上面这些名词,就不难理解,OAuth的作用就是让”客户端”安全可控地获取”用户”的授权,与”服务商提供商”进行互动。
OAuth的思路
OAuth在”客户端”与”服务提供商”之间,设置了一个授权层(authorization layer)。”客户端”不能直接登录”服务提供商”,只能登录授权层,以此将用户与客户端区分开来。”客户端”登录授权层所用的令牌(token),与用户的密码不同。用户可以在登录的时候,指定授权层令牌的权限范围和有效期。
“客户端”登录授权层以后,”服务提供商”根据令牌的权限范围和有效期,向”客户端”开放用户储存的资料。
运行流程
OAuth 2.0的运行流程如下图,摘自RFC 6749[1]。
+--------+ +---------------+
| |--(A)- Authorization Request ->| Resource |
| | | Owner |
| |<-(B)-- Authorization Grant ---| |
| | +---------------+
| |
| | +---------------+
| |--(C)-- Authorization Grant -->| Authorization |
| Client | | Server |
| |<-(D)----- Access Token -------| |
| | +---------------+
| |
| | +---------------+
| |--(E)----- Access Token ------>| Resource |
| | | Server |
| |<-(F)--- Protected Resource ---| |
+--------+ +---------------+
项目
- oauth2-server:oauth2认证服务器
- oauth2-resource-server:资源服务器,接口所在的项目
- oauth2-client:接口调用端
mysql ddl
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- ----------------------------
-- 应用信息
-- ----------------------------
DROP TABLE IF EXISTS `app_info`;
CREATE TABLE `app_info` (
`app_id` int NOT NULL AUTO_INCREMENT COMMENT '应用id',
`app_key` char(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '应用key',
`app_secret` char(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '应用秘钥',
`ip_whites` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT 'ip白名单列表,多个以英文逗号分割',
PRIMARY KEY (`app_id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 2 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '应用信息' ROW_FORMAT = Dynamic;
-- ----------------------------
-- oauth2 授权信息
-- ----------------------------
DROP TABLE IF EXISTS `oauth2_authorization`;
CREATE TABLE `oauth2_authorization` (
`id` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL,
`registered_client_id` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL,
`principal_name` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL,
`authorization_grant_type` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL,
`authorized_scopes` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
`attributes` blob NULL,
`state` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
`authorization_code_value` blob NULL,
`authorization_code_issued_at` timestamp NULL DEFAULT NULL,
`authorization_code_expires_at` timestamp NULL DEFAULT NULL,
`authorization_code_metadata` blob NULL,
`access_token_value` blob NULL,
`access_token_issued_at` timestamp NULL DEFAULT NULL,
`access_token_expires_at` timestamp NULL DEFAULT NULL,
`access_token_metadata` blob NULL,
`access_token_type` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
`access_token_scopes` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
`oidc_id_token_value` blob NULL,
`oidc_id_token_issued_at` timestamp NULL DEFAULT NULL,
`oidc_id_token_expires_at` timestamp NULL DEFAULT NULL,
`oidc_id_token_metadata` blob NULL,
`refresh_token_value` blob NULL,
`refresh_token_issued_at` timestamp NULL DEFAULT NULL,
`refresh_token_expires_at` timestamp NULL DEFAULT NULL,
`refresh_token_metadata` blob NULL,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic;
SET FOREIGN_KEY_CHECKS = 1;
oauth2-server
核心配置
package show.lmm.oauth2server.core.conf;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
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.config.annotation.web.configurers.OAuth2AuthorizationServerConfigurer;
import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings;
import org.springframework.security.oauth2.server.authorization.settings.ClientSettings;
import org.springframework.security.oauth2.server.authorization.settings.OAuth2TokenFormat;
import org.springframework.security.oauth2.server.authorization.settings.TokenSettings;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
import org.springframework.security.web.util.matcher.RequestMatcher;
import show.lmm.oauth2server.core.DataHandler;
import show.lmm.oauth2server.core.handle.OAuth2AuthenticationFailureHandler;
import show.lmm.oauth2server.core.handle.OAuth2AuthenticationSuccessHandler;
import show.lmm.oauth2server.core.util.IpUtils;
import show.lmm.oauth2server.service.AppInfoService;
import java.time.Duration;
import java.util.*;
/**
* oauth2服务器配置
*/
@Configuration
public class OAuth2ServerConfig {
public static void applyDefaultSecurity(HttpSecurity http) throws Exception {
OAuth2AuthorizationServerConfigurer authorizationServerConfigurer =
new OAuth2AuthorizationServerConfigurer();
authorizationServerConfigurer
.tokenEndpoint(tokenEndpoint ->
tokenEndpoint.accessTokenRequestConverter(request -> {
// IP白名单校验
String ip = IpUtils.getIp(request);
String clientId = request.getParameter("client_id");
if (Optional.ofNullable(clientId).isEmpty()) {
throw new OAuth2AuthenticationException("非法请求,错误码:1001");
}
Collection<String> ipWhites = DataHandler.appIpWhites.get(clientId);
if (Optional.ofNullable(ipWhites).isEmpty() || ipWhites.isEmpty()) {
throw new OAuth2AuthenticationException("非法请求,错误码:1002");
}
if (ipWhites.contains(ip)) {
return null;
}
throw new OAuth2AuthenticationException(String.format("ip: %s,不在白名单", ip));
})
// 自定义accessToken返回值
.accessTokenResponseHandler(new OAuth2AuthenticationSuccessHandler())
).clientAuthentication(clientAuthenticationConfigurer ->
clientAuthenticationConfigurer
// 自定义异常返回值
.errorResponseHandler(new OAuth2AuthenticationFailureHandler())
);
RequestMatcher endpointsMatcher = authorizationServerConfigurer
.getEndpointsMatcher();
http
.securityMatcher(endpointsMatcher)
.authorizeHttpRequests(authorize ->
authorize.anyRequest().authenticated()
)
.csrf(csrf -> csrf.ignoringRequestMatchers(endpointsMatcher))
.apply(authorizationServerConfigurer);
}
@Bean
@Order(Ordered.HIGHEST_PRECEDENCE)
public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
OAuth2AuthorizationServerConfigurer authorizationServerConfigurer =
new OAuth2AuthorizationServerConfigurer();
http.apply(authorizationServerConfigurer);
applyDefaultSecurity(http);
return http
.exceptionHandling(exceptions -> exceptions.
authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint("/login"))
).build();
}
@Bean
public RegisteredClientRepository registeredClientRepository(AppInfoService appInfoService) {
List<RegisteredClient> registeredClients = new ArrayList<>();
appInfoService.list().forEach(item -> {
DataHandler.appIpWhites.put(item.getAppKey(), Arrays.asList(item.getIpWhites().split(",")));
registeredClients.add(RegisteredClient.withId(UUID.randomUUID().toString())
.clientId(item.getAppKey())
.clientSecret(String.format("{noop}%s", item.getAppSecret()))
.clientAuthenticationMethods(s -> {
s.add(ClientAuthenticationMethod.CLIENT_SECRET_POST);
s.add(ClientAuthenticationMethod.CLIENT_SECRET_BASIC);
})
.authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
.scope("message.read")
.clientSettings(ClientSettings.builder()
.requireAuthorizationConsent(true)
.requireProofKey(false)
.build())
.tokenSettings(TokenSettings.builder()
// 生成不透明token
.accessTokenFormat(OAuth2TokenFormat.REFERENCE)
// 签名方式
.idTokenSignatureAlgorithm(SignatureAlgorithm.RS256)
// accessToken 有效期
.accessTokenTimeToLive(Duration.ofHours(1))
.reuseRefreshTokens(true)
.build())
.build());
});
return new InMemoryRegisteredClientRepository(registeredClients);
}
@Bean
public AuthorizationServerSettings authorizationServerSettings() {
return AuthorizationServerSettings.builder()
.issuer("http://127.0.0.1:8081")
.build();
}
}
{noop} 表示密码不加密
获取accessToken示例
curl --location --request POST 'http://localhost:8081/oauth2/token?client_id=a53e8303bcd8441f8c8b3b708c0f3bf5&client_secret=fa8896185af841d687e90091254a0b7c&grant_type=client_credentials'
自定义accessToken返回值
/**
* 认证成功 handler
*/
public class OAuth2AuthenticationSuccessHandler implements AuthenticationSuccessHandler {
private static final String NO_RESPONSE_DATA_PARAM_NAME = "no_response_data";
private final Converter<OAuth2AccessTokenResponse, Map<String, Object>> accessTokenResponseParametersConverter =
new DefaultOAuth2AccessTokenResponseMapConverter();
@SneakyThrows
@Override
public void onAuthenticationSuccess(HttpServletRequest request,
HttpServletResponse response,
Authentication authentication) {
OAuth2AccessTokenAuthenticationToken accessTokenAuthentication = (OAuth2AccessTokenAuthenticationToken) authentication;
OAuth2AccessToken accessToken = accessTokenAuthentication.getAccessToken();
OAuth2RefreshToken refreshToken = accessTokenAuthentication.getRefreshToken();
Map<String, Object> additionalParameters = accessTokenAuthentication.getAdditionalParameters();
OAuth2AccessTokenResponse.Builder builder = OAuth2AccessTokenResponse.withToken(accessToken.getTokenValue())
.tokenType(accessToken.getTokenType()).scopes(accessToken.getScopes());
if (accessToken.getIssuedAt() != null && accessToken.getExpiresAt() != null) {
builder.expiresIn(ChronoUnit.SECONDS.between(accessToken.getIssuedAt(), accessToken.getExpiresAt()));
}
if (refreshToken != null) {
builder.refreshToken(refreshToken.getTokenValue());
}
if (!CollectionUtils.isEmpty(additionalParameters)) {
builder.additionalParameters(additionalParameters);
}
OAuth2AccessTokenResponse accessTokenResponse = builder.build();
Map<String, Object> tokenResponseParameters = this.accessTokenResponseParametersConverter.convert(accessTokenResponse);
ServletServerHttpResponse httpResponse = new ServletServerHttpResponse(response);
boolean noResponseData = Boolean.parseBoolean(request.getParameter(NO_RESPONSE_DATA_PARAM_NAME));
if(request.getHeader("user-agent").toLowerCase().contains("java")){
noResponseData = true;
}
if (noResponseData) {
HttpEndpointUtils.writeWithMessageConverters(tokenResponseParameters, httpResponse);
} else {
ReturnHandler<Map<String, Object>> returnHandler = ReturnHandler.ok(tokenResponseParameters);
HttpEndpointUtils.writeWithMessageConverters(returnHandler, httpResponse);
}
}
}
自定义异常返回值
/**
* 认证失败 handler
*/
@Component
public class OAuth2AuthenticationFailureHandler implements AuthenticationFailureHandler {
private static final String AUTHENTICATION_METHOD = "authentication_method";
private static final String CREDENTIALS = "credentials";
@SneakyThrows
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) {
ReturnHandler<?> error = createError(exception);
ServletServerHttpResponse servletServerHttpResponse = new ServletServerHttpResponse(response);
servletServerHttpResponse.setStatusCode(HttpStatus.UNAUTHORIZED);
HttpEndpointUtils.writeWithMessageConverters(error, servletServerHttpResponse);
}
/**
* 创建 error
*
* @param exception /
* @return /
*/
private ReturnHandler<?> createError(AuthenticationException exception) {
ReturnHandler<?> error = null;
if (exception instanceof OAuth2AuthenticationException oAuth2AuthenticationException) {
String errorCode = oAuth2AuthenticationException.getError().getErrorCode();
String description = oAuth2AuthenticationException.getError().getDescription();
switch (errorCode) {
case OAuth2ErrorCodes.INVALID_CLIENT -> {
if (StringUtils.isBlank(description)) {
error = ReturnHandler.error(ResponseCode.INVALID_GRANT, "无效的客户端");
} else if (description.contains(OAuth2ParameterNames.CLIENT_ID)) {
error = ReturnHandler.error(ResponseCode.CLIENT_NOT_EXIST);
} else if (description.contains(AUTHENTICATION_METHOD)) {
error = ReturnHandler.error(ResponseCode.AUTHORIZATION_DENIED);
} else if (description.contains(CREDENTIALS)) {
error = ReturnHandler.error(ResponseCode.CLIENT_PASSWORD_EMPTY);
} else if (description.contains(OAuth2ParameterNames.CLIENT_SECRET)) {
error = ReturnHandler.error(ResponseCode.CLIENT_PASSWORD_INCORRECT);
}
}
case OAuth2ErrorCodes.UNSUPPORTED_GRANT_TYPE ->
error = ReturnHandler.error(ResponseCode.UNSUPPORTED_GRANT_TYPE);
case OAuth2ErrorCodes.INVALID_REQUEST -> {
if (StringUtils.isBlank(description)) {
error = ReturnHandler.error(ResponseCode.INVALID_GRANT, "无效的客户端");
} else if (description.contains(OAuth2ParameterNames.GRANT_TYPE)) {
error = ReturnHandler.error(ResponseCode.GRANT_TYPE_EMPTY);
}
}
case OAuth2ErrorCodes.INVALID_GRANT -> error = ReturnHandler.error(ResponseCode.INVALID_GRANT);
case OAuth2ErrorCodes.INVALID_SCOPE -> error = ReturnHandler.error(ResponseCode.INVALID_SCOPE);
case OAuth2ErrorCodes.UNAUTHORIZED_CLIENT ->
error = ReturnHandler.error(ResponseCode.UNAUTHORIZED_CLIENT);
default ->
error = ReturnHandler.error(ResponseCode.USER_LOGIN_ABNORMAL.getCode(), oAuth2AuthenticationException.getError().getErrorCode());
}
} else {
error = ReturnHandler.error(ResponseCode.USER_LOGIN_ABNORMAL, exception.getLocalizedMessage());
}
return error;
}
}
oauth2-resource-server
核心配置
/**
* 资源服务器配置
*/
@Configuration(proxyBeanMethods = false)
@EnableMethodSecurity
public class ResourceServerConfig {
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(authorize -> authorize
.anyRequest().authenticated())
.oauth2ResourceServer(OAuth2ResourceServerConfigurer::opaqueToken);
return http.build();
}
}
配置文件 application.yml
server:
port: 8082
spring:
security:
oauth2:
resource-server:
opaque-token:
introspection-uri: http://127.0.0.1:8081/oauth2/introspect
client-id: a53e8303bcd8441f8c8b3b708c0f3bf5
client-secret: fa8896185af841d687e90091254a0b7c
logging:
level:
root: debug
api接口
@RestController
@RequestMapping("/user_info")
@PreAuthorize("hasAuthority('SCOPE_message.read')")
public class UserInfoController {
@GetMapping("/get")
public UserInfo getUserInfo(@RequestParam("user_type")int userType) {
UserInfo userInfo = new UserInfo();
userInfo.setUsername("测试用户");
userInfo.setUserType(userType);
return userInfo;
}
}
oauth2-client
核心配置
/**
* Spring Security 配置
*/
@Configuration
public class SecurityConfig {
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeRequests(authorizeRequests ->
authorizeRequests.anyRequest().permitAll()
)
.oauth2Client(withDefaults());
return http.build();
}
/**
* OAuth2 webClient
*/
@Bean
WebClient webClient(OAuth2AuthorizedClientManager authorizedClientManager) {
ServletOAuth2AuthorizedClientExchangeFilterFunction oauth2Client = new ServletOAuth2AuthorizedClientExchangeFilterFunction(authorizedClientManager);
return WebClient.builder()
.filter(oauth2Client)
.build();
}
@Bean
OAuth2AuthorizedClientManager authorizedClientManager(ClientRegistrationRepository clientRegistrationRepository,
OAuth2AuthorizedClientService authorizedClientService) {
OAuth2AuthorizedClientProvider authorizedClientProvider = OAuth2AuthorizedClientProviderBuilder
.builder()
.clientCredentials()
.build();
AuthorizedClientServiceOAuth2AuthorizedClientManager authorizedClientManager =
new AuthorizedClientServiceOAuth2AuthorizedClientManager(clientRegistrationRepository, authorizedClientService);
authorizedClientManager.setAuthorizedClientProvider(authorizedClientProvider);
return authorizedClientManager;
}
}
使用示例
@Slf4j
@Component
public class UserInfoSchedule {
private final WebClient webClient;
public UserInfoSchedule(WebClient webClient) {
this.webClient = webClient;
}
@Scheduled(cron = "0/2 * * * * ? ")
public void query() {
String result = this.webClient
.get()
.uri("http://127.0.0.1:8082/user_info/get?user_type=22")
.attributes(ServletOAuth2AuthorizedClientExchangeFilterFunction.clientRegistrationId("messaging-client-model"))
.retrieve()
.bodyToMono(String.class)
.block();
log.info("Call resource server execution result: " + result);
}
}
配置文件 application.yml
server:
port: 8083
spring:
security:
oauth2:
client:
registration:
messaging-client-model:
provider: client-provider
client-id: a53e8303bcd8441f8c8b3b708c0f3bf5
client-secret: fa8896185af841d687e90091254a0b7c
authorization-grant-type: client_credentials
client-authentication-method: client_secret_post
scope: message.read
client-name: messaging-client-model
provider:
client-provider:
token-uri: http://127.0.0.1:8081/oauth2/token