简介
OAuth 2.0:是用于授权的行业标准协议。 OAuth 2.0致力于简化客户端开发人员的工作,同时为Web应用程序,桌面应用程序,移动电话和客厅设备提供特定的授权流程。 该规范及其扩展正在IETF OAuth工作组内开发。
Spring Security定义的OAuth2.0授权类型
- 授权码(Authorization Code)
- 客户凭证(Client Credentials)
- 资源所有者密码凭证(Resource Owner Password Credentials)
- 刷新令牌(Refresh Token)
Spring Security OAuth2.0原理分析(Authorization Code方式)
OAuth2.0 Client重定向到Authorization Server
- 用户在登录页选择登录方式,比如GitHub、微信等,请求路径:oauth2/authorization/{registrationId},以GitHub为例:oauth2/authorization/github ;
- OAuth2AuthorizationRequestRedirectFilter拦截请求,并调用OAuth2AuthorizationRequestResolver.resolve();
- OAuth2AuthorizationRequestResolver校验请求路径是否正确,如果正确则接着调用ClientRegistrationRepository.findByRegistrationId()获取ClientRegistration注册信息(配置),OAuth2AuthorizationRequestResolver将注册信息包装成OAuth2AuthorizationRequest并返回;
- OAuth2AuthorizationRequestRedirectFilter判断上一步返回的OAuth2AuthorizationRequest不为空,则接着判断当前授权类型是否==授权码类型,如果是则需要调用AuthorizationRequestRepository.saveAuthorizationRequest()存储OAuth2AuthorizationRequest(以Map方式K-state,V-OAuth2AuthorizationRequest存储到Session中),最后通过RedirectStrategy发起重定向操作。
Authorization Server授权
- OAuth2.0 Client选择某个三方授权中心(这边以基于spring security搭建的自定义授权中心为例),进入custom授权中心,发现用户未登录,跳转登录页面;
- 用户登录成功后,重现GET /oauth/authorize?response_type=code&client_id=client_web&state=SZn-vxg9xmXr6rXFHXHDycthS2YZpvF2iNR32QktNOM%3D&redirect_uri=http://localhost:8080/client/login/oauth2/code/custom,进入AuthorizationEndpoint授权端点;
- 接着进入授权页面,用户可以选择是否授权;
- 将授权结果以POST方式提交给AuthorizationEndpoint,如果授权成功,将签发code并重定向到客户端。
Authorization Server授权成功重定向到OAuth2.0 Client
- 用户在Authorization Server授权成功,重定向到OAuth2.0 Client,请求路径:/login/oauth2/code/{registrationId},以GitHub为例:/login/oauth2/code/github,OAuth2LoginAuthenticationFilter拦截到该请求,通过判断请求参数是否包含code、state或者error、state;
- 接着OAuth2LoginAuthenticationFilter调用AuthorizationRequestRepository.removeAuthorizationRequest()删除AuthorizationRequest并返回,返回的AuthorizationRequest不为空则走下一步;
- 再着OAuth2LoginAuthenticationFilter调用ClientRegistrationRepository.findByRegistrationId()获取配置的ClientRegistration,返回的ClientRegistration不为空则走下一步;
- 然后OAuth2LoginAuthenticationFilter调用AuthenticationManager.authenticate();
- 将认证逻辑委托给OAuth2LoginAuthenticationProvider;
- OAuth2LoginAuthenticationProvider调用OAuth2AuthorizationCodeAuthenticationProvider.authenticate(),OAuth2AuthorizationCodeAuthenticationProvider比较state的值是否一致,如果一致则调用OAuth2AccessTokenResponseClient.getTokenResponse()向Authorization Server获取accessToken;
- OAuth2LoginAuthenticationProvider调用OAuth2UserService.loadUser()向Resource Server获取用户信息。
- 最后OAuth2LoginAuthenticationFilter调用OAuth2AuthorizedClientRepository.saveAuthorizedClient()保存授权用户信息,OAuth2AuthorizedClientRepository调用OAuth2AuthorizedClientService.saveAuthorizedClient()以Map
形式保存在内存中。
OAuth2.0 Client从Authorization Server获取accessToken
OAuth2.0 Client调用 POST /oauth/token从Authorization Server获取token信息。
OAuth2.0 Client从Resource Server获取用户信息
- 用户发起获取资源请求,例如:/getUser(路径可自定义,无特殊要求) ,必须包含
Authorization: Bearer token值
请求头; - 经过BearerTokenAuthenticationFilter,将token解析成BearerToken
[图片上传中...(image.png-b9379b-1608388383917-0)]
AuthenticationToken对象,交给AuthenticationManager进行认证处理; - 成功则继续往下处理filterChain.doFilter(request, response),失败则交给认证失败处理器来处理。
基于JWT token类型的认证
- AuthenticationManager将token认证委托给JwtAuthenticationProvider;
- JwtAuthenticationProvider通过JwtDecoder解析并校验token信息;
- JwtAuthenticationConverter将token信息转化为JwtAuthenticationToken并返回;
JwtDecoder解析并校验token信息
首先了解下JWT的数据结构:
- Header:头部,由算法和类型两部分组成,头部基于Base64Url编码;
{
"alg": "HS256",
"typ": "JWT"
}
- Payload:负载,存储用户信息和附加数据,负载基于Base64Url编码;
{
"sub": "1234567890",
"name": "John Doe",
"admin": true
}
- Signature:签名,对头部、负载进行签名;
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret)
如何验证JWT的有效性?
头部和负载信息随时都有可能被篡改,JWT通过Signature签名方式保证数据的安全性。JWT数据生产方:采用SHA系列算法将头部、负载生成摘要信息,接着通过私钥(对称或者非对称加密算法)进行签名;JWT数据消费方:通过通过base64UrlDecode解析头部信息获取签名算法,然后通过接口请求JWT数据生产方获取公钥,通过公钥+头部的签名算法验证改JWT的有效性。当然除了以外,还要保证该token还没过期等。
因此:Authorization Server作为JWT生产方,颁发token;Resource Server作为JWT消费方,解析Header、Payload信息,获得签名算法,接着调用/.well-known/jwks.json从Authorization Server获取公钥集,并进行验签操作(Spring Security 采用Nimbus框架支持JWT功能)。
Spring Security OAuth2.0实战
OAuth2.0 Client
- 引入依赖
org.springframework.boot
spring-boot-starter-web
org.springframework.boot
spring-boot-starter-thymeleaf
org.springframework.security
spring-security-config
5.4.1
org.springframework.security
spring-security-oauth2-client
5.4.1
org.springframework.security
spring-security-oauth2-jose
5.4.1
org.thymeleaf.extras
thymeleaf-extras-springsecurity5
3.0.4.RELEASE
- 修改配置文件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.相关代码
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
Spring Security - OAuth 2.0 Login
User:
OAuth 2.0 Login with Spring Security
You are successfully logged in
via the OAuth 2.0 Client
User Attributes:
-
:
OAuth2.0 Authorization Server
- 引入依赖
org.springframework.boot
spring-boot-starter-web
org.springframework.boot
spring-boot-starter-security
org.springframework.security.oauth.boot
spring-security-oauth2-autoconfigure
2.4.0
com.nimbusds
nimbus-jose-jwt
9.1.2
- 修改配置文件application.yml
server:
port: 8081
servlet:
context-path: /oauth2authorizationserver
- 相关代码
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;
}
/**
* 扩展响应属性
*/
public static class SubjectAttributeUserTokenConverter extends DefaultUserAuthenticationConverter {
@Override
public Map convertUserAuthentication(Authentication authentication) {
Map 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 getKey() {
RSAPublicKey publicKey = (RSAPublicKey) this.keyPair.getPublic();
RSAKey key = new RSAKey.Builder(publicKey).build();
return new JWKSet(key).toJSONObject();
}
}
OAuth2.0 Resource Server
- 引入依赖
org.springframework.boot
spring-boot-starter-web
org.springframework.security
spring-security-config
5.4.1
org.springframework.security
spring-security-oauth2-resource-server
5.4.1
org.springframework.security
spring-security-oauth2-jose
5.4.1
- 修改配置文件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
- 相关代码
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 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);
}
}