likes
comments
collection
share

Spring Authorization Server 集成MySQL

作者站长头像
站长
· 阅读数 11

Spring Authorization Server

上一篇文章中,我们简单快速上手了Spring Authorization Server,但是所有数据都是存储在内存中的,无法应用与生产环境。

这一次我们引入MySQL和Mybatis-Plus,将oauth-client和user数据持久化到数据库中,建立一个基本上生产可用的认证和授权服务器。

1.依赖管理

引入数据库相关的依赖后,完整的依赖如下:

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-configuration-processor</artifactId>
            <optional>true</optional>
        </dependency>

        <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-test</artifactId>
            <scope>test</scope>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jdbc</artifactId>
        </dependency>
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>3.5.5</version>
            <exclusions>
                <exclusion>
                    <groupId>org.mybatis</groupId>
                    <artifactId>mybatis-spring</artifactId>
                </exclusion>
            </exclusions>
        </dependency>
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-generator</artifactId>
            <version>3.5.5</version>
        </dependency>
        <!--低版本的mybatis-spring不兼容-->
        <dependency>
            <groupId>org.mybatis</groupId>
            <artifactId>mybatis-spring</artifactId>
            <version>3.0.3</version>
        </dependency>
        <dependency>
            <groupId>org.freemarker</groupId>
            <artifactId>freemarker</artifactId>
            <version>2.3.23</version>
        </dependency>
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>5.1.49</version>
        </dependency>

2.数据库设置

OAuth相关:

create database if not exists auth_server;

use auth_server;

DROP TABLE if exists oauth2_authorization_consent;
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)
);


/*
IMPORTANT:
    If using PostgreSQL, update ALL columns defined with 'blob' to 'text',
    as PostgreSQL does not support the 'blob' data type.
*/
DROP TABLE if exists oauth2_authorization;
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 0,
    authorization_code_expires_at datetime DEFAULT 0,
    authorization_code_metadata   blob          DEFAULT NULL,
    access_token_value            blob          DEFAULT NULL,
    access_token_issued_at        datetime DEFAULT 0,
    access_token_expires_at       datetime DEFAULT 0,
    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 0,
    oidc_id_token_expires_at      datetime DEFAULT 0,
    oidc_id_token_metadata        blob          DEFAULT NULL,
    refresh_token_value           blob          DEFAULT NULL,
    refresh_token_issued_at       datetime DEFAULT 0,
    refresh_token_expires_at      datetime DEFAULT 0,
    refresh_token_metadata        blob          DEFAULT NULL,
    user_code_value               blob          DEFAULT NULL,
    user_code_issued_at           datetime DEFAULT 0,
    user_code_expires_at          datetime DEFAULT 0,
    user_code_metadata            blob          DEFAULT NULL,
    device_code_value             blob          DEFAULT NULL,
    device_code_issued_at         datetime DEFAULT 0,
    device_code_expires_at        datetime DEFAULT 0,
    device_code_metadata          blob          DEFAULT NULL,
    PRIMARY KEY (id)
);

DROP TABLE if exists oauth2_registered_client;
CREATE TABLE oauth2_registered_client
(
    id                            varchar(100)  NOT NULL,
    client_id                     varchar(100)  NOT NULL,
    client_id_issued_at           datetime DEFAULT 0,
    client_secret                 varchar(200)  DEFAULT NULL,
    client_secret_expires_at      datetime DEFAULT 0,
    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)
);


用户相关:

use auth_server;

create table auth_user
(
    id       int          not null primary key auto_increment,
    user_id  varchar(50)  not null default '',
    username varchar(50)  not null default '',
    password varchar(100) not null default '',
    mobile   varchar(50)  not null default ''
);

代码生成器:

/**
 * @author hundanli
 * @version 1.0.0
 * @date 2024/3/8 11:20
 */
public class MybatisPlusGenerator {

    public static void main(String[] args) {
        String url = "jdbc:mysql://127.0.0.1:3306/auth_server?useSSL=false";
        String user = "root";
        String pass = "root";
        String module = "auth-server";
        FastAutoGenerator.create(url, user, pass)
                .globalConfig(builder -> {
                    builder.author("hundanli")// 设置作者
                            .outputDir(System.getProperty("user.dir") + "/" + module + "/src/main/java"); // 指定输出目录
                })
                .dataSourceConfig(builder -> builder.typeConvertHandler((globalConfig, typeRegistry, metaInfo) -> {
                    int typeCode = metaInfo.getJdbcType().TYPE_CODE;
                    if (typeCode == Types.SMALLINT) {
                        // 自定义类型转换
                        return DbColumnType.INTEGER;
                    }
                    return typeRegistry.getColumnType(metaInfo);

                }))
                .packageConfig(builder -> {
                    builder.parent("com.hauth.auth.dao"); // 设置父包名
//                            .moduleName("auth-server") // 设置父包模块名
//                            .pathInfo(Collections.singletonMap(OutputFile.xml, System.getProperty("user.dir") + "/" + module + "/src/main/java")); // 设置mapperXml生成路径
                })
                .strategyConfig(builder -> {
                    builder.addInclude("auth_user"); // 设置需要生成的表名
                })
                .templateEngine(new FreemarkerTemplateEngine())
                // 使用Freemarker引擎模板,默认的是Velocity引擎模板
                .execute();
    }
}

执行即可生成操作auth_user表的dao代码。

最后,还需要在application.properties文件加上数据库配置信息:

spring.datasource.url=jdbc:mysql://127.0.0.1:3306/auth_server?useSSL=false&serverTimezone=Asia/Shanghai&autoReconnect=true
spring.datasource.username=root
spring.datasource.password=root
spring.datasource.driver-class-name=com.mysql.jdbc.Driver

auth.jdbc=true

3.授权配置

与上一篇类似,需要注入多个bean到Spring容器中,这里导入了jdbc相关的实现:


/**
 * @author hundanli
 * @version 1.0.0
 * @date 2024/3/7 11:45
 */
@Configuration
@EnableWebSecurity
@ConditionalOnProperty(name = "auth.jdbc", havingValue = "true", matchIfMissing = false)
public class AuthorizationServerConfig {

    /**
     * 授权服务器 认证过滤器
     */
    @Bean
    @Order(1)
    public SecurityFilterChain authorizationServerSecurityFilterChain(
            HttpSecurity http) throws Exception {

        // 配置默认的设置,忽略授权请求端点的csrf校验
        OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);

        // 开启OpenID Connect 1.0协议相关端点
        http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
                .oidc(Customizer.withDefaults());

        // 当未登录时访问认证端点时重定向至login页面
        http.exceptionHandling((exceptions) -> exceptions
                .defaultAuthenticationEntryPointFor(
                        new LoginUrlAuthenticationEntryPoint("/login"),
                        new MediaTypeRequestMatcher(MediaType.TEXT_HTML)
                ))
                // 处理使用access token访问用户信息端点和客户端注册端点
                .oauth2ResourceServer((resourceServer) -> resourceServer
                        .jwt(Customizer.withDefaults()));

        return http.build();
    }

    // 注册用户使用,暂时忽略认证
    @Bean
    @Order(2)
    public SecurityFilterChain customSecurityFilterChain(HttpSecurity http) throws Exception {
        // whitelist
        http.authorizeHttpRequests(authorize -> {
            authorize.requestMatchers("/authUser/**").anonymous();
        }).csrf(csrf -> {
            csrf.ignoringRequestMatchers("/authUser/**");
        }).formLogin(Customizer.withDefaults());

        // disable cors
        http.cors(AbstractHttpConfigurer::disable);
        return http.build();
    }

    // spring security 认证过滤器,该bean必须放到最低优先级
    @Bean
    @Order(Ordered.LOWEST_PRECEDENCE - 1)
    public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http)
            throws Exception {
        http
                .authorizeHttpRequests((authorize) ->
                        authorize.anyRequest().authenticated()
                )
                // Form login handles the redirect to the login page from the
                // authorization server filter chain
                .formLogin(Customizer.withDefaults());
        return http.build();
    }

    @Bean
    public JWKSource<SecurityContext> jwkSource() {
        KeyPair keyPair = generateRsaKey();
        RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
        RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
        // @formatter:off
        RSAKey rsaKey = new RSAKey.Builder(publicKey)
                .privateKey(privateKey)
                .keyID(UUID.randomUUID().toString())
                .build();
        // @formatter:on
        JWKSet jwkSet = new JWKSet(rsaKey);
        return new ImmutableJWKSet<>(jwkSet);
    }

    // token加解密RSA密钥
    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;
    }

    // token解密器
    @Bean
    public JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) {
        return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource);
    }

    // authorization server 设置
    @Bean
    public AuthorizationServerSettings authorizationServerSettings() {
        return AuthorizationServerSettings.builder().build();
    }

    // 密码加密器
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

	// oauth client存储服务
    @Bean
    public RegisteredClientRepository registeredClientRepository(JdbcTemplate jdbcTemplate, PasswordEncoder passwordEncoder) {
        RegisteredClientRepository registeredClientRepository = new JdbcRegisteredClientRepository(jdbcTemplate);
        initMallHelloClient(registeredClientRepository, passwordEncoder);
        return registeredClientRepository;
    }

    // oauth授权服务
    @Bean
    public OAuth2AuthorizationService authorizationService(JdbcTemplate jdbcTemplate,
                                                           RegisteredClientRepository registeredClientRepository) {
        return new JdbcOAuth2AuthorizationService(jdbcTemplate, registeredClientRepository);
    }

    // 用户同意服务
    @Bean
    public OAuth2AuthorizationConsentService authorizationConsentService(JdbcTemplate jdbcTemplate,
                                                                         RegisteredClientRepository registeredClientRepository) {
        // Will be used by the ConsentController
        return new JdbcOAuth2AuthorizationConsentService(jdbcTemplate, registeredClientRepository);
    }

    // access_token生成器
    @Bean
    OAuth2TokenGenerator<?> tokenGenerator(JWKSource<SecurityContext> jwkSource) {
        JwtGenerator jwtGenerator = new JwtGenerator(new NimbusJwtEncoder(jwkSource));
        OAuth2AccessTokenGenerator accessTokenGenerator = new OAuth2AccessTokenGenerator();
        OAuth2RefreshTokenGenerator refreshTokenGenerator = new OAuth2RefreshTokenGenerator();
        return new DelegatingOAuth2TokenGenerator(
                jwtGenerator, accessTokenGenerator, refreshTokenGenerator);
    }

	// 注册测试client
    private void initMallHelloClient(RegisteredClientRepository registeredClientRepository, PasswordEncoder passwordEncoder) {

        String clientId = "hello";
        String clientSecret = "123456";
        String clientName = "Hello客户端";

        String encodeSecret = passwordEncoder.encode(clientSecret);

        RegisteredClient client = registeredClientRepository.findByClientId(clientId);
        String id = client != null ? client.getId() : UUID.randomUUID().toString();

        RegisteredClient registeredClient = RegisteredClient.withId(id)
                .clientId(clientId)
                .clientSecret(encodeSecret)
                .clientName(clientName)
            	// 需要设置clientSecret过期时间,否则默认非常短回到只token接口401
                .clientSecretExpiresAt(Instant.now().plus(Duration.ofDays(36500)))
                .clientAuthenticationMethod(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:8080/authorized")
                .postLogoutRedirectUri("http://127.0.0.1:8080/logged-out")
                .scope(OidcScopes.OPENID)
                .scope(OidcScopes.PROFILE)            .tokenSettings(TokenSettings.builder().refreshTokenTimeToLive(Duration.ofDays(30)).accessTokenTimeToLive(Duration.ofDays(1)).build())
                .clientSettings(ClientSettings.builder().requireAuthorizationConsent(true).build())
                .build();
        registeredClientRepository.save(registeredClient);
    }


4.认证实现

这一步我们需要按照Spring Security的规范,注入一个UserDetailsService类型的Bean用于用户认证。


/**
 * @author hundanli
 * @version 1.0.0
 * @date 2024/3/8 10:25
 */
@Service
@ConditionalOnProperty(value = "auth.jdbc", havingValue = "true")
public class JdbcUserAuthenticationService implements UserDetailsService {


    @Autowired
    private IAuthUserService authUserService;

    @Autowired(required = false)
    private PasswordEncoder passwordEncoder;


    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        LambdaQueryWrapper<AuthUser> queryWrapper = Wrappers.lambdaQuery();
        queryWrapper.eq(AuthUser::getUsername, username);
        queryWrapper.last("limit 1");
        AuthUser authUser = authUserService.getOne(queryWrapper);
        if (authUser == null) {
            throw new UsernameNotFoundException("use not found: " + username);
        }
        return User.withUsername(username)
                .password(authUser.getPassword())
                .accountExpired(false)
                .accountLocked(false)
                .build();
    }
}

这里将查询数据库获取用户,返回给Spring Security做认证。

5.用户注册

因为我们使用了密码加密存储,因此我写了一个用户注册的简单接口便于调试,也就是授权配置中忽略认证的接口:

/**
 * @author hundanli
 * @version 1.0.0
 * @date 2024/3/8 15:20
 */
@RestController
@RequestMapping("authUser")
public class AuthUserController {

    @Autowired
    private IAuthUserService authUserService;

    @Autowired(required = false)
    private PasswordEncoder passwordEncoder;

    @PostMapping("register")
    public String register(@RequestParam("username") String username, @RequestParam("password") String password) {
        String encodedPassword = passwordEncoder.encode(password);
        AuthUser authUser = new AuthUser();
        authUser.setUserId("" + System.currentTimeMillis()/1000);
        authUser.setUsername(username);
        authUser.setPassword(encodedPassword);
        authUser.setMobile("");
        authUserService.save(authUser);
        return "ok";
    }
}

6.授权测试

同样启动AuthServer和ClientServer两个服务,测试前先注册一个用户:

curl -XPOST 'http://127.0.0.1:8000/authUser/register?username=hello&password=12
3456' -v

然后执行上一篇同样的流程测试授权流程:

1.在浏览器中访问:http://127.0.0.1:8000/oauth2/authorize?response_type=code&client_id=hello&redirect_uri=http://127.0.0.1:8080/authorized&scope=openid

2.此时会跳转到:http://127.0.0.1:8000/login 页面,输入hello/123456进行登录,AuthServer会返回302响应和code

3.浏览器将会自动进行302请求:http://127.0.0.1:8080/authorized?code=xxx

4.然后OAuth Client将会获取这个code调用AuthServer的/oauth2/token接口获取access_token。

5.最后再使用access_token调用AuthServer的/userinfo接口获取用户信息。

至此顺利完成数据库集成,完结撒花。