掘金 后端 ( ) • 2024-03-31 14:17

Single Sign-On Server

上一篇文章中,我写了一个简单的CAS实现,用户名和密码是配置在内存中的,在生产环境中,我们一般会使用数据库保存用户信息,企业应用中经常使用LDAP数据库保存员工信息,面向C端用户的则一般使用MySQL或者MongoDB。本篇文章将讲述如何集成MySQL数据库。

1.导入依赖

这里使用mybatis-plus进行crud操作,并且使用spring-secury的crypto模块来做密码加密。

        <!--mybatis-plus start-->
        <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>
        <!--mybatis-plus end-->

		<!-- 密码加密工具 -->
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-crypto</artifactId>
        </dependency>

2.创建数据库

首先需初始化数据库表:

create database cas_server;
use cas_server;


drop table if exists cas_account;

-- 账号表
create table cas_account
(
    id           int          not null primary key auto_increment,
    account_name varchar(20)  not null default '' comment '用户名',
    display_name varchar(20)  not null default '' comment '显示名',
    account_id   varchar(20)  not null default '' comment '账号ID',
    password     varchar(255) not null default '' comment '密码',
    mobile       varchar(100) not null default '' comment '手机号码',
    mail         varchar(100) not null default '' comment '邮箱',
    avatar       varchar(255) not null default '' comment '用户头像',
    expired      int          not null default 0 comment '是否过期',
    disabled     int          not null default 0 comment '是否禁用',
    create_time  int          not null default 0 comment '创建时间',
    update_time  int          not null default 0 comment '更新时间'
);

3.配置数据源

在application.properties文件中添加数据源配置项:

spring.datasource.url=jdbc:mysql://127.0.0.1:3306/cas_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

4.代码生成

使用mybatis-plus的代码生成器生成dao相关的类:

/**
 * @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/cas_server?useSSL=false";
        String user = "root";
        String pass = "root";
        String module = "cas-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.cas.dao"); // 设置父包名
//                            .moduleName("auth-server") // 设置父包模块名
//                            .pathInfo(Collections.singletonMap(OutputFile.xml, System.getProperty("user.dir") + "/" + module + "/src/main/java")); // 设置mapperXml生成路径
                })
                .strategyConfig(builder -> {
                    builder.addInclude("cas_account"); // 设置需要生成的表名
                })
                .templateEngine(new FreemarkerTemplateEngine())
                // 使用Freemarker引擎模板,默认的是Velocity引擎模板
                .execute();
    }
}

5.认证实现

首先定义一个认证Provider接口:

import org.springframework.core.Ordered;

/**
 * @author hundanli
 * @version 1.0.0
 * @date 2024/3/18 14:52
 */
public interface AuthenticationProvider extends Ordered {


    /**
     * 用户认证
     *
     * @param authentication 用户凭证
     * @return 认证结果
     */
    Authentication authenticate(Authentication authentication);


    /**
     * 是否支持认证
     *
     * @param authenticationType 认证类型
     * @return 是否支持
     */
    boolean supports(AuthenticationType authenticationType);

}

再写一个AuthenticationManager类,将所有AuthenticationProvider接口的所有实现都注入进来,注意尝试认证:

/**
 * @author hundanli
 * @version 1.0.0
 * @date 2024/3/19 23:11
 */
@Slf4j
@Service
public class AuthenticationManager {

    @Autowired
    private List<AuthenticationProvider> authenticationProviders;


    public Authentication authenticate(Authentication authentication) {
        for (AuthenticationProvider provider : authenticationProviders) {
            if (provider.supports(authentication.getAuthenticationType())) {
                long currentTime = System.currentTimeMillis();
                provider.authenticate(authentication);
                if (authentication.isAuthenticated()) {
                    log.info("authenticate success with handler {}, cost time: {}ms", provider.getClass().getSimpleName(), (System.currentTimeMillis() - currentTime));
                    return authentication;
                }
            }
        }
        authentication.setAuthenticated(false);
        return authentication;
    }
}

这样后续需要增加认证数据源时,只要实现AuthenticationProvider接口,并向Spring容器中注入Bean即可。

现在来实现一个MySQL的认证数据源:


/**
 * @author hundanli
 * @version 1.0.0
 * @date 2024/3/27 13:27
 */
@Slf4j
@Service
public class MysqlAuthenticationProvider implements AuthenticationProvider {

    @Autowired
    private ICasAccountService casAccountService;

    @Autowired
    private PasswordEncoder passwordEncoder;


    @Override
    public Authentication authenticate(Authentication authentication) {
        String accountName = authentication.getPrincipal();
        String password = authentication.getCredential();
        LambdaQueryWrapper<CasAccount> queryWrapper = Wrappers.lambdaQuery();
        queryWrapper.eq(CasAccount::getAccountName, accountName);
        queryWrapper.last("limit 1");
        CasAccount casAccount = casAccountService.getOne(queryWrapper);
        if (casAccount == null) {
            authentication.setAuthenticated(false);
            return authentication;
        }
        if (passwordEncoder.matches(password, casAccount.getPassword())) {
            authentication.setAuthenticated(true);
            authentication.setAttributes(collectAttributes(casAccount));
        } else {
            authentication.setAuthenticated(false);
            authentication.setErrorCode(ErrorCodeConstant.INVALID_CREDENTIAL);
        }
        return authentication;
    }

    @Override
    public boolean supports(AuthenticationType authenticationType) {
        return AuthenticationType.PASSWORD.equals(authenticationType);
    }

    @Override
    public int getOrder() {
        return Ordered.HIGHEST_PRECEDENCE;
    }

    private Map<String, Object> collectAttributes(CasAccount casAccount) {
        Map<String, Object> attributes = new HashMap<>(8);
        attributes.put(AttributeNames.SAM_ACCOUNT_NAME, casAccount.getAccountName());
        attributes.put(AttributeNames.NAME, casAccount.getDisplayName());
        attributes.put(AttributeNames.EMPLOYEE_ID, casAccount.getAccountId());
        attributes.put(AttributeNames.MAIL, casAccount.getMail());
        attributes.put(AttributeNames.MOBILE, casAccount.getMobile());
        return attributes;
    }
}

最后修改/login端点的代码,使用AuthenticationManager进行认证即可:

    @CrossOrigin(origins = "*", allowedHeaders = "*", exposedHeaders = "*", methods = {RequestMethod.POST, RequestMethod.HEAD})
    @PostMapping("login")
    public String formLogin(@RequestParam("username") String username,
                            @RequestParam("password") String password,
                            @RequestParam(value = "service", required = false) String service,
                            @RequestParam(value = "redirect", defaultValue = "true") Boolean redirect,
                            HttpServletResponse response,
                            HttpServletRequest request) throws IOException {
        if (authenticationManager.hasLogin(request)) {
            return "You have login successfully!";
        }
        Authentication authentication = new UserPasswordAuthentication(username, password);
        if (authenticationManager.authenticate(authentication).isAuthenticated()) {
            return grantTicketAndRedirect(service, redirect, response, request, authentication);
        } else {
            return "Invalid credentials!";
        }
    }

完整代码:https://github.com/hundanLi/hauth-server/tree/main/cas-server