自定义OAuth2授权同意页面

777 阅读2分钟

本文已参与「新人创作礼」活动,一起开启掘金创作之路。

自定义OAuth2授权同意页面

前文我们已经简单的介绍了如何搭建授权服务器,下面将继续介绍如何自定义OAuth2授权同意页面。

如果你已经无法容忍Spring Authorization Server 默认丑陋的授权同意页面,那么你可以继续阅读本文,逐步创建一个令自己满意的授权同意页面。

OAuth2授权服务器实现

从创建一个授权服务器开始。

maven依赖

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-security</artifactId>
  <version>2.6.7</version>
</dependency>

<dependency>
  <groupId>org.springframework.security</groupId>
  <artifactId>spring-security-oauth2-authorization-server</artifactId>
  <version>0.3.1</version>
</dependency>

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-web</artifactId>
  <version>2.6.7</version>
</dependency>

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-thymeleaf</artifactId>
  <version>2.6.7</version>
</dependency>

配置

首先我们为授权服务器配置端口8080:

server:
  port: 8080

之后我们创建一个AuthorizationServerConfig配置类,在此类中我们将创建OAuth2授权服务器所需特定的Bean。首先指定我们授权同意页面/oauth2/consent uri替换原有默认实现。

@Configuration(proxyBeanMethods = false)
public class AuthorizationServerConfig {
    private static final String CUSTOM_CONSENT_PAGE_URI = "/oauth2/consent";

    @Bean
    @Order(Ordered.HIGHEST_PRECEDENCE)
    public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
        OAuth2AuthorizationServerConfigurer<HttpSecurity> authorizationServerConfigurer = new OAuth2AuthorizationServerConfigurer<>();
        //定义授权同意页面
        authorizationServerConfigurer.authorizationEndpoint(authorizationEndpoint ->
                authorizationEndpoint.consentPage(CUSTOM_CONSENT_PAGE_URI));

        RequestMatcher endpointsMatcher = authorizationServerConfigurer
                .getEndpointsMatcher();

        http.requestMatcher(endpointsMatcher)
                .authorizeRequests(authorizeRequests ->
                        authorizeRequests.anyRequest().authenticated()
                )
                .csrf(csrf -> csrf.ignoringRequestMatchers(endpointsMatcher))
                .apply(authorizationServerConfigurer);
        return http.exceptionHandling(exceptions -> exceptions.
                authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint("/login"))).build();
    }
  
  //...
}

接下来我们使用RegisteredClient构建器类型创建一个OAuth2客户端,并将它存储在缓存中。

 @Bean
    public RegisteredClientRepository registeredClientRepository() {
        RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString())
                .clientId("relive-client")
                .clientSecret("{noop}relive-client")
                .clientName("ReLive27")
                .clientAuthenticationMethods(s -> {
                    s.add(ClientAuthenticationMethod.CLIENT_SECRET_POST);
                    s.add(ClientAuthenticationMethod.CLIENT_SECRET_BASIC);
                })
                .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
                .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
                .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
                .authorizationGrantType(AuthorizationGrantType.PASSWORD)
                .redirectUri("http://127.0.0.1:8070/login/oauth2/code/messaging-client-authorization-code")
                .scope(OidcScopes.PROFILE)
                .scope("message.read")
                .scope("message.write")
                .clientSettings(ClientSettings.builder()
                        .requireAuthorizationConsent(true)
                        .requireProofKey(false)
                        .build())
                .tokenSettings(TokenSettings.builder()
                        .accessTokenFormat(OAuth2TokenFormat.SELF_CONTAINED)
                        .idTokenSignatureAlgorithm(SignatureAlgorithm.RS256)
                        .accessTokenTimeToLive(Duration.ofSeconds(30 * 60))
                        .refreshTokenTimeToLive(Duration.ofSeconds(60 * 60))
                        .reuseRefreshTokens(true)
                        .build())
                .build();

        return new InMemoryRegisteredClientRepository(registeredClient);
    }

其余配置将不再赘述,可以参考之前将JWT与Spring Security OAuth2结合使用文章。


接下来将创建一个授权页面控制器,并将所需参数传递给Model

@Controller
@RequiredArgsConstructor
public class AuthorizationConsentController {
    private final RegisteredClientRepository registeredClientRepository;

    @GetMapping(value = "/oauth2/consent")
    public String consent(Principal principal, Model model,
                          @RequestParam(OAuth2ParameterNames.CLIENT_ID) String clientId,
                          @RequestParam(OAuth2ParameterNames.SCOPE) String scope,
                          @RequestParam(OAuth2ParameterNames.STATE) String state) {

        Set<String> scopesToApprove = new LinkedHashSet<>();
        RegisteredClient registeredClient = this.registeredClientRepository.findByClientId(clientId);
        Set<String> scopes = registeredClient.getScopes();
        for (String requestedScope : StringUtils.delimitedListToStringArray(scope, " ")) {
            if (scopes.contains(requestedScope)) {
                scopesToApprove.add(requestedScope);
            }
        }

        model.addAttribute("clientId", clientId);
        model.addAttribute("clientName", registeredClient.getClientName());
        model.addAttribute("state", state);
        model.addAttribute("scopes", withDescription(scopesToApprove));
        model.addAttribute("principalName", principal.getName());
        model.addAttribute("redirectUri", registeredClient.getRedirectUris().iterator().next());

        return "consent";
    }

    private static Set<ScopeWithDescription> withDescription(Set<String> scopes) {
        Set<ScopeWithDescription> scopeWithDescriptions = new LinkedHashSet<>();
        for (String scope : scopes) {
            scopeWithDescriptions.add(new ScopeWithDescription(scope));

        }
        return scopeWithDescriptions;
    }

    public static class ScopeWithDescription {
        private static final String DEFAULT_DESCRIPTION = "我们无法提供有关此权限的信息";
        private static final Map<String, String> scopeDescriptions = new HashMap<>();

        static {
            scopeDescriptions.put(
                    "profile",
                    "验证您的身份"
            );
            scopeDescriptions.put(
                    "message.read",
                    "了解您可以访问哪些权限"
            );
            scopeDescriptions.put(
                    "message.write",
                    "代表您行事"
            );
        }

        public final String scope;
        public final String description;

        ScopeWithDescription(String scope) {
            this.scope = scope;
            this.description = scopeDescriptions.getOrDefault(scope, DEFAULT_DESCRIPTION);
        }
    }
}

之后让我们定义html页面,这里使用thymeleaf模版引擎:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css"
          integrity="sha384-JcKb8q3iqJ61gNV9KGb8thSsNjpSL0n8PARn9HuZOnIxN0hoP+VmmDGMN5t9UJ0Z" crossorigin="anonymous">
    <title>Custom consent page - Consent required</title>
    <style>
        body {
            background-color: #f6f8fa;
        }

        #submit-consent {
            width: 45%;
            float: right;
            height: 40px;
            font-size: 18px;
            border-color: #cccccc;
            margin-right: 3%;
        }

        #cancel-consent {
            width: 45%;
            height: 40px;
            font-size: 18px;
            color: black;
            background-color: #cccccc;
            border-color: #cccccc;
            float: left;
            margin-left: 3%;
        }
    </style>
    <script>
        function cancelConsent() {
            document.consent_form.reset();
            document.consent_form.submit();
        }
    </script>
</head>
<body>
<div style="width: 500px;height: 600px;margin: 100px auto">
    <h5 style="text-align: center"><b th:text="${clientName}"></b>希望获得以下许可:</h5>
    <div style="width: 100%;height: 500px;border: #cccccc 1px solid;border-radius: 10px">
        <form name="consent_form" method="post" action="/oauth2/authorize">
            <input type="hidden" name="client_id" th:value="${clientId}">
            <input type="hidden" name="state" th:value="${state}">

            <div th:each="scope: ${scopes}" class="form-group form-check py-1" style="margin-left: 5%">
                <input class="form-check-input"
                       type="checkbox"
                       name="scope"
                       th:value="${scope.scope}"
                       th:id="${scope.scope}"
                       checked>
                <label class="form-check-label font-weight-bold" th:for="${scope.scope}"
                       th:text="${scope.scope}=='profile'?(${scope.description}+'('+${principalName}+')'):${scope.description}"></label>
            </div>

            <hr style="width: 90%">
            <p style="margin-left: 5%"><b th:text="${clientName}"></b>尚未安装在您有权访问的任何账户上。</p>
            <hr style="width: 90%">
            <div class="form-group pt-3" style="width: 100%;height: 80px;">
                <button class="btn btn-primary btn-lg" type="submit" id="submit-consent">
                    授权同意
                </button>
                <button class="btn btn-primary btn-lg" type="button" id="cancel-consent" onclick="cancelConsent();">
                    取消
                </button>
            </div>
            <div style="margin-top: 5px;width: 100%;height: 50px">
                <p style="text-align: center;font-size: 14px">授权将重定向到</p>
                <p style="text-align: center;font-size: 14px"><b th:text="${redirectUri}"></b></p>
            </div>
        </form>
    </div>
</div>
</body>
</html>

访问授权页面

启动服务后,我们将发起一个授权请求,http://localhost:8080/oauth2/authorize?response_type=code&client_id=relive-client&scope=message.write%20message.read%20profile&state=some-state&redirect_uri=http://127.0.0.1:8070/login/oauth2/code/messaging-client-authorization-code, 在认证成功后,我们可以看到以下我们定义的授权同意页面:

custom-page.png

结论

与往常一样,本文中使用的源代码可在 GitHub 上获得。