Spring Security OAuth2.0

简介

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

image.png
  1. 用户在登录页选择登录方式,比如GitHub、微信等,请求路径:oauth2/authorization/{registrationId},以GitHub为例:oauth2/authorization/github ;
  2. OAuth2AuthorizationRequestRedirectFilter拦截请求,并调用OAuth2AuthorizationRequestResolver.resolve();
  3. OAuth2AuthorizationRequestResolver校验请求路径是否正确,如果正确则接着调用ClientRegistrationRepository.findByRegistrationId()获取ClientRegistration注册信息(配置),OAuth2AuthorizationRequestResolver将注册信息包装成OAuth2AuthorizationRequest并返回;
  4. OAuth2AuthorizationRequestRedirectFilter判断上一步返回的OAuth2AuthorizationRequest不为空,则接着判断当前授权类型是否==授权码类型,如果是则需要调用AuthorizationRequestRepository.saveAuthorizationRequest()存储OAuth2AuthorizationRequest(以Map方式K-state,V-OAuth2AuthorizationRequest存储到Session中),最后通过RedirectStrategy发起重定向操作。

Authorization Server授权

image.png
  1. OAuth2.0 Client选择某个三方授权中心(这边以基于spring security搭建的自定义授权中心为例),进入custom授权中心,发现用户未登录,跳转登录页面;
  2. 用户登录成功后,重现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授权端点;
  3. 接着进入授权页面,用户可以选择是否授权;
  4. 将授权结果以POST方式提交给AuthorizationEndpoint,如果授权成功,将签发code并重定向到客户端。

Authorization Server授权成功重定向到OAuth2.0 Client

image.png
  1. 用户在Authorization Server授权成功,重定向到OAuth2.0 Client,请求路径:/login/oauth2/code/{registrationId},以GitHub为例:/login/oauth2/code/github,OAuth2LoginAuthenticationFilter拦截到该请求,通过判断请求参数是否包含code、state或者error、state;
  2. 接着OAuth2LoginAuthenticationFilter调用AuthorizationRequestRepository.removeAuthorizationRequest()删除AuthorizationRequest并返回,返回的AuthorizationRequest不为空则走下一步;
  3. 再着OAuth2LoginAuthenticationFilter调用ClientRegistrationRepository.findByRegistrationId()获取配置的ClientRegistration,返回的ClientRegistration不为空则走下一步;
  4. 然后OAuth2LoginAuthenticationFilter调用AuthenticationManager.authenticate();
    1. 将认证逻辑委托给OAuth2LoginAuthenticationProvider;
    2. OAuth2LoginAuthenticationProvider调用OAuth2AuthorizationCodeAuthenticationProvider.authenticate(),OAuth2AuthorizationCodeAuthenticationProvider比较state的值是否一致,如果一致则调用OAuth2AccessTokenResponseClient.getTokenResponse()向Authorization Server获取accessToken;
    3. OAuth2LoginAuthenticationProvider调用OAuth2UserService.loadUser()向Resource Server获取用户信息。
  5. 最后OAuth2LoginAuthenticationFilter调用OAuth2AuthorizedClientRepository.saveAuthorizedClient()保存授权用户信息,OAuth2AuthorizedClientRepository调用OAuth2AuthorizedClientService.saveAuthorizedClient()以Map<OAuth2AuthorizedClientId, OAuth2AuthorizedClient>形式保存在内存中。

OAuth2.0 Client从Authorization Server获取accessToken

image.png

OAuth2.0 Client调用 POST /oauth/token从Authorization Server获取token信息。

OAuth2.0 Client从Resource Server获取用户信息

image.png
  1. 用户发起获取资源请求,例如:/getUser(路径可自定义,无特殊要求) ,必须包含Authorization: Bearer token值请求头;
  2. 经过BearerTokenAuthenticationFilter,将token解析成BearerToken
    [图片上传中...(image.png-b9379b-1608388383917-0)]
    AuthenticationToken对象,交给AuthenticationManager进行认证处理;
  3. 成功则继续往下处理filterChain.doFilter(request, response),失败则交给认证失败处理器来处理。

基于JWT token类型的认证

image.png
  1. AuthenticationManager将token认证委托给JwtAuthenticationProvider;
  2. JwtAuthenticationProvider通过JwtDecoder解析并校验token信息;
  3. 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

  1. 引入依赖
<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>
  1. 修改配置文件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

<!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">&nbsp;</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>&nbsp;</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

  1. 引入依赖
<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>
  1. 修改配置文件application.yml
server:
  port: 8081
  servlet:
    context-path: /oauth2authorizationserver
  1. 相关代码
    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<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

  1. 引入依赖
<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>
  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
  1. 相关代码
    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);
    }
}
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 217,406评论 6 503
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,732评论 3 393
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 163,711评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,380评论 1 293
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,432评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,301评论 1 301
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,145评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,008评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,443评论 1 314
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,649评论 3 334
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,795评论 1 347
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,501评论 5 345
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,119评论 3 328
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,731评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,865评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,899评论 2 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,724评论 2 354

推荐阅读更多精彩内容