簡介
OAuth 2.0:是用于授權(quán)的行業(yè)標(biāo)準(zhǔn)協(xié)議辉川。 OAuth 2.0致力于簡化客戶端開發(fā)人員的工作叶沛,同時(shí)為Web應(yīng)用程序惰赋,桌面應(yīng)用程序产徊,移動(dòng)電話和客廳設(shè)備提供特定的授權(quán)流程昂勒。 該規(guī)范及其擴(kuò)展正在IETF OAuth工作組內(nèi)開發(fā)。
Spring Security定義的OAuth2.0授權(quán)類型
- 授權(quán)碼(Authorization Code)
- 客戶憑證(Client Credentials)
- 資源所有者密碼憑證(Resource Owner Password Credentials)
- 刷新令牌(Refresh Token)
Spring Security OAuth2.0原理分析(Authorization Code方式)
OAuth2.0 Client重定向到Authorization Server
- 用戶在登錄頁選擇登錄方式舟铜,比如GitHub戈盈、微信等,請(qǐng)求路徑:oauth2/authorization/{registrationId},以GitHub為例:oauth2/authorization/github 塘娶;
- OAuth2AuthorizationRequestRedirectFilter攔截請(qǐng)求归斤,并調(diào)用OAuth2AuthorizationRequestResolver.resolve();
- OAuth2AuthorizationRequestResolver校驗(yàn)請(qǐng)求路徑是否正確刁岸,如果正確則接著調(diào)用ClientRegistrationRepository.findByRegistrationId()獲取ClientRegistration注冊(cè)信息(配置)脏里,OAuth2AuthorizationRequestResolver將注冊(cè)信息包裝成OAuth2AuthorizationRequest并返回;
- OAuth2AuthorizationRequestRedirectFilter判斷上一步返回的OAuth2AuthorizationRequest不為空虹曙,則接著判斷當(dāng)前授權(quán)類型是否==授權(quán)碼類型迫横,如果是則需要調(diào)用AuthorizationRequestRepository.saveAuthorizationRequest()存儲(chǔ)OAuth2AuthorizationRequest(以Map方式K-state,V-OAuth2AuthorizationRequest存儲(chǔ)到Session中),最后通過RedirectStrategy發(fā)起重定向操作酝碳。
Authorization Server授權(quán)
- OAuth2.0 Client選擇某個(gè)三方授權(quán)中心(這邊以基于spring security搭建的自定義授權(quán)中心為例)矾踱,進(jìn)入custom授權(quán)中心,發(fā)現(xiàn)用戶未登錄疏哗,跳轉(zhuǎn)登錄頁面呛讲;
- 用戶登錄成功后,重現(xiàn)GET /oauth/authorize?response_type=code&client_id=client_web&state=SZn-vxg9xmXr6rXFHXHDycthS2YZpvF2iNR32QktNOM%3D&redirect_uri=http://localhost:8080/client/login/oauth2/code/custom返奉,進(jìn)入AuthorizationEndpoint授權(quán)端點(diǎn)贝搁;
- 接著進(jìn)入授權(quán)頁面,用戶可以選擇是否授權(quán)芽偏;
- 將授權(quán)結(jié)果以POST方式提交給AuthorizationEndpoint雷逆,如果授權(quán)成功,將簽發(fā)code并重定向到客戶端污尉。
Authorization Server授權(quán)成功重定向到OAuth2.0 Client
- 用戶在Authorization Server授權(quán)成功关面,重定向到OAuth2.0 Client,請(qǐng)求路徑:/login/oauth2/code/{registrationId}十厢,以GitHub為例:/login/oauth2/code/github,OAuth2LoginAuthenticationFilter攔截到該請(qǐng)求捂齐,通過判斷請(qǐng)求參數(shù)是否包含code蛮放、state或者error、state;
- 接著OAuth2LoginAuthenticationFilter調(diào)用AuthorizationRequestRepository.removeAuthorizationRequest()刪除AuthorizationRequest并返回奠宜,返回的AuthorizationRequest不為空則走下一步包颁;
- 再著OAuth2LoginAuthenticationFilter調(diào)用ClientRegistrationRepository.findByRegistrationId()獲取配置的ClientRegistration,返回的ClientRegistration不為空則走下一步压真;
- 然后OAuth2LoginAuthenticationFilter調(diào)用AuthenticationManager.authenticate()娩嚼;
- 將認(rèn)證邏輯委托給OAuth2LoginAuthenticationProvider;
- OAuth2LoginAuthenticationProvider調(diào)用OAuth2AuthorizationCodeAuthenticationProvider.authenticate()滴肿,OAuth2AuthorizationCodeAuthenticationProvider比較state的值是否一致岳悟,如果一致則調(diào)用OAuth2AccessTokenResponseClient.getTokenResponse()向Authorization Server獲取accessToken;
- OAuth2LoginAuthenticationProvider調(diào)用OAuth2UserService.loadUser()向Resource Server獲取用戶信息。
- 最后OAuth2LoginAuthenticationFilter調(diào)用OAuth2AuthorizedClientRepository.saveAuthorizedClient()保存授權(quán)用戶信息贵少,OAuth2AuthorizedClientRepository調(diào)用OAuth2AuthorizedClientService.saveAuthorizedClient()以Map<OAuth2AuthorizedClientId, OAuth2AuthorizedClient>形式保存在內(nèi)存中呵俏。
OAuth2.0 Client從Authorization Server獲取accessToken
OAuth2.0 Client調(diào)用 POST /oauth/token從Authorization Server獲取token信息。
OAuth2.0 Client從Resource Server獲取用戶信息
- 用戶發(fā)起獲取資源請(qǐng)求滔灶,例如:/getUser(路徑可自定義普碎,無特殊要求) ,必須包含
Authorization: Bearer token值
請(qǐng)求頭录平; - 經(jīng)過BearerTokenAuthenticationFilter麻车,將token解析成BearerToken
[圖片上傳中...(image.png-b9379b-1608388383917-0)]
AuthenticationToken對(duì)象,交給AuthenticationManager進(jìn)行認(rèn)證處理斗这; - 成功則繼續(xù)往下處理filterChain.doFilter(request, response)动猬,失敗則交給認(rèn)證失敗處理器來處理。
基于JWT token類型的認(rèn)證
- AuthenticationManager將token認(rèn)證委托給JwtAuthenticationProvider涝影;
- JwtAuthenticationProvider通過JwtDecoder解析并校驗(yàn)token信息枣察;
- JwtAuthenticationConverter將token信息轉(zhuǎn)化為JwtAuthenticationToken并返回;
JwtDecoder解析并校驗(yàn)token信息
首先了解下JWT的數(shù)據(jù)結(jié)構(gòu):
- Header:頭部燃逻,由算法和類型兩部分組成序目,頭部基于Base64Url編碼;
{
"alg": "HS256",
"typ": "JWT"
}
- Payload:負(fù)載伯襟,存儲(chǔ)用戶信息和附加數(shù)據(jù)猿涨,負(fù)載基于Base64Url編碼;
{
"sub": "1234567890",
"name": "John Doe",
"admin": true
}
- Signature:簽名姆怪,對(duì)頭部叛赚、負(fù)載進(jìn)行簽名;
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret)
如何驗(yàn)證JWT的有效性稽揭?
頭部和負(fù)載信息隨時(shí)都有可能被篡改俺附,JWT通過Signature簽名方式保證數(shù)據(jù)的安全性。JWT數(shù)據(jù)生產(chǎn)方:采用SHA系列算法將頭部溪掀、負(fù)載生成摘要信息事镣,接著通過私鑰(對(duì)稱或者非對(duì)稱加密算法)進(jìn)行簽名;JWT數(shù)據(jù)消費(fèi)方:通過通過base64UrlDecode解析頭部信息獲取簽名算法揪胃,然后通過接口請(qǐng)求JWT數(shù)據(jù)生產(chǎn)方獲取公鑰璃哟,通過公鑰+頭部的簽名算法驗(yàn)證改JWT的有效性。當(dāng)然除了以外喊递,還要保證該token還沒過期等随闪。
因此:Authorization Server作為JWT生產(chǎn)方,頒發(fā)token骚勘;Resource Server作為JWT消費(fèi)方铐伴,解析Header、Payload信息,獲得簽名算法盛杰,接著調(diào)用/.well-known/jwks.json從Authorization Server獲取公鑰集挽荡,并進(jìn)行驗(yàn)簽操作(Spring Security 采用Nimbus框架支持JWT功能)。
Spring Security OAuth2.0實(shí)戰(zhàn)
OAuth2.0 Client
- 引入依賴
<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>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-config</artifactId>
<version>5.4.1</version>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-client</artifactId>
<version>5.4.1</version>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-jose</artifactId>
<version>5.4.1</version>
</dependency>
<dependency>
<groupId>org.thymeleaf.extras</groupId>
<artifactId>thymeleaf-extras-springsecurity5</artifactId>
<version>3.0.4.RELEASE</version>
</dependency>
- 修改配置文件application.yml
server:
port: 8080
servlet:
context-path: /client
spring:
thymeleaf:
cache: false
security:
oauth2:
client:
registration:
github:
client-id: b8a7914d0895b3c086f4
client-secret: 097b313fd4b4375066dc9ad22c92b124792687d2
custom:
client-id: client_web
client-secret: secret
authorization-grant-type: authorization_code
redirect-uri: http://localhost:8080/client/login/oauth2/code/custom
provider:
custom:
authorization-uri: http://localhost:8081/oauth2authorizationserver/oauth/authorize
token-uri: http://localhost:8081/oauth2authorizationserver/oauth/token
user-info-uri: http://localhost:8082/resourceserver
user-name-attribute: name
3.相關(guān)代碼
OAuth2LoginController.java
@Controller
public class OAuth2LoginController {
@GetMapping("/")
public String index(Model model,
@RegisteredOAuth2AuthorizedClient OAuth2AuthorizedClient authorizedClient,
@AuthenticationPrincipal OAuth2User oauth2User) {
model.addAttribute("userName", oauth2User.getName());
model.addAttribute("clientName", authorizedClient.getClientRegistration().getClientName());
model.addAttribute("userAttributes", oauth2User.getAttributes());
return "index";
}
}
index.html
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="https://www.thymeleaf.org" xmlns:sec="https://www.thymeleaf.org/thymeleaf-extras-springsecurity5">
<head>
<title>Spring Security - OAuth 2.0 Login</title>
<meta charset="utf-8" />
</head>
<body>
<div style="float: right" th:fragment="logout" sec:authorize="isAuthenticated()">
<div style="float:left">
<span style="font-weight:bold">User: </span><span sec:authentication="name"></span>
</div>
<div style="float:none"> </div>
<div style="float:right">
<form action="#" th:action="@{/logout}" method="post">
<input type="submit" value="Logout" />
</form>
</div>
</div>
<h1>OAuth 2.0 Login with Spring Security</h1>
<div>
You are successfully logged in <span style="font-weight:bold" th:text="${userName}"></span>
via the OAuth 2.0 Client <span style="font-weight:bold" th:text="${clientName}"></span>
</div>
<div> </div>
<div>
<span style="font-weight:bold">User Attributes:</span>
<ul>
<li th:each="userAttribute : ${userAttributes}">
<span style="font-weight:bold" th:text="${userAttribute.key}"></span>: <span th:text="${userAttribute.value}"></span>
</li>
</ul>
</div>
</body>
</html>
OAuth2.0 Authorization Server
- 引入依賴
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security.oauth.boot</groupId>
<artifactId>spring-security-oauth2-autoconfigure</artifactId>
<version>2.4.0</version>
</dependency>
<dependency>
<groupId>com.nimbusds</groupId>
<artifactId>nimbus-jose-jwt</artifactId>
<version>9.1.2</version>
</dependency>
- 修改配置文件application.yml
server:
port: 8081
servlet:
context-path: /oauth2authorizationserver
- 相關(guān)代碼
SecurityConfig.java
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.mvcMatchers("/.well-known/jwks.json").permitAll()
.anyRequest().authenticated()
.and()
.httpBasic();
}
@Bean
@Override
public UserDetailsService userDetailsService() {
return new InMemoryUserDetailsManager(
User.withDefaultPasswordEncoder()
.username("admin")
.password("admin")
.roles("USER")
.build());
}
}
AuthorizationServerConfiguration.java
@EnableAuthorizationServer
@Configuration
public class AuthorizationServerConfiguration extends AuthorizationServerConfigurerAdapter {
@Autowired
private AuthenticationConfiguration authenticationConfiguration;
@Override
public void configure(ClientDetailsServiceConfigurer clients)
throws Exception {
// @formatter:off
clients.inMemory()
.withClient("client_web")
.redirectUris("http://localhost:8080/client/login/oauth2/code/custom")
.authorizedGrantTypes("authorization_code", "refresh_token")
.scopes("message:read", "message:write")
.authorities("oauth2")
.secret("{noop}secret")
.accessTokenValiditySeconds((int) TimeUnit.DAYS.toSeconds(1))
.refreshTokenValiditySeconds((int)TimeUnit.DAYS.toSeconds(1));
// @formatter:on
}
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
// @formatter:off
endpoints
.authenticationManager(this.authenticationConfiguration.getAuthenticationManager())
.tokenStore(tokenStore())
.accessTokenConverter(accessTokenConverter());
// @formatter:on
}
@Bean
public KeyPair keyPair() {
try {
String privateExponent = "3851612021791312596791631935569878540203393691253311342052463788814433805390794604753109719790052408607029530149004451377846406736413270923596916756321977922303381344613407820854322190592787335193581632323728135479679928871596911841005827348430783250026013354350760878678723915119966019947072651782000702927096735228356171563532131162414366310012554312756036441054404004920678199077822575051043273088621405687950081861819700809912238863867947415641838115425624808671834312114785499017269379478439158796130804789241476050832773822038351367878951389438751088021113551495469440016698505614123035099067172660197922333993";
String modulus = "18044398961479537755088511127417480155072543594514852056908450877656126120801808993616738273349107491806340290040410660515399239279742407357192875363433659810851147557504389760192273458065587503508596714389889971758652047927503525007076910925306186421971180013159326306810174367375596043267660331677530921991343349336096643043840224352451615452251387611820750171352353189973315443889352557807329336576421211370350554195530374360110583327093711721857129170040527236951522127488980970085401773781530555922385755722534685479501240842392531455355164896023070459024737908929308707435474197069199421373363801477026083786683";
String exponent = "65537";
RSAPublicKeySpec publicSpec = new RSAPublicKeySpec(new BigInteger(modulus), new BigInteger(exponent));
RSAPrivateKeySpec privateSpec = new RSAPrivateKeySpec(new BigInteger(modulus), new BigInteger(privateExponent));
KeyFactory factory = KeyFactory.getInstance("RSA");
return new KeyPair(factory.generatePublic(publicSpec), factory.generatePrivate(privateSpec));
} catch ( Exception e ) {
throw new IllegalArgumentException(e);
}
}
@Bean
public TokenStore tokenStore() {
return new JwtTokenStore(accessTokenConverter());
}
@Bean
public JwtAccessTokenConverter accessTokenConverter() {
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
converter.setKeyPair(this.keyPair());
DefaultAccessTokenConverter accessTokenConverter = new DefaultAccessTokenConverter();
accessTokenConverter.setUserTokenConverter(new SubjectAttributeUserTokenConverter());
converter.setAccessTokenConverter(accessTokenConverter);
return converter;
}
/**
* 擴(kuò)展響應(yīng)屬性
*/
public static class SubjectAttributeUserTokenConverter extends DefaultUserAuthenticationConverter {
@Override
public Map<String, ?> convertUserAuthentication(Authentication authentication) {
Map<String, Object> response = new LinkedHashMap<>();
response.put("sub", authentication.getName());
if (authentication.getAuthorities() != null && !authentication.getAuthorities().isEmpty()) {
response.put(AUTHORITIES, AuthorityUtils.authorityListToSet(authentication.getAuthorities()));
}
return response;
}
}
}
JwkSetEndpoint.java
@FrameworkEndpoint
public class JwkSetEndpoint {
@Autowired
private KeyPair keyPair;
@GetMapping("/.well-known/jwks.json")
@ResponseBody
public Map<String, Object> getKey() {
RSAPublicKey publicKey = (RSAPublicKey) this.keyPair.getPublic();
RSAKey key = new RSAKey.Builder(publicKey).build();
return new JWKSet(key).toJSONObject();
}
}
OAuth2.0 Resource Server
- 引入依賴
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-config</artifactId>
<version>5.4.1</version>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-resource-server</artifactId>
<version>5.4.1</version>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-jose</artifactId>
<version>5.4.1</version>
</dependency>
- 修改配置文件application.yml
server:
port: 8082
servlet:
context-path: /resourceserver
spring:
security:
oauth2:
resourceserver:
jwt:
jwk-set-uri: http://localhost:8081/oauth2authorizationserver/.well-known/jwks.json
- 相關(guān)代碼
OAuth2ResourceServerSecurityConfiguration.java
@EnableWebSecurity
public class OAuth2ResourceServerSecurityConfiguration extends WebSecurityConfigurerAdapter {
@Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri}") String jwkSetUri;
@Override
protected void configure(HttpSecurity http) throws Exception {
// @formatter:off
http
.authorizeRequests((authorizeRequests) ->
authorizeRequests
.antMatchers(HttpMethod.GET, "/message/**").hasAuthority("SCOPE_message:read")
.antMatchers(HttpMethod.POST, "/message/**").hasAuthority("SCOPE_message:write")
.anyRequest().authenticated()
)
.oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt);
// @formatter:on
}
@Bean
JwtDecoder jwtDecoder() {
return NimbusJwtDecoder.withJwkSetUri(this.jwkSetUri).build();
}
}
OAuth2ResourceServerController.java
@RestController
public class OAuth2ResourceServerController {
private static final Map<String, String> USER_MAP = new HashMap<>();
static {
USER_MAP.put("admin", "{\n" +
"\"name\":\"admin\",\n" +
"\"age\":\"20\",\n" +
"\"realName\":\"管理員\"\n" +
"}");
}
@GetMapping("/")
public String index(@AuthenticationPrincipal Jwt jwt) {
return USER_MAP.get(jwt.getSubject());
}
@GetMapping("/message")
public String message() {
return "secret message";
}
@PostMapping("/message")
public String createMessage(@RequestBody String message) {
return String.format("Message was created. Content: %s", message);
}
}