前语

文章较长,步骤比较繁琐,请各位读者耐心观看。 上篇文章大概了解了下结构的相关理论,本篇文章将带我们一步步构建一个简略的认证服务器 开端之前先放一下文档的链接:官网文档

项目环境要求(当时结构版别1.1.0)

  1. Spring Boot版别大于等于3.1.0-RC1
  2. JDK版别大于等于17

认证项目建立

1. 在Idea中或Spring Initializr中创立spring boot项目

  1. Spring Boot版别挑选3.1.0,Java版别挑选17以上,在Dependencies中勾选Spring Authorization Server和spring web依赖,其它看自己需求

    Spring Authorization Server入门 (二) springboot整合Spring Authorization Server

    引进持久层结构(自己用的是MybatisPlus,读者可自选)

     <dependency>
         <groupId>com.baomidou</groupId>
         <artifactId>mybatis-plus-boot-starter</artifactId>
         <version>3.5.3.1</version>
     </dependency>
    

    引进webjars和bootstrap,自定义登录页和承认页面时运用

    <dependency>
        <groupId>org.webjars</groupId>
        <artifactId>webjars-locator-core</artifactId>
    </dependency>
    <dependency>
        <groupId>org.webjars</groupId>
        <artifactId>bootstrap</artifactId>
        <version>5.2.3</version>
    </dependency>
    

    项目pom.xml示例

    <?xml version="1.0" encoding="UTF-8"?>
    <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
             xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
        <modelVersion>4.0.0</modelVersion>
        <parent>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-parent</artifactId>
            <version>3.1.0</version>
            <relativePath/> <!-- lookup parent from repository -->
        </parent>
        <groupId>com.example</groupId>
        <artifactId>authorization-example</artifactId>
        <version>0.0.1-SNAPSHOT</version>
        <name>authorization-example</name>
        <description>authorization-example</description>
        <properties>
            <java.version>17</java.version>
        </properties>
        <dependencies>
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-oauth2-authorization-server</artifactId>
            </dependency>
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-thymeleaf</artifactId>
            </dependency>
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-web</artifactId>
            </dependency>
            <dependency>
                <groupId>com.mysql</groupId>
                <artifactId>mysql-connector-j</artifactId>
                <scope>runtime</scope>
            </dependency>
            <dependency>
                <groupId>org.projectlombok</groupId>
                <artifactId>lombok</artifactId>
                <optional>true</optional>
            </dependency>
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-test</artifactId>
                <scope>test</scope>
            </dependency>
            <dependency>
                <groupId>com.baomidou</groupId>
                <artifactId>mybatis-plus-boot-starter</artifactId>
                <version>3.5.3.1</version>
            </dependency>
            <dependency>
                <groupId>org.webjars</groupId>
                <artifactId>webjars-locator-core</artifactId>
            </dependency>
            <dependency>
                <groupId>org.webjars</groupId>
                <artifactId>bootstrap</artifactId>
                <version>5.2.3</version>
            </dependency>
        </dependencies>
        <build>
            <plugins>
                <plugin>
                    <groupId>org.springframework.boot</groupId>
                    <artifactId>spring-boot-maven-plugin</artifactId>
                    <configuration>
                        <excludes>
                            <exclude>
                                <groupId>org.projectlombok</groupId>
                                <artifactId>lombok</artifactId>
                            </exclude>
                        </excludes>
                    </configuration>
                </plugin>
            </plugins>
        </build>
    </project>
    
  2. 初始化结构自带数据库表

    schema方位如图

    Spring Authorization Server入门 (二) springboot整合Spring Authorization Server

    修改后适配MySQL的SQL如下

    -- 用户授权承认表
    CREATE TABLE oauth2_authorization_consent
    (
        registered_client_id varchar(100)  NOT NULL,
        principal_name       varchar(200)  NOT NULL,
        authorities          varchar(1000) NOT NULL,
        PRIMARY KEY (registered_client_id, principal_name)
    );
    -- 用户认证信息表
    CREATE TABLE oauth2_authorization
    (
        id                            varchar(100) NOT NULL,
        registered_client_id          varchar(100) NOT NULL,
        principal_name                varchar(200) NOT NULL,
        authorization_grant_type      varchar(100) NOT NULL,
        authorized_scopes             varchar(1000) DEFAULT NULL,
        attributes                    blob          DEFAULT NULL,
        state                         varchar(500)  DEFAULT NULL,
        authorization_code_value      blob          DEFAULT NULL,
        authorization_code_issued_at  DATETIME      DEFAULT NULL,
        authorization_code_expires_at DATETIME      DEFAULT NULL,
        authorization_code_metadata   blob          DEFAULT NULL,
        access_token_value            blob          DEFAULT NULL,
        access_token_issued_at        DATETIME      DEFAULT NULL,
        access_token_expires_at       DATETIME      DEFAULT NULL,
        access_token_metadata         blob          DEFAULT NULL,
        access_token_type             varchar(100)  DEFAULT NULL,
        access_token_scopes           varchar(1000) DEFAULT NULL,
        oidc_id_token_value           blob          DEFAULT NULL,
        oidc_id_token_issued_at       DATETIME      DEFAULT NULL,
        oidc_id_token_expires_at      DATETIME      DEFAULT NULL,
        oidc_id_token_metadata        blob          DEFAULT NULL,
        refresh_token_value           blob          DEFAULT NULL,
        refresh_token_issued_at       DATETIME      DEFAULT NULL,
        refresh_token_expires_at      DATETIME      DEFAULT NULL,
        refresh_token_metadata        blob          DEFAULT NULL,
        user_code_value               blob          DEFAULT NULL,
        user_code_issued_at           DATETIME      DEFAULT NULL,
        user_code_expires_at          DATETIME      DEFAULT NULL,
        user_code_metadata            blob          DEFAULT NULL,
        device_code_value             blob          DEFAULT NULL,
        device_code_issued_at         DATETIME      DEFAULT NULL,
        device_code_expires_at        DATETIME      DEFAULT NULL,
        device_code_metadata          blob          DEFAULT NULL,
        PRIMARY KEY (id)
    );
    -- 客户端表
    CREATE TABLE oauth2_registered_client
    (
        id                            varchar(100)                            NOT NULL,
        client_id                     varchar(100)                            NOT NULL,
        client_id_issued_at           DATETIME      DEFAULT CURRENT_TIMESTAMP NOT NULL,
        client_secret                 varchar(200)  DEFAULT NULL,
        client_secret_expires_at      DATETIME      DEFAULT NULL,
        client_name                   varchar(200)                            NOT NULL,
        client_authentication_methods varchar(1000)                           NOT NULL,
        authorization_grant_types     varchar(1000)                           NOT NULL,
        redirect_uris                 varchar(1000) DEFAULT NULL,
        post_logout_redirect_uris     varchar(1000) DEFAULT NULL,
        scopes                        varchar(1000)                           NOT NULL,
        client_settings               varchar(2000)                           NOT NULL,
        token_settings                varchar(2000)                           NOT NULL,
        PRIMARY KEY (id)
    );
    

2. 在config包下创立AuthorizationConfig类,并增加装备

装备端点的过滤器链

/**
 * 装备端点的过滤器链
 *
 * @param http spring security中心装备类
 * @return 过滤器链
 * @throws Exception 抛出
 */
@Bean
public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
    // 装备默许的设置,疏忽认证端点的csrf校验
    OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
    http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
        // 敞开OpenID Connect 1.0协议相关端点
        .oidc(Customizer.withDefaults())
        // 设置自定义用户承认授权页
        .authorizationEndpoint(authorizationEndpoint -> authorizationEndpoint.consentPage(CUSTOM_CONSENT_PAGE_URI));
    http
        // 当未登录时拜访认证端点时重定向至login页面
        .exceptionHandling((exceptions) -> exceptions
            .defaultAuthenticationEntryPointFor(
            new LoginUrlAuthenticationEntryPoint("/login"),
            new MediaTypeRequestMatcher(MediaType.TEXT_HTML)
            )
        )
        // 处理运用access token拜访用户信息端点和客户端注册端点
        .oauth2ResourceServer((resourceServer) -> resourceServer
        .jwt(Customizer.withDefaults()));
    return http.build();
}

装备身份验证过滤器链

/**
 * 装备认证相关的过滤器链
 *
 * @param http spring security中心装备类
 * @return 过滤器链
 * @throws Exception 抛出
 */
@Bean
public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
    http.authorizeHttpRequests((authorize) -> authorize
            // 放行静态资源
            .requestMatchers("/assets/**", "/webjars/**", "/login").permitAll()
            .anyRequest().authenticated()
        )
        // 指定登录页面
        .formLogin(formLogin ->
            formLogin.loginPage("/login")
        );
    // 增加BearerTokenAuthenticationFilter,将认证服务作为一个资源服务,解析恳求头中的token
    http.oauth2ResourceServer((resourceServer) -> resourceServer
        .jwt(Customizer.withDefaults()));
    return http.build();
}

装备暗码解析器

/**
 * 装备暗码解析器,运用BCrypt的办法对暗码进行加密和验证
 *
 * @return BCryptPasswordEncoder
 */
@Bean
public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
}

装备客户端repository

/**
 * 装备客户端Repository
 *
 * @param jdbcTemplate    db 数据源信息
 * @param passwordEncoder 暗码解析器
 * @return 根据数据库的repository
 */
@Bean
public RegisteredClientRepository registeredClientRepository(JdbcTemplate jdbcTemplate, PasswordEncoder passwordEncoder) {
    RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString())
        // 客户端id
        .clientId("messaging-client")
        // 客户端秘钥,运用暗码解析器加密
        .clientSecret(passwordEncoder.encode("123456"))
        // 客户端认证办法,根据恳求头的认证
        .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
        // 装备资源服务器运用该客户端获取授权时支撑的办法
        .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
        .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
        .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
        // 授权码形式回调地址,oauth2.1已改为精准匹配,不能只设置域名,而且屏蔽了localhost
        .redirectUri("http://127.0.0.1:8080/login/oauth2/code/messaging-client-oidc")
        // 装备一个百度的域名回调,稍后运用该回调获取code
        .redirectUri("https://www.baidu.com")
        // 该客户端的授权规模,OPENID与PROFILE是IdToken的scope,获取授权时恳求OPENID的scope时认证服务会回来IdToken
        .scope(OidcScopes.OPENID)
        .scope(OidcScopes.PROFILE)
        // 自定scope
        .scope("message.read")
        .scope("message.write")
        // 客户端设置,设置用户需求承认授权
        .clientSettings(ClientSettings.builder().requireAuthorizationConsent(true).build())
        .build();
    // 根据db存储客户端,还有一个根据内存的完成 InMemoryRegisteredClientRepository
    JdbcRegisteredClientRepository registeredClientRepository = new JdbcRegisteredClientRepository(jdbcTemplate);
    // 初始化客户端
    RegisteredClient repositoryByClientId = registeredClientRepository.findByClientId(registeredClient.getClientId());
    if (repositoryByClientId == null) {
        registeredClientRepository.save(registeredClient);
    }
    // 设备码授权客户端
    RegisteredClient deviceClient = RegisteredClient.withId(UUID.randomUUID().toString())
        .clientId("device-message-client")
        // 公共客户端
        .clientAuthenticationMethod(ClientAuthenticationMethod.NONE)
        // 设备码授权
        .authorizationGrantType(AuthorizationGrantType.DEVICE_CODE)
        .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
        // 自定scope
        .scope("message.read")
        .scope("message.write")
        .build();
    RegisteredClient byClientId = registeredClientRepository.findByClientId(deviceClient.getClientId());
    if (byClientId == null) {
        registeredClientRepository.save(deviceClient);
    }
    return registeredClientRepository;
}

假如数据库已经存在客户端数据或不需求默许设置,则直接注入一个JdbcRegisteredClientRepository即可


/**
 * 装备客户端Repository
 *
 * @param jdbcTemplate    db 数据源信息
 * @return 根据数据库的repository
 */
@Bean
public RegisteredClientRepository registeredClientRepository(JdbcTemplate jdbcTemplate) {
    return new JdbcRegisteredClientRepository(jdbcTemplate);
}

装备授权办理服务

/**
 * 装备根据db的oauth2的授权办理服务
 *
 * @param jdbcTemplate               db数据源信息
 * @param registeredClientRepository 上边注入的客户端repository
 * @return JdbcOAuth2AuthorizationService
 */
@Bean
public OAuth2AuthorizationService authorizationService(JdbcTemplate jdbcTemplate, RegisteredClientRepository registeredClientRepository) {
    // 根据db的oauth2认证服务,还有一个根据内存的服务InMemoryOAuth2AuthorizationService
    return new JdbcOAuth2AuthorizationService(jdbcTemplate, registeredClientRepository);
}

装备授权承认办理服务

/**
 * 装备根据db的授权承认办理服务
 *
 * @param jdbcTemplate               db数据源信息
 * @param registeredClientRepository 客户端repository
 * @return JdbcOAuth2AuthorizationConsentService
 */
@Bean
public OAuth2AuthorizationConsentService authorizationConsentService(JdbcTemplate jdbcTemplate, RegisteredClientRepository registeredClientRepository) {
    // 根据db的授权承认办理服务,还有一个根据内存的服务完成InMemoryOAuth2AuthorizationConsentService
    return new JdbcOAuth2AuthorizationConsentService(jdbcTemplate, registeredClientRepository);
}

装备jwk

/**
 * 装备jwk源,运用非对称加密,揭露用于检索匹配指定挑选器的JWK的办法
 *
 * @return JWKSource
 */
@Bean
public JWKSource<SecurityContext> jwkSource() {
    KeyPair keyPair = generateRsaKey();
    RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
    RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
    RSAKey rsaKey = new RSAKey.Builder(publicKey)
        .privateKey(privateKey)
        .keyID(UUID.randomUUID().toString())
        .build();
    JWKSet jwkSet = new JWKSet(rsaKey);
    return new ImmutableJWKSet<>(jwkSet);
}
/**
 * 生成rsa密钥对,提供给jwk
 *
 * @return 密钥对
 */
private 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;
}

装备jwt解析器

/**
 * 装备jwt解析器
 *
 * @param jwkSource jwk源
 * @return JwtDecoder
 */
@Bean
public JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) {
    return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource);
}

装备认证服务器设置

/**
 * 增加认证服务器装备,设置jwt签发者、默许端点恳求地址等
 *
 * @return AuthorizationServerSettings
 */
@Bean
public AuthorizationServerSettings authorizationServerSettings() {
    return AuthorizationServerSettings.builder().build();
}

装备一个根据内存的默许用户

/**
 * 先暂时装备一个根据内存的用户,结构在用户认证时会默许调用
 * {@link UserDetailsService#loadUserByUsername(String)} 办法根据
 * 账号查询用户信息,一般是重写该办法完成自己的逻辑
 *
 * @param passwordEncoder 暗码解析器
 * @return UserDetailsService
 */
@Bean
public UserDetailsService users(PasswordEncoder passwordEncoder) {
    UserDetails user = User.withUsername("admin")
        .password(passwordEncoder.encode("123456"))
        .roles("admin", "normal")
        .authorities("app", "web")
        .build();
    return new InMemoryUserDetailsManager(user);
}

完好的AuthorizationConfig.java如下

package com.example.config;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
import com.nimbusds.jose.jwk.source.JWKSource;
import com.nimbusds.jose.proc.SecurityContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.MediaType;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.security.access.annotation.Secured;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
import org.springframework.security.oauth2.core.oidc.OidcScopes;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.server.authorization.JdbcOAuth2AuthorizationConsentService;
import org.springframework.security.oauth2.server.authorization.JdbcOAuth2AuthorizationService;
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsentService;
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
import org.springframework.security.oauth2.server.authorization.client.JdbcRegisteredClientRepository;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
import org.springframework.security.oauth2.server.authorization.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration;
import org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers.OAuth2AuthorizationServerConfigurer;
import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings;
import org.springframework.security.oauth2.server.authorization.settings.ClientSettings;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
import org.springframework.security.web.util.matcher.MediaTypeRequestMatcher;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import java.util.UUID;
/**
 * 认证装备
 * {@link EnableMethodSecurity} 敞开大局办法认证,启用JSR250注解支撑,启用注解 {@link Secured} 支撑,
 * 在Spring Security 6.0版别中将@Configuration注解从@EnableWebSecurity, @EnableMethodSecurity, @EnableGlobalMethodSecurity
 * 和 @EnableGlobalAuthentication 中移除,运用这些注解需手动增加 @Configuration 注解
 * {@link EnableWebSecurity} 注解有两个作用:
 * 1. 加载了WebSecurityConfiguration装备类, 装备安全认证战略。
 * 2. 加载了AuthenticationConfiguration, 装备了认证信息。
 *
 * @author vains
 */
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(jsr250Enabled = true, securedEnabled = true)
public class AuthorizationConfig {
    private static final String CUSTOM_CONSENT_PAGE_URI = "/oauth2/consent";
    /**
     * 装备端点的过滤器链
     *
     * @param http spring security中心装备类
     * @return 过滤器链
     * @throws Exception 抛出
     */
    @Bean
    public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
        // 装备默许的设置,疏忽认证端点的csrf校验
        OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
        http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
                // 敞开OpenID Connect 1.0协议相关端点
                .oidc(Customizer.withDefaults())
                // 设置自定义用户承认授权页
                .authorizationEndpoint(authorizationEndpoint -> authorizationEndpoint.consentPage(CUSTOM_CONSENT_PAGE_URI));
        http
                // 当未登录时拜访认证端点时重定向至login页面
                .exceptionHandling((exceptions) -> exceptions
                        .defaultAuthenticationEntryPointFor(
                                new LoginUrlAuthenticationEntryPoint("/login"),
                                new MediaTypeRequestMatcher(MediaType.TEXT_HTML)
                        )
                )
                // 处理运用access token拜访用户信息端点和客户端注册端点
                .oauth2ResourceServer((resourceServer) -> resourceServer
                        .jwt(Customizer.withDefaults()));
        return http.build();
    }
    /**
     * 装备认证相关的过滤器链
     *
     * @param http spring security中心装备类
     * @return 过滤器链
     * @throws Exception 抛出
     */
    @Bean
    public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests((authorize) -> authorize
                        // 放行静态资源
                        .requestMatchers("/assets/**", "/webjars/**", "/login").permitAll()
                        .anyRequest().authenticated()
                )
                // 指定登录页面
                .formLogin(formLogin ->
                        formLogin.loginPage("/login")
                );
        // 增加BearerTokenAuthenticationFilter,将认证服务作为一个资源服务,解析恳求头中的token
        http.oauth2ResourceServer((resourceServer) -> resourceServer
                .jwt(Customizer.withDefaults()));
        return http.build();
    }
    /**
     * 装备暗码解析器,运用BCrypt的办法对暗码进行加密和验证
     *
     * @return BCryptPasswordEncoder
     */
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
    /**
     * 装备客户端Repository
     *
     * @param jdbcTemplate    db 数据源信息
     * @param passwordEncoder 暗码解析器
     * @return 根据数据库的repository
     */
    @Bean
    public RegisteredClientRepository registeredClientRepository(JdbcTemplate jdbcTemplate, PasswordEncoder passwordEncoder) {
        RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString())
                // 客户端id
                .clientId("messaging-client")
                // 客户端秘钥,运用暗码解析器加密
                .clientSecret(passwordEncoder.encode("123456"))
                // 客户端认证办法,根据恳求头的认证
                .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
                // 装备资源服务器运用该客户端获取授权时支撑的办法
                .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
                .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
                .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
                // 授权码形式回调地址,oauth2.1已改为精准匹配,不能只设置域名,而且屏蔽了localhost,本机运用127.0.0.1拜访
                .redirectUri("http://127.0.0.1:8080/login/oauth2/code/messaging-client-oidc")
                .redirectUri("https://www.baidu.com")
                // 该客户端的授权规模,OPENID与PROFILE是IdToken的scope,获取授权时恳求OPENID的scope时认证服务会回来IdToken
                .scope(OidcScopes.OPENID)
                .scope(OidcScopes.PROFILE)
                // 自定scope
                .scope("message.read")
                .scope("message.write")
                // 客户端设置,设置用户需求承认授权
                .clientSettings(ClientSettings.builder().requireAuthorizationConsent(true).build())
                .build();
        // 根据db存储客户端,还有一个根据内存的完成 InMemoryRegisteredClientRepository
        JdbcRegisteredClientRepository registeredClientRepository = new JdbcRegisteredClientRepository(jdbcTemplate);
        // 初始化客户端
        RegisteredClient repositoryByClientId = registeredClientRepository.findByClientId(registeredClient.getClientId());
        if (repositoryByClientId == null) {
            registeredClientRepository.save(registeredClient);
        }
        // 设备码授权客户端
        RegisteredClient deviceClient = RegisteredClient.withId(UUID.randomUUID().toString())
                .clientId("device-message-client")
                // 公共客户端
                .clientAuthenticationMethod(ClientAuthenticationMethod.NONE)
                // 设备码授权
                .authorizationGrantType(AuthorizationGrantType.DEVICE_CODE)
                .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
                // 自定scope
                .scope("message.read")
                .scope("message.write")
                .build();
        RegisteredClient byClientId = registeredClientRepository.findByClientId(deviceClient.getClientId());
        if (byClientId == null) {
            registeredClientRepository.save(deviceClient);
        }
        return registeredClientRepository;
    }
    /**
     * 装备根据db的oauth2的授权办理服务
     *
     * @param jdbcTemplate               db数据源信息
     * @param registeredClientRepository 上边注入的客户端repository
     * @return JdbcOAuth2AuthorizationService
     */
    @Bean
    public OAuth2AuthorizationService authorizationService(JdbcTemplate jdbcTemplate, RegisteredClientRepository registeredClientRepository) {
        // 根据db的oauth2认证服务,还有一个根据内存的服务完成InMemoryOAuth2AuthorizationService
        return new JdbcOAuth2AuthorizationService(jdbcTemplate, registeredClientRepository);
    }
    /**
     * 装备根据db的授权承认办理服务
     *
     * @param jdbcTemplate               db数据源信息
     * @param registeredClientRepository 客户端repository
     * @return JdbcOAuth2AuthorizationConsentService
     */
    @Bean
    public OAuth2AuthorizationConsentService authorizationConsentService(JdbcTemplate jdbcTemplate, RegisteredClientRepository registeredClientRepository) {
        // 根据db的授权承认办理服务,还有一个根据内存的服务完成InMemoryOAuth2AuthorizationConsentService
        return new JdbcOAuth2AuthorizationConsentService(jdbcTemplate, registeredClientRepository);
    }
    /**
     * 装备jwk源,运用非对称加密,揭露用于检索匹配指定挑选器的JWK的办法
     *
     * @return JWKSource
     */
    @Bean
    public JWKSource<SecurityContext> jwkSource() {
        KeyPair keyPair = generateRsaKey();
        RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
        RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
        RSAKey rsaKey = new RSAKey.Builder(publicKey)
                .privateKey(privateKey)
                .keyID(UUID.randomUUID().toString())
                .build();
        JWKSet jwkSet = new JWKSet(rsaKey);
        return new ImmutableJWKSet<>(jwkSet);
    }
    /**
     * 生成rsa密钥对,提供给jwk
     *
     * @return 密钥对
     */
    private 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;
    }
    /**
     * 装备jwt解析器
     *
     * @param jwkSource jwk源
     * @return JwtDecoder
     */
    @Bean
    public JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) {
        return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource);
    }
    /**
     * 增加认证服务器装备,设置jwt签发者、默许端点恳求地址等
     *
     * @return AuthorizationServerSettings
     */
    @Bean
    public AuthorizationServerSettings authorizationServerSettings() {
        return AuthorizationServerSettings.builder().build();
    }
    /**
     * 先暂时装备一个根据内存的用户,结构在用户认证时会默许调用
     * {@link UserDetailsService#loadUserByUsername(String)} 办法根据
     * 账号查询用户信息,一般是重写该办法完成自己的逻辑
     *
     * @param passwordEncoder 暗码解析器
     * @return UserDetailsService
     */
    @Bean
    public UserDetailsService users(PasswordEncoder passwordEncoder) {
        UserDetails user = User.withUsername("admin")
                .password(passwordEncoder.encode("123456"))
                .roles("admin", "normal", "unAuthentication")
                .authorities("app", "web", "/test2", "/test3")
                .build();
        return new InMemoryUserDetailsManager(user);
    }
}

注意,装备类中说到的根据内存存储的类制止用于出产环境

3. 增加AuthorizationController,将恳求转发至自定义的登录页面和用户承认授权页面

以下代码摘抄自官方示例

运用thymeleaf结构烘托页面

package com.example.controller;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
import org.springframework.security.oauth2.core.oidc.OidcScopes;
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsent;
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsentService;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import java.security.Principal;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
/**
 * 认证服务器相关自定接口
 *
 * @author vains
 */
@Controller
@RequiredArgsConstructor
public class AuthorizationController {
    private final RegisteredClientRepository registeredClientRepository;
    private final OAuth2AuthorizationConsentService authorizationConsentService;
    @GetMapping("/login")
    public String login() {
        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,
                          @RequestParam(name = OAuth2ParameterNames.USER_CODE, required = false) String userCode) {
        // Remove scopes that were already approved
        Set<String> scopesToApprove = new HashSet<>();
        Set<String> previouslyApprovedScopes = new HashSet<>();
        RegisteredClient registeredClient = this.registeredClientRepository.findByClientId(clientId);
        if (registeredClient == null) {
            throw new RuntimeException("客户端不存在");
        }
        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 (OidcScopes.OPENID.equals(requestedScope)) {
                continue;
            }
            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());
        model.addAttribute("userCode", userCode);
        if (StringUtils.hasText(userCode)) {
            model.addAttribute("requestURI", "/oauth2/device_verification");
        } else {
            model.addAttribute("requestURI", "/oauth2/authorize");
        }
        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;
    }
    @Data
    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(
                    OidcScopes.PROFILE,
                    "This application will be able to read your profile information."
            );
            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);
        }
    }
}

4. 在application.yml中装备db数据源

spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/authorization-example?serverTimezone=UTC&userUnicode=true&characterEncoding=utf-8
    username: root
    password: root

5. 编写登录页面和用户授权承认页面

以下代码摘抄自官方示例

登录页面 login.html

<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:th="https://www.thymeleaf.org">
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Spring Authorization Server sample</title>
    <link rel="stylesheet" href="/webjars/bootstrap/css/bootstrap.css" th:href="@{/webjars/bootstrap/css/bootstrap.css}" />
    <link rel="stylesheet" href="/assets/css/signin.css" th:href="@{/assets/css/signin.css}" />
</head>
<body>
<div class="container">
    <form class="form-signin w-100 m-auto" method="post" th:action="@{/login}">
        <div th:if="${param.error}" class="alert alert-danger" role="alert">
            Invalid username or password.
        </div>
        <div th:if="${param.logout}" class="alert alert-success" role="alert">
            You have been logged out.
        </div>
        <h1 class="h3 mb-3 fw-normal">Please sign in</h1>
        <div class="form-floating">
            <input type="text" id="username" name="username" class="form-control" required autofocus>
            <label for="username">Username</label>
        </div>
        <div class="form-floating">
            <input type="password" id="password" name="password" class="form-control" required>
            <label for="password">Password</label>
        </div>
        <div>
            <button class="w-100 btn btn-lg btn-primary btn-block" type="submit">Sign in</button>
        </div>
    </form>
</div>
</body>
</html>

登录页面css, signin.css

html,
body {
    height: 100%;
}
body {
    display: flex;
    align-items: start;
    padding-top: 100px;
    background-color: #f5f5f5;
}
.form-signin {
    max-width: 330px;
    padding: 15px;
}
.form-signin .form-floating:focus-within {
    z-index: 2;
}
.form-signin input[type="username"] {
    margin-bottom: -1px;
    border-bottom-right-radius: 0;
    border-bottom-left-radius: 0;
}
.form-signin input[type="password"] {
    margin-bottom: 10px;
    border-top-left-radius: 0;
    border-top-right-radius: 0;
}

用户授权承认页面consent.html

<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:th="https://www.thymeleaf.org">
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Custom consent page - Consent required</title>
    <link rel="stylesheet" href="/webjars/bootstrap/css/bootstrap.css" th:href="@{/webjars/bootstrap/css/bootstrap.css}" />
    <script>
        function cancelConsent() {
            document.consent_form.reset();
            document.consent_form.submit();
        }
    </script>
</head>
<body>
<div class="container">
    <div class="row py-5">
        <h1 class="text-center text-primary">App permissions</h1>
    </div>
    <div class="row">
        <div class="col text-center">
            <p>
                The application
                <span class="fw-bold text-primary" th:text="${clientId}"></span>
                wants to access your account
                <span class="fw-bold" th:text="${principalName}"></span>
            </p>
        </div>
    </div>
    <div th:if="${userCode}" class="row">
        <div class="col text-center">
            <p class="alert alert-warning">
                You have provided the code
                <span class="fw-bold" th:text="${userCode}"></span>.
                Verify that this code matches what is shown on your device.
            </p>
        </div>
    </div>
    <div class="row pb-3">
        <div class="col text-center">
            <p>
                The following permissions are requested by the above app.<br/>
                Please review these and consent if you approve.
            </p>
        </div>
    </div>
    <div class="row">
        <div class="col text-center">
            <form name="consent_form" method="post" th:action="${requestURI}">
                <input type="hidden" name="client_id" th:value="${clientId}">
                <input type="hidden" name="state" th:value="${state}">
                <input th:if="${userCode}" type="hidden" name="user_code" th:value="${userCode}">
                <div th:each="scope: ${scopes}" class="form-check py-1">
                    <input class="form-check-input"
                           style="float: none"
                           type="checkbox"
                           name="scope"
                           th:value="${scope.scope}"
                           th:id="${scope.scope}">
                    <label class="form-check-label fw-bold px-2" 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)}">
                    You have already granted the following permissions to the above app:
                </p>
                <div th:each="scope: ${previouslyApprovedScopes}" class="form-check py-1">
                    <input class="form-check-input"
                           style="float: none"
                           type="checkbox"
                           th:id="${scope.scope}"
                           disabled
                           checked>
                    <label class="form-check-label fw-bold px-2" th:for="${scope.scope}" th:text="${scope.scope}"></label>
                    <p class="text-primary" th:text="${scope.description}"></p>
                </div>
                <div class="pt-3">
                    <button class="btn btn-primary btn-lg" type="submit" id="submit-consent">
                        Submit Consent
                    </button>
                </div>
                <div class="pt-3">
                    <button class="btn btn-link regular" type="button" id="cancel-consent" onclick="cancelConsent();">
                        Cancel
                    </button>
                </div>
            </form>
        </div>
    </div>
    <div class="row pt-4">
        <div class="col text-center">
            <p>
                <small>
                    Your consent to provide access is required.<br/>
                    If you do not approve, click Cancel, in which case no information will be shared with the app.
                </small>
            </p>
        </div>
    </div>
</div>
</body>
</html>

至此,一个简略的认证服务就建立成功了。

本来不想设置自定义页面的,但是不知道是自己的网络问题,仍是默许的页面里的css相关cdn无法拜访,页面加载巨慢还丑,只能从官方示例中拿一下登录页面和用户授权承认页面,css改为从项目的webjars中引进

最后放一下项目结构图

Spring Authorization Server入门 (二) springboot整合Spring Authorization Server

6. 简略测验

1. 拼接url,拜访授权接口

http://127.0.0.1:8080/oauth2/authorize?client_id=messaging-client&response_type=code&scope=message.read&redirect_uri=https%3A%2F%2Fwww.baidu.com

2. 授权接口检测到未登录,重定向至登录页面

Spring Authorization Server入门 (二) springboot整合Spring Authorization Server

3. 输入AuthorizationConfig中装备的账号暗码

账号:admin, 暗码:123456

4. 登录成功后跳转至授权承认页面

登录成功跳转至第1步的授权接口,授权接口检测到用户未承认授权,跳转至授权承认页面

Spring Authorization Server入门 (二) springboot整合Spring Authorization Server

挑选对应的scope并提交承认权限

5. 提交后重定向至第1步的授权接口

授权接口生成code并重定向至第1步恳求授权接口时带着的redirectUri地址,重定向时带着上参数code和state,我这儿省掉掉了state参数,重定向之后只会带着code参数;state用来避免CSRF进犯,正式恳求需生成并带着state参数。

6. 用户承认授权后带着code跳转至redirectUri

一般来说装备的回调地址都是客户端的接口,接口在接收到回调时根据code去换取accessToken,接下来我会用postman模拟客户端建议一个http恳求去换取token 不知道为什么在手机浏览器上看回调至百度的图片在渠道显现违规,这儿放一张另一个回调地址的替代图片

Spring Authorization Server入门 (二) springboot整合Spring Authorization Server

7. 根据code换取AccessToken

恳求/oauth2/token接口

1. 设置Basic Auth

之前客户端设置的认证办法是CLIENT_SECRET_BASIC,所以需将客户端信息增加至恳求头

Spring Authorization Server入门 (二) springboot整合Spring Authorization Server

2. 增加表单数据,建议POST恳求

下列表单数据可增加至form-data也可增加至url params

Spring Authorization Server入门 (二) springboot整合Spring Authorization Server
参数中的code就是第6步回调时带着的code

注意:增加url params时redirect_uri参数要通过encodeURIComponent函数对回调地址进行编码

Spring Authorization Server入门 (二) springboot整合Spring Authorization Server

8. 参数解释

1. client_id: 客户端的id
2. client_secret: 客户端秘钥
3. redirect_uri:申请授权成功后的回调地址
4. response_type:授权码形式固定参数code
5. code_verifier:一段随机字符串
6. code_challenge:根据指定的加密办法将code_verifier加密后得到的字符串
7. code_challenge_method:加密办法
8. scope:客户端申请的授权规模
9. state:跟随authCode原样回来,避免CSRF进犯
10. grant_type:指定获取token 的办法:
	1. refresh_token:刷新token
    2. authorization_code:根据授权码形式的授权码获取
    3. client_credentials:客户端形式获取

总结

本篇文章从0到1建立了一个简略认证服务,解释了认证服务的各项装备用意,怎么设置自己的登录页和授权承认页,怎么让认证服务解析恳求时带着的token,文章过长不免有遗漏的当地,假如文章中有遗漏或错误的当地请各位读者在评论区指出。