拥抱 Spring 全新 OAuth 解决方案:spring-authorization-server 该怎么玩?

点击关注公众号,实用技术文章及时了解拥抱 Spring 全新 OAuth 解决方案:spring-authorization-server 该怎么玩?


前言

为什么使用Spring-authorization-server?

真实原因:原先是因为个人原因,需要研究新版鉴权服务,看到了spring-authorization-server,使用过程中,想着能不能整合新版本cloud,因此此处先以SpringBoot搭建spring-authorization-server,后续再替换为springcloud2021。

官方原因:原先使用Spring Security OAuth,而该项目已经逐渐被淘汰,虽然网上还是有不少该方案,但秉着技术要随时代更新,从而使用spring-authorization-server

Spring 团队正式宣布 Spring Security OAuth 停止维护,该项目将不会再进行任何的迭代

拥抱 Spring 全新 OAuth 解决方案:spring-authorization-server 该怎么玩?

项目构建

以springboot搭建spring-authorization-server(即认证与资源服务器)

数据库相关表结构构建

需要创建3张表,sql分别如下

CREATE TABLE `oauth2_authorization`  (
  `id` varchar(100CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
  `registered_client_id` varchar(100CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
  `principal_name` varchar(200CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
  `authorization_grant_type` varchar(100CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
  `attributes` varchar(4000CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL,
  `state` varchar(500CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL,
  `authorization_code_value` blob NULL,
  `authorization_code_issued_at` timestamp(0NULL DEFAULT NULL,
  `authorization_code_expires_at` timestamp(0NULL DEFAULT NULL,
  `authorization_code_metadata` varchar(2000CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL,
  `access_token_value` blob NULL,
  `access_token_issued_at` timestamp(0NULL DEFAULT NULL,
  `access_token_expires_at` timestamp(0NULL DEFAULT NULL,
  `access_token_metadata` varchar(2000CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL,
  `access_token_type` varchar(100CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL,
  `access_token_scopes` varchar(1000CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL,
  `oidc_id_token_value` blob NULL,
  `oidc_id_token_issued_at` timestamp(0NULL DEFAULT NULL,
  `oidc_id_token_expires_at` timestamp(0NULL DEFAULT NULL,
  `oidc_id_token_metadata` varchar(2000CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL,
  `refresh_token_value` blob NULL,
  `refresh_token_issued_at` timestamp(0NULL DEFAULT NULL,
  `refresh_token_expires_at` timestamp(0NULL DEFAULT NULL,
  `refresh_token_metadata` varchar(2000CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL,
  PRIMARY KEY (`id`USING BTREE
ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci ROW_FORMAT = Dynamic;
 
 
CREATE TABLE `oauth2_authorization_consent`  (
  `registered_client_id` varchar(100CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
  `principal_name` varchar(200CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
  `authorities` varchar(1000CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
  PRIMARY KEY (`registered_client_id``principal_name`USING BTREE
ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci ROW_FORMAT = Dynamic;
 
 
 
CREATE TABLE `oauth2_registered_client`  (
  `id` varchar(100CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
  `client_id` varchar(100CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
  `client_id_issued_at` timestamp(0NOT NULL DEFAULT CURRENT_TIMESTAMP(0),
  `client_secret` varchar(200CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL,
  `client_secret_expires_at` timestamp(0NULL DEFAULT NULL,
  `client_name` varchar(200CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
  `client_authentication_methods` varchar(1000CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
  `authorization_grant_types` varchar(1000CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
  `redirect_uris` varchar(1000CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL,
  `scopes` varchar(1000CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
  `client_settings` varchar(2000CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
  `token_settings` varchar(2000CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
  PRIMARY KEY (`id`USING BTREE
ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci ROW_FORMAT = Dynamic;
先进行认证服务器相关配置

pom.xml引入依赖

注意!!!spring boot版本需2.6.x以上,是为后面升级成cloud做准备

<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <version>1.18.22</version>
</dependency>
 
<!-- 此依赖是个人公共依赖,你们引入其他具体依赖即可 -->
<dependency>
    <groupId>com.xxxx.iov</groupId>
    <artifactId>iov-cloud-framework-web</artifactId>
    <version>2.0.0-SNAPSHOT</version>
    <exclusions>
        <!-- 这里是因为公共依赖中的web版本太低,所以移除 -->
        <exclusion>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </exclusion>
    </exclusions>
</dependency>
 
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
    <version>2.6.6</version>
</dependency>
 
<!-- hutool -->
<dependency>
    <groupId>cn.hutool</groupId>
    <artifactId>hutool-all</artifactId>
    <version>5.8.0</version>
</dependency>
 
<!-- fastjson -->
<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>fastjson</artifactId>
    <version>1.2.39</version>
</dependency>
 
<!-- security -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>
 
<!-- oauth2-authorization-server -->
<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-oauth2-authorization-server</artifactId>
    <version>0.2.3</version>
</dependency>
 
<!-- security-cas -->
<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-cas</artifactId>
</dependency>
 
<!-- thymeleaf -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
 
<!-- 数据连接池 -->
<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>druid-spring-boot-starter</artifactId>
    <version>1.2.9</version>
</dependency>
 
<!-- 数据库驱动 -->
<dependency>
    <groupId>MySQL</groupId>
    <artifactId>mysql-connector-Java</artifactId>
    <version>8.0.28</version>
</dependency>
 
<!-- mybatis-plus -->
<dependency>
    <groupId>com.baomidou</groupId>
    <artifactId>mybatis-plus-boot-starter</artifactId>
    <version>3.5.1</version>
</dependency>
 
<!-- guava -->
<dependency>
    <groupId>com.google.guava</groupId>
    <artifactId>guava</artifactId>
    <version>31.1-jre</version>
</dependency>

创建自定义登录页面 login.html (可不要,使用自带的登录界面)

<!DOCTYPE html>
<html lang="en"
      xmlns:th="https://www.thymeleaf.org"
      xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity5">

<head>
    <meta charset="utf-8">
    <meta name="author" content="test">
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <meta name="description" content="This is a login page template based on Bootstrap 5">
    <title>Login Page</title>
    <style>
        .is-invalid {
            color: red;
        }
 
        .invalid-feedback {
            color: red;
        }
 
        .mb-3 {
            margin-bottom3px;
        }
    
</style>
    <script th:inline="JavaScript">
        /*<![CDATA[*/
        // const baseURL = /*[[@{/}]]*/ ''; /*]]>*/
        if (window !== top) {
            top.location.href = location.href;
        }
    
</script>
</head>
<body class="hold-transition login-page">
<div class="login-box">
    <div class="card">
        <div class="card-body login-card-body">
            <p class="login-box-msg">Sign in to start your session</p>
            <div th:if="${param.error}" class="alert alert-error">
                Invalid username and password.
            </div>
            <div th:if="${param.logout}" class="alert alert-success">
                You have been logged out.
            </div>
            <form th:action="@{/login}" method="post" id="loginForm">
                <div class="input-group mb-3">
                    <input type="text" class="form-control" value="zxg" name="username" placeholder="Email"
                           autocomplete="off">

                </div>
                <div class="input-group mb-3">
                    <input type="password" id="password" name="password" value="123" class="form-control"
                           maxlength="25" placeholder="Password"
                           autocomplete="off">

                </div>
                <div class="row">
                    <div class="col-4">
                        <button type="submit" id="submitBtn">Sign In</button>
                    </div>
                </div>
            </form>
            <p class="mb-1">
                <a href="javascript:void(0)">I forgot my password</a>
            </p>
            <p class="mb-0">
                <a href="javascript:void(0)" class="text-center">Register a new membership</a>
            </p>
        </div>
    </div>
</div>
 
<script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.6.0/jquery.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/jsencrypt/3.1.0/jsencrypt.min.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/jquery-validate/1.9.0/jquery.validate.min.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/jquery-validate/1.9.0/additional-methods.min.js"></script>
 
<script th:inline="javascript">
 
    $(function ({
        var encrypt = new JSEncrypt();
 
        $.validator.setDefaults({
            submitHandlerfunction (form{
                console.log("Form successful submitted!");
                form.submit();
            }
        });
 
    });
</script>
</body>
</html>

创建自定义授权页面 consent.html(可不要,可使用自带的授权页面)

<!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>授权页面</title>
    <style>
        body {
            background-color: aliceblue;
        }
    
</style>
   <script>
      function cancelConsent({
         document.consent_form.reset();
         document.consent_form.submit();
      }
   
</script>
</head>
<body>
<div class="container">
    <div class="py-5">
        <h1 class="text-center text-primary">用户授权确认</h1>
    </div>
    <div class="row">
        <div class="col text-center">
            <p>
                应用
                <a href="https://felord.cn"><span class="font-weight-bold text-primary" th:text="${clientName}"></span></a>
                想要访问您的账号
                <span class="font-weight-bold" th:text="${principalName}"></span>
            </p>
        </div>
    </div>
    <div class="row pb-3">
        <div class="col text-center"><p>上述应用程序请求以下权限<br/>请审阅以下选项并勾选您同意的权限</p></div>
    </div>
    <div class="row">
        <div class="col text-center">
            <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">
                    <input class="form-check-input"
                           type="checkbox"
                           name="scope"
                           th:value="${scope.scope}"
                           th:id="${scope.scope}">

                    <label class="form-check-label font-weight-bold" th:for="${scope.scope}" th:text="${scope.scope}"></label>
                    <p class="text-primary" th:text="${scope.description}"></p>
                </div>
 
                <p th:if="${not #lists.isEmpty(previouslyApprovedScopes)}">您已对上述应用授予以下权限:</p>
                <div th:each="scope: ${previouslyApprovedScopes}" class="form-group form-check py-1">
                    <input class="form-check-input"
                           type="checkbox"
                           th:id="${scope.scope}"
                           disabled
                           checked>

                    <label class="form-check-label font-weight-bold" th:for="${scope.scope}" th:text="${scope.scope}"></label>
                    <p class="text-primary" th:text="${scope.description}"></p>
                </div>
 
                <div class="form-group pt-3">
                    <button class="btn btn-primary btn-lg" type="submit" id="submit-consent">
                        同意授权
                    </button>
                </div>
                <div class="form-group">
                    <button class="btn btn-link regular" type="button" id="cancel-consent" onclick="cancelConsent();">
                        取消授权
                    </button>
                </div>
            </form>
        </div>
    </div>
    <div class="row pt-4">
        <div class="col text-center">
            <p>
                <small>
                    需要您同意并提供访问权限。
                    <br/>如果您不同意,请单击<span class="font-weight-bold text-primary">取消授权</span>,将不会为上述应用程序提供任何您的信息。
                </small>
            </p>
        </div>
    </div>
</div>
</body>
</html>

修改配置文件 application.yml(配置内容可自行简略)

server:
  port: 9000
 
spring:
  application:
    name: authorization-server
  thymeleaf:
    cache: false
  datasource:
    url: jdbc:mysql://192.168.1.69:3306/test
    username: root
    password: root
    driver-class-name: com.mysql.cj.jdbc.Driver
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: http://127.0.0.1:9000  #认证中心端点,作为资源端的配置
          
application:
  security:
    excludeUrls: #excludeUrls中存放白名单地址
      - "/favicon.ico" 
 
# mybatis plus配置
mybatis-plus:
  mapper-locations: classpath:/mapper/*Mapper.xml
  global-config:
    # 关闭MP3.0自带的banner
    banner: false
    db-config:
      #主键类型  0:"数据库ID自增", 1:"不操作", 2:"用户输入ID",3:"数字型snowflake", 4:"全局唯一ID UUID", 5:"字符串型snowflake";
      id-type: AUTO
      #字段策略
      insert-strategy: not_null
      update-strategy: not_null
      select-strategy: not_null
      #驼峰下划线w转换
      table-underline: true
      # 逻辑删除配置
      # 逻辑删除全局值(1表示已删除,这也是Mybatis Plus的默认配置)
      logic-delete-value: 1
      # 逻辑未删除全局值(0表示未删除,这也是Mybatis Plus的默认配置)
      logic-not-delete-value: 0
  configuration:
    #驼峰
    map-underscore-to-camel-case: true
    #打开二级缓存
    cache-enabled: true
    # log-impl: org.apache.ibatis.logging.stdout.StdOutImpl #开启sql日志

新增认证服务器配置文件 AuthorizationServerConfig

@Configuration(proxyBeanMethods = false)
public class AuthorizationServerConfig {
    /**
     * 自定义授权页面
     * 使用系统自带的即不用
     */

    private static final String CUSTOM_CONSENT_PAGE_URI = "/oauth2/consent";
 
    /**
     * 自定义UserDetailsService
     */

    @Autowired
    private UserService userService;
 
 
    /**
     *
     * 使用默认配置进行form表单登录
     * OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http)
     */

    @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)
                .userDetailsService(userService)
                .authorizeRequests(authorizeRequests -> authorizeRequests.anyRequest().authenticated())
                .csrf(csrf -> csrf.ignoringRequestMatchers(endpointsMatcher))
                .apply(authorizationServerConfigurer);
        return http.formLogin(Customizer.withDefaults()).build();
    }
 
    /**
     * 注册客户端应用
     */

    @Bean
    public RegisteredClientRepository registeredClientRepository(JdbcTemplate jdbcTemplate) {
        // Save registered client in db as if in-jdbc
        RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString())
                .clientId("zxg")
                .clientSecret("123")
                .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
                .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
                .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
                .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
                // 回调地址
                .redirectUri("http://www.baidu.com")
                // scope自定义的客户端范围
                .scope(OidcScopes.OPENID)
                .scope("message.read")
                .scope("message.write")
                // client请求访问时需要授权同意
                .clientSettings(ClientSettings.builder().requireAuthorizationConsent(true).build())
                // token配置项信息
                .tokenSettings(TokenSettings.builder()
                        // token有效期100分钟
                        .accessTokenTimeToLive(Duration.ofMinutes(100L))
                        // 使用默认JWT相关格式
                        .accessTokenFormat(OAuth2TokenFormat.SELF_CONTAINED)
                        // 开启刷新token
                        .reuseRefreshTokens(true)
                        // refreshToken有效期120分钟
                        .refreshTokenTimeToLive(Duration.ofMinutes(120L))
                        .idTokenSignatureAlgorithm(SignatureAlgorithm.RS256).build()
                )
                .build();
 
        // Save registered client in db as if in-memory
        JdbcRegisteredClientRepository registeredClientRepository = new JdbcRegisteredClientRepository(jdbcTemplate);
        registeredClientRepository.save(registeredClient);
        return registeredClientRepository;
    }
 
    /**
     * 授权服务:管理OAuth2授权信息服务
     */

    @Bean
    public OAuth2AuthorizationService authorizationService(JdbcTemplate jdbcTemplate, RegisteredClientRepository registeredClientRepository) {
        return new JdbcOAuth2AuthorizationService(jdbcTemplate, registeredClientRepository);
    }
 
    /**
     * 授权确认信息处理服务
     */

    @Bean
    public OAuth2AuthorizationConsentService authorizationConsentService(JdbcTemplate jdbcTemplate, RegisteredClientRepository registeredClientRepository) {
        return new JdbcOAuth2AuthorizationConsentService(jdbcTemplate, registeredClientRepository);
    }
 
    /**
     * 加载JWK资源
     * JWT:指的是 JSON Web Token,不存在签名的JWT是不安全的,存在签名的JWT是不可窜改的
     * JWS:指的是签过名的JWT,即拥有签名的JWT
     * JWK:既然涉及到签名,就涉及到签名算法,对称加密还是非对称加密,那么就需要加密的 密钥或者公私钥对。此处我们将 JWT的密钥或者公私钥对统一称为 JSON WEB KEY,即 JWK。
     */

    @Bean
    public JWKSource<SecurityContext> jwkSource() {
        RSAKey rsaKey = JwksUtils.generateRsa();
        JWKSet jwkSet = new JWKSet(rsaKey);
        return (jwkSelector, securityContext) -> jwkSelector.select(jwkSet);
    }
 
    /**
     * 配置 OAuth2.0 提供者元信息
     */

    @Bean
    public ProviderSettings providerSettings() {
        return ProviderSettings.builder().issuer("http://127.0.0.1:9000").build();
    }
 
}

新增Security的配置文件WebSecurityConfig

@Configuration
@EnableWebSecurity(debug = true//开启Security
public class WebSecurityConfig {
    @Autowired
    private ApplicationProperties properties;
 
    /**
     * 设置加密方式
     */

    @Bean
    public PasswordEncoder passwordEncoder() {
//        // 将密码加密方式采用委托方式,默认以BCryptPasswordEncoder方式进行加密,兼容ldap,MD4,MD5等方式
//        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
 
        // 此处我们使用明文方式 不建议这样
        return NoOpPasswordEncoder.getInstance();
    }
 
    /**
     * 使用WebSecurity.ignoring()忽略某些URL请求,这些请求将被Spring Security忽略
     */

    @Bean
    WebSecurityCustomizer webSecurityCustomizer() {
        return new WebSecurityCustomizer() {
            @Override
            public void customize(WebSecurity web) {
                // 读取配置文件application.security.excludeUrls下的链接进行忽略
                web.ignoring().antMatchers(properties.getSecurity().getExcludeUrls().toArray(new String[]{}));
            }
        };
    }
 
    /**
     * 针对http请求,进行拦截过滤
     *
     * CookieCsrfTokenRepository进行CSRF保护的工作方式:
     *      1.客户端向服务器发出GET请求,例如请求主页
     *      2.Spring发送 GET 请求的响应以及 Set-cookie 标头,其中包含安全生成的XSRF令牌
     */

    @Bean
    public SecurityFilterChain httpSecurityFilterChain(HttpSecurity httpSecurity) throws Exception {
        httpSecurity
                .authorizeRequests(authorizeRequests ->
                        authorizeRequests.antMatchers("/login").permitAll()
                                .anyRequest().authenticated()
                )
 
                //使用默认登录页面
                //.formLogin(withDefaults())
 
                //设置form登录,设置且放开登录页login
                .formLogin(fromlogin -> fromlogin.loginPage("/login").permitAll())
 
                // Spring Security CSRF保护
                .csrf(csrfToken -> csrfToken.csrfTokenRepository(new CookieCsrfTokenRepository()))
                
//                 //开启认证服务器的资源服务器相关功能,即需校验token
//                .oauth2ResourceServer()
//                .accessDeniedHandler(new SimpleAccessDeniedHandler())
//                .authenticationEntryPoint(new SimpleAuthenticationEntryPoint())
//                .jwt()
        ;
        return httpSecurity.build();
    }
 
}

新增读取application配置的类 ApplicationProperties

/**
* 此步主要是获取配置文件中配置的白名单,可自行舍去或自定义实现其他方式
**/

@Data
@Component
@ConfigurationProperties("application")
public class ApplicationProperties {
    private final Security security = new Security();
 
    @Data
    public static class Security {
        private Oauth2 oauth2;
        private List<String> excludeUrls = new ArrayList<>();
 
        @Data
        public static class Oauth2 {
            private String issuerUrl;
 
        }
    }
}

新增 JwksUtils 类和 KeyGeneratorUtils,这两个类作为JWT对称加密

public final class JwksUtils {
 
    private JwksUtils() {
    }
 
    /**
     * 生成RSA加密key (即JWK)
     */

    public static RSAKey generateRsa() {
        // 生成RSA加密的key
        KeyPair keyPair = KeyGeneratorUtils.generateRsaKey();
        // 公钥
        RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
        // 私钥
        RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
        // 构建RSA加密key
        return new RSAKey.Builder(publicKey)
                .privateKey(privateKey)
                .keyID(UUID.randomUUID().toString())
                .build();
    }
 
    /**
     * 生成EC加密key (即JWK)
     */

    public static ECKey generateEc() {
        // 生成EC加密的key
        KeyPair keyPair = KeyGeneratorUtils.generateEcKey();
        // 公钥
        ECPublicKey publicKey = (ECPublicKey) keyPair.getPublic();
        // 私钥
        ECPrivateKey privateKey = (ECPrivateKey) keyPair.getPrivate();
        // 根据公钥参数生成曲线
        Curve curve = Curve.forECParameterSpec(publicKey.getParams());
        // 构建EC加密key
        return new ECKey.Builder(curve, publicKey)
                .privateKey(privateKey)
                .keyID(UUID.randomUUID().toString())
                .build();
    }
 
    /**
     * 生成HmacSha256密钥
     */

    public static OctetSequenceKey generateSecret() {
        SecretKey secretKey = KeyGeneratorUtils.generateSecretKey();
        return new OctetSequenceKey.Builder(secretKey)
                .keyID(UUID.randomUUID().toString())
                .build();
    }
}
 
 
class KeyGeneratorUtils {
 
    private KeyGeneratorUtils() {
    }
 
    /**
     * 生成RSA密钥
     */

    static KeyPair generateRsaKey() {
        KeyPair keyPair;
        try {
            KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
            keyPairGenerator.initialize(2048);
            keyPair = keyPairGenerator.generateKeyPair();
        } catch (Exception ex) {
            throw new IllegalStateException(ex);
        }
        return keyPair;
    }
 
    /**
     * 生成EC密钥
     */

    static KeyPair generateEcKey() {
        EllipticCurve ellipticCurve = new EllipticCurve(
                new ECFieldFp(
                        new BigInteger("115792089210356248762697446949407573530086143415290314195533631308867097853951")),
                new BigInteger("115792089210356248762697446949407573530086143415290314195533631308867097853948"),
                new BigInteger("41058363725152142129326129780047268409114441015993725554835256314039467401291"));
        ECPoint ecPoint = new ECPoint(
                new BigInteger("48439561293906451759052585252797914202762949526041747995844080717082404635286"),
                new BigInteger("36134250956749795798585127919587881956611106672985015071877198253568414405109"));
        ECParameterSpec ecParameterSpec = new ECParameterSpec(
                ellipticCurve,
                ecPoint,
                new BigInteger("115792089210356248762697446949407573529996955224135760342422259061068512044369"),
                1);
 
        KeyPair keyPair;
        try {
            KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("EC");
            keyPairGenerator.initialize(ecParameterSpec);
            keyPair = keyPairGenerator.generateKeyPair();
        } catch (Exception ex) {
            throw new IllegalStateException(ex);
        }
        return keyPair;
    }
 
    /**
     * 生成HmacSha256密钥
     */

    static SecretKey generateSecretKey() {
        SecretKey hmacKey;
        try {
            hmacKey = KeyGenerator.getInstance("HmacSha256").generateKey();
        } catch (Exception ex) {
            throw new IllegalStateException(ex);
        }
        return hmacKey;
    }
}

新建 ConsentController,编写登录和认证页面的跳转

如果在上面没有使用自定义的登录和授权页面,下面的跳转方法按需舍去

@Slf4j
@Controller
public class ConsentController {
 
    private final RegisteredClientRepository registeredClientRepository;
    private final OAuth2AuthorizationConsentService authorizationConsentService;
 
    public ConsentController(RegisteredClientRepository registeredClientRepository,
                             OAuth2AuthorizationConsentService authorizationConsentService)
 
{
        this.registeredClientRepository = registeredClientRepository;
        this.authorizationConsentService = authorizationConsentService;
    }
 
    @ResponseBody
    @GetMapping("/favicon.ico")
    public String faviconico(){
        return "favicon.ico";
    }
 
    @GetMapping("/login")
    public String loginPage(){
        return "login";
    }
 
    @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) 
{
 
        // Remove scopes that were already approved
        Set<String> scopesToApprove = new HashSet<>();
        Set<String> previouslyApprovedScopes = new HashSet<>();
        RegisteredClient registeredClient = this.registeredClientRepository.findByClientId(clientId);
        OAuth2AuthorizationConsent currentAuthorizationConsent =
                this.authorizationConsentService.findById(registeredClient.getId(), principal.getName());
        Set<String> authorizedScopes;
        if (currentAuthorizationConsent != null) {
            authorizedScopes = currentAuthorizationConsent.getScopes();
        } else {
            authorizedScopes = Collections.emptySet();
        }
        for (String requestedScope : StringUtils.delimitedListToStringArray(scope, " ")) {
            if (authorizedScopes.contains(requestedScope)) {
                previouslyApprovedScopes.add(requestedScope);
            } else {
                scopesToApprove.add(requestedScope);
            }
        }
 
        model.addAttribute("clientId", clientId);
        model.addAttribute("state", state);
        model.addAttribute("scopes", withDescription(scopesToApprove));
        model.addAttribute("previouslyApprovedScopes", withDescription(previouslyApprovedScopes));
        model.addAttribute("principalName", principal.getName());
 
        return "consent";
    }
 
    private static Set<ScopeWithDescription> withDescription(Set<String> scopes) {
        Set<ScopeWithDescription> scopeWithDescriptions = new HashSet<>();
        for (String scope : scopes) {
            scopeWithDescriptions.add(new ScopeWithDescription(scope));
 
        }
        return scopeWithDescriptions;
    }
 
    public static class ScopeWithDescription {
        private static final String DEFAULT_DESCRIPTION = "UNKNOWN SCOPE - We cannot provide information about this permission, use caution when granting this.";
        private static final Map<String, String> scopeDescriptions = new HashMap<>();
        static {
            scopeDescriptions.put(
                    "message.read",
                    "This application will be able to read your message."
            );
            scopeDescriptions.put(
                    "message.write",
                    "This application will be able to add new messages. It will also be able to edit and delete existing messages."
            );
            scopeDescriptions.put(
                    "other.scope",
                    "This is another scope example of a scope description."
            );
        }
 
        public final String scope;
        public final String description;
 
        ScopeWithDescription(String scope) {
            this.scope = scope;
            this.description = scopeDescriptions.getOrDefault(scope, DEFAULT_DESCRIPTION);
        }
    }
 
}

新建 UserController,User,UserService等标准的自定义用户业务,此处仅放出UserServiceImpl

@RequiredArgsConstructor
@Slf4j
@Component
class UserServiceImpl implements UserService {
    private final UserMapper userMapper;
 
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userMapper.selectOne(new LambdaQueryWrapper<User>().eq(User::getUsername,username));
        return new org.springframework.security.core.userdetails.User(username, user.getPassword(), new ArrayList<>());
    }
}

启动项目,如下图

拥抱 Spring 全新 OAuth 解决方案:spring-authorization-server 该怎么玩?

认证服务器整体结构图

拥抱 Spring 全新 OAuth 解决方案:spring-authorization-server 该怎么玩?
资源服务器相关配置

pom.xml引入资源服务器相关依赖

<!-- resource-server资源服务器 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
 
<!-- security -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

新增配置文件 application.yaml

server:
  port: 9003
spring:
  application:
    name: resource
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: http://127.0.0.1:9000
feign:
  client:
    config:
      default: #配置超时时间
        connect-timeout: 10000
        read-timeout: 10000

新增资源服务器配置文件 ResourceServerConfiguration

@Configuration
@EnableWebSecurity(debug = true)
@EnableGlobalMethodSecurity(prePostEnabled = true//开启鉴权服务
public class ResourceServerConfiguration {
 
    @Bean
    public SecurityFilterChain httpSecurityFilterChain(HttpSecurity httpSecurity) throws Exception {
        // 所有请求都进行拦截
        httpSecurity.authorizeRequests().anyRequest().authenticated();
        // 关闭session
        httpSecurity.sessionManagement().disable();
        // 配置资源服务器的无权限,无认证拦截器等 以及JWT验证
        httpSecurity.oauth2ResourceServer()
                .accessDeniedHandler(new SimpleAccessDeniedHandler())
                .authenticationEntryPoint(new SimpleAuthenticationEntryPoint())
                .jwt();
        return httpSecurity.build();
    }
 
}

新增相关无认证无权限统一拦截回复 SimpleAccessDeniedHandlerSimpleAuthenticationEntryPoint

/**
 * 携带了token 而且token合法 但是权限不足以访问其请求的资源 403
 * @author zxg
 */

public class SimpleAccessDeniedHandler implements AccessDeniedHandler {
 
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        response.setCharacterEncoding("utf-8");
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        ObjectMapper objectMapper = new ObjectMapper();
        String resBody = objectMapper.writeValueAsString(SingleResultBundle.failed("无权访问"));
        PrintWriter printWriter = response.getWriter();
        printWriter.print(resBody);
        printWriter.flush();
        printWriter.close();
    }
}
 
 
/**
 * 在资源服务器中 不携带token 或者token无效  401
 * @author zxg
 */

@Slf4j
public class SimpleAuthenticationEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        if (response.isCommitted()){
            return;
        }
 
        Throwable throwable = authException.fillInStackTrace();
 
        String errorMessage = "认证失败";
 
        if (throwable instanceof BadCredentialsException){
            errorMessage = "错误的客户端信息";
        }else {
            Throwable cause = authException.getCause();
 
            if (cause instanceof JwtValidationException) {
                log.warn("JWT Token 过期,具体内容:" + cause.getMessage());
                errorMessage = "无效的token信息";
            } else if (cause instanceof BadJwtException){
                log.warn("JWT 签名异常,具体内容:" + cause.getMessage());
                errorMessage = "无效的token信息";
            } else if (cause instanceof AccountExpiredException){
                errorMessage = "账户已过期";
            } else if (cause instanceof LockedException){
                errorMessage = "账户已被锁定";
//            } else if (cause instanceof InvalidClientException || cause instanceof BadClientCredentialsException){
//                response.getWriter().write(JSON.toJSONString(SingleResultBundle.failed(401,"无效的客户端")));
//            } else if (cause instanceof InvalidGrantException || cause instanceof RedirectMismatchException){
//                response.getWriter().write(JSON.toJSONString(SingleResultBundle.failed("无效的类型")));
//            } else if (cause instanceof UnauthorizedClientException) {
//                response.getWriter().write(JSON.toJSONString(SingleResultBundle.failed("未经授权的客户端")));
            } else if (throwable instanceof InsufficientAuthenticationException) {
                String message = throwable.getMessage();
                if (message.contains("Invalid token does not contain resource id")){
                    errorMessage = "未经授权的资源服务器";
                }else if (message.contains("Full authentication is required to access this resource")){
                    errorMessage = "缺少验证信息";
                }
            }else {
                errorMessage = "验证异常";
            }
        }
 
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.setCharacterEncoding("utf-8");
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        ObjectMapper objectMapper = new ObjectMapper();
        String resBody = objectMapper.writeValueAsString(SingleResultBundle.failed(errorMessage));
        PrintWriter printWriter = response.getWriter();
        printWriter.print(resBody);
        printWriter.flush();
        printWriter.close();
    }
}

新增 ResourceController 进行接口测试

@Slf4j
@RestController
public class ResourceController {
 
    /**
     * 测试Spring Authorization Server,测试权限
     */

    @PreAuthorize("hasAuthority('SCOPE_message.read')")
    @GetMapping("/getTest")
    public String getTest(){
        return "getTest";
    }
 
    /**
     * 默认登录成功跳转页为 /  防止404状态
     *
     * @return the map
     */

    @GetMapping("/")
    public Map<String, String> index() {
        return Collections.singletonMap("msg""login success!");
    }
 
    @GetMapping("/getResourceTest")
    public SingleResultBundle<String> getResourceTest(){
        return SingleResultBundle.success("这是resource的测试方法 getResourceTest()");
    }
}

启动项目,效果如下

拥抱 Spring 全新 OAuth 解决方案:spring-authorization-server 该怎么玩?

项目总体结构如下

拥抱 Spring 全新 OAuth 解决方案:spring-authorization-server 该怎么玩?
测试认证鉴权
#调用 /oauth2/authorize ,获取code
http://127.0.0.1:9000/oauth2/authorize?client_id=zxg&response_type=code&scope=message.read&redirect_uri=http://www.baidu.com
#会判断是否登录,若没有,则跳转到登录页面,如下图1
#登录完成后,会提示是否授权,若没有,则跳转到授权界面,如下图2
#授权成功后,跳转到回调地址,并带上code,如图3
拥抱 Spring 全新 OAuth 解决方案:spring-authorization-server 该怎么玩?
拥抱 Spring 全新 OAuth 解决方案:spring-authorization-server 该怎么玩?

打开postman,进行获取access_token

#访问 /oauth2/token 地址
#在Authorization中选择Basic Auth模式,填入对应客户端,其会在header中生成Authorization,如下图右侧
拥抱 Spring 全新 OAuth 解决方案:spring-authorization-server 该怎么玩?

返回结果如下

拥抱 Spring 全新 OAuth 解决方案:spring-authorization-server 该怎么玩?

调用ResourceController中的接口,测试token是否生效

拥抱 Spring 全新 OAuth 解决方案:spring-authorization-server 该怎么玩?

源码下载地址

  • https://gitee.com/rjj521/authorization-server-learn

总结

至此,spring-authorization-server的基础使用已完成,总体上和原Spring Security OAuth大差不差,个别配置项不同。期间在网上搜寻了很多资料,然后进行整合,因此文中存在与其他网上教程相同代码,如有争议,请联系我删除改正,谢谢。

来源:blog.csdn.net/qq_37182370/article/
details/124822587

推荐

Java面试题宝典

技术内卷群,一起来学习!!

拥抱 Spring 全新 OAuth 解决方案:spring-authorization-server 该怎么玩?

PS:因为公众号平台更改了推送规则,如果不想错过内容,记得读完点一下“在看”,加个“星标”,这样每次新文章推送才会第一时间出现在你的订阅列表里。“在看”支持我们吧!

原文始发于微信公众号(Java知音):拥抱 Spring 全新 OAuth 解决方案:spring-authorization-server 该怎么玩?

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。

文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/71114.html

(0)
小半的头像小半

相关推荐

发表回复

登录后才能评论
极客之音——专业性很强的中文编程技术网站,欢迎收藏到浏览器,订阅我们!