掘金 后端 ( ) • 2024-06-30 10:21

@[TOC]

写在前面的话

因为SpringBoot3.x是目前最新的版本,整合spring-security-oauth2-authorization-server的资料很少,所以产生了这篇文章,主要为想尝试SpringBoot高版本,想整合最新的spring-security-oauth2-authorization-server的初学者,旨在为大家提供一个简单上手的参考,如果哪里写得不对或可以优化的还请大家踊跃评论指正。

前面一篇文章《spring-security-oauth2-authorization-server(二)Token生成分析之JWT Token和Opaque Token》主要介绍了主要结合官网的描述简单分析一下Token的几种生成策略,以及如何自定义Token生成器来生成我们在OAuth2.0中常见的形如这样:Bearer 237d224d-1bdc-4d48-855a-f6abb37e378f的OpaqueToken。本篇文章主要想实现基于JDBC获取用户并且实现一个自定义的登录页,该篇是一个过渡比较简单,里面会提出几个问题,这几个问题很关键,为下一篇埋个伏笔。 整个项目的配置还是复用的上一篇。

一、实现基于JDBC查询用户信息

1. 准备工作

1.1 创建用户实体

@Data
@EqualsAndHashCode(callSuper = true)
@NoArgsConstructor
@AllArgsConstructor
@Accessors(chain = true)
@TableName(value = "acc_account")
public class AccAccountDO extends BaseDO<AccAccountDO> implements UserDetails {

    @Serial
    private static final long serialVersionUID = -6155520593458223103L;

    /**
     * 账号
     */
    @TableField(value = "account_no")
    private String accountNo;

    /**
     * 账户名称
     */
    @TableField(value = "account_name")
    private String accountName;

    /**
     * 账户密码
     */
    @TableField(value = "account_password")
    private String accountPassword;

    /**
     * 是否启用
     */
    @TableField(value = "enabled")
    private Boolean enabled;

    /**
     * 权限列表
     */
    @TableField(value = "authorities", exist = false)
    Set<GrantedAuthority> authorities;

    @Override
    public String getPassword() {
        return accountPassword;
    }

    @Override
    public String getUsername() {
        return accountName;
    }

    @Override
    public Set<GrantedAuthority> getAuthorities() {
        return this.authorities;
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return this.enabled;
    }
}
@EqualsAndHashCode(callSuper = true)
@Data
public class BaseDO<T extends Model<T>> extends Model<T> implements Serializable {

    @Serial
    private static final long serialVersionUID = -2548248444612845271L;

    /**
     * 主键id
     */
    @TableId(value = "id", type = IdType.AUTO)
    private Long id;

    /**
     * 创建人id
     */
    @TableField(value = "create_by", fill = FieldFill.INSERT)
    private Long createBy;

    /**
     * 创建人
     */
    @TableField(value = "create_by_name", fill = FieldFill.INSERT)
    private String createByName;

    /**
     * 创建时间
     */
    @TableField(value = "gmt_create", fill = FieldFill.INSERT)
    private LocalDateTime gmtCreate;

    /**
     * 修改人id
     */
    @TableField(value = "modify_by", fill = FieldFill.INSERT_UPDATE)
    private Long modifyBy;

    /**
     * 修改人
     */
    @TableField(value = "modify_by_name", fill = FieldFill.INSERT_UPDATE)
    private String modifyByName;

    /**
     * 修改时间
     */
    @TableField(value = "gmt_modify", fill = FieldFill.INSERT_UPDATE)
    private LocalDateTime gmtModify;

    /**
     * 是否已删除 1-已删除 0-未删除
     */
    @TableField(value = "is_deleted", fill = FieldFill.INSERT)
    @TableLogic
    private Integer isDeleted;

    /**
     * 版本号
     */
    @TableField(value = "version", fill = FieldFill.INSERT)
    private Long version;
}

1.2 创建DAO层

public interface AccAccountManager extends IService<AccAccountDO> {
}

@Service
public class AccAccountManagerImpl extends ServiceImpl<AccAccountMapper, AccAccountDO> implements AccAccountManager {
}

1.3 service继承UserDetailsService重写方法

public interface AccAccountService extends UserDetailsService {
}

@Service
public class AccAccountServiceImpl implements AccAccountService {

    private final AccAccountManager accAccountManager;

    private final PasswordEncoder pw;

    @Autowired
    public AccAccountServiceImpl(AccAccountManager accAccountManager, PasswordEncoder pw) {
        this.accAccountManager = accAccountManager;
        this.pw = pw;
    }

    @Override
    public UserDetails loadUserByUsername(String accountNo) throws UsernameNotFoundException {
        Assert.isTrue(StrUtil.isNotBlank(accountNo), "账号不可为空!");
        AccAccountDO accountDO = accAccountManager.lambdaQuery().eq(AccAccountDO::getAccountNo, accountNo).one();
        Assert.isTrue(ObjectUtil.isNotEmpty(accountDO), () -> new RuntimeException("用户未找到"));
        String password = pw.encode(accountDO.getAccountPassword());
        accountDO.setAccountPassword(password);
        Set<GrantedAuthority> authorities = new HashSet<>();
        authorities.add(new SimpleGrantedAuthority("user"));
        accountDO.setAuthorities(authorities);
        return accountDO;
    }
}

1.4 修改认证服务器配置

    @Bean
    public OAuth2AuthorizationService authorizationService(JdbcTemplate jdbcTemplate, RegisteredClientRepository registeredClientRepository) {
        return new JdbcOAuth2AuthorizationService(jdbcTemplate, registeredClientRepository);
    }

    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
        return authenticationConfiguration.getAuthenticationManager();
    }

//    @Bean
//    public UserDetailsService userDetailsService() {
//        UserDetails userDetails = User.withUsername("admin")
//                .password(passwordEncoder().encode("123456"))
//                .roles("admin")
//                .build();
//        return new InMemoryUserDetailsManager(userDetails);
//    }

2. 测试效果

访问: http://localhost:8080/oauth2/authorize?response_type=code&scope=user&client_id=oauth2-client&state=ok&redirect_uri=https://www.baidu.com 跳转至登录页,输入之前配好的账号密码 在这里插入图片描述 之后来到授权页,授权后调整百度获取授权码,此时会发现报错了 在这里插入图片描述 查看堆栈发现是如下错误:

java.lang.IllegalArgumentException: The class with com.roshine.authorization.domain.AccAccountDO and name of com.roshine.authorization.domain.AccAccountDO is not in the allowlist. If you believe this class is safe to deserialize, please provide an explicit mapping using Jackson annotations or by providing a Mixin. If the serialization is only done by a trusted source, you can also enable default typing. See https://github.com/spring-projects/spring-security/issues/4370 for details

翻译过来就是:我们自定义的AccAccountDO未列入白名单。如果您认为此类可以安全地反序列化,请使用 Jackson 注释或提供 Mixin 来提供显式映射。如果序列化仅由受信任的来源完成,您还可以启用默认类型。有关详细信息,请参阅#4370;嵌套异常是 java。也提供了一个github issue地址。 开发的原则就是要啥给啥,我们就给他提供一个AccAccountDO的反序列化实现

2.1 提供自定义用户类的反序列化实现

public class AccountDeserializer extends JsonDeserializer<AccAccountDO> {

    private static final TypeReference<Set<GrantedAuthority>> SIMPLE_GRANTED_AUTHORITY_SET = new TypeReference<>() {};

    @Override
    public AccAccountDO deserialize(JsonParser jp, DeserializationContext ctxt) throws IOException {
        ObjectMapper mapper = (ObjectMapper) jp.getCodec();
        JsonNode jsonNode = mapper.readTree(jp);
        Set<GrantedAuthority> authorities = mapper.convertValue(jsonNode.get("authorities"), SIMPLE_GRANTED_AUTHORITY_SET);
        JsonNode passwordNode = readJsonNode(jsonNode, "password");
        String accountNo = readJsonNode(jsonNode, "accountNo").asText();
        String accountName = readJsonNode(jsonNode, "accountName").asText();
        String accountPassword = passwordNode.asText("");
        Long id = readJsonNode(jsonNode, "id").asLong();
        Long createBy = readJsonNode(jsonNode, "createBy").asLong();
        String createByName = readJsonNode(jsonNode, "createByName").asText();
        LocalDateTime gmtCreate = mapper.convertValue(readJsonNode(jsonNode, "gmtCreate"), LocalDateTime.class);
        Long modifyBy = readJsonNode(jsonNode, "modifyBy").asLong();
        String modifyByName = readJsonNode(jsonNode, "modifyByName").asText();
        LocalDateTime gmtModify = mapper.convertValue(readJsonNode(jsonNode, "gmtModify"), LocalDateTime.class);
        int isDeleted = readJsonNode(jsonNode, "isDeleted").asInt();
        Long version = readJsonNode(jsonNode, "version").asLong();
        boolean enabled = readJsonNode(jsonNode, "enabled").asBoolean();
        AccAccountDO result = new AccAccountDO();
        result.setId(id);
        result.setAccountNo(accountNo);
        result.setAccountName(accountName);
        result.setAccountPassword(accountPassword);
        result.setEnabled(enabled);
        result.setAuthorities(authorities);
        result.setCreateBy(createBy);
        result.setCreateByName(createByName);
        result.setGmtCreate(gmtCreate);
        result.setModifyBy(modifyBy);
        result.setModifyByName(modifyByName);
        result.setGmtModify(gmtModify);
        result.setIsDeleted(isDeleted);
        result.setVersion(version);
        if (passwordNode.asText(null) == null) {
            result.eraseCredentials();
        }
        return result;
    }

    private JsonNode readJsonNode(JsonNode jsonNode, String field) {
        return jsonNode.has(field) ? jsonNode.get(field) : MissingNode.getInstance();
    }
}

然后把该实现类加到AccAccountDO上 在这里插入图片描述

2.2 重新测试

获取到了授权码 https://www.baidu.com/?code=376J0JJeTLJmQ_LipDntKJLK4_B66QHof55HPCqYKh1JwH_4kBoTaR5uk2ONxS55IbFPCjB0ElmLQncRN9fFVBDDNFZRcjK0Bnx8GylEEQk0hCztKxg241jxs9mHz0hV&state=ok 然后获取token也是可以的。 在这里插入图片描述 此时,通过JDBC模式读取用户信息已经完成。

二、自定义登录页

1. 首先编写登录页面

在这里插入图片描述

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>欢迎登录 | ADP-自动化运维部署平台</title>
    <link rel="stylesheet" href="https://cdn.staticfile.org/twitter-bootstrap/3.3.7/css/bootstrap.min.css" />
    <link rel="stylesheet" type="text/css" th:href="@{/css/login.css}">
    <link rel="stylesheet" type="text/css" th:href="@{/css/sweet-alert.css}"/>
    <link rel="icon" th:href="@{/images/favicon.ico}">
    <script src="http://libs.baidu.com/jquery/2.0.0/jquery.min.js"></script>
    <script src="https://cdn.bootcdn.net/ajax/libs/twitter-bootstrap/4.5.0/js/bootstrap.min.js"></script>
    <script src="https://cdn.bootcdn.net/ajax/libs/sweetalert/2.1.2/sweetalert.min.js"></script>
    <script type="text/javascript" src="//cdn.bootcss.com/jquery-cookie/1.4.1/jquery.cookie.min.js"></script>
</head>
<body>
<div class="bg bg-blur"></div>
<div class="showBox">
    <h1 style="font-size: 80px">MatrixSphere</h1>
    <h3 style="font-size: 50px">欢迎登录MatrixSphere运维管理平台</h3>
</div>
<div class="loginBox">
    <h2 class="loginBox-title">登录</h2>
    <form id="loginForm" th:action="@{/authentication/login}" method="post">
        <div class="item">
            <label class="item-label">用户名 |</label>
            <label class="item-label-input">
                <input placeholder="请输入用户名" value="admin" name="username" type="text" autocomplete="off" required>
            </label>
        </div>
        <div class="item">
            <label class="item-label">密 &emsp;码 |</label>
            <label class="item-label-input">
                <input placeholder="请输入密码" value="123456" name="password" type="password" required>
            </label>
        </div>
        <button id="submitBtn" class="btn">
            确认
            <span></span>
            <span></span>
            <span></span>
            <span></span>
        </button>
    </form>
</div>
</body>
</html>

需要注意:自定义表单提交接口是我们自定义的:/authentication/login

2. 设置前往登录页接口

2.1 定义接口为:/sso/login

@Slf4j
@Controller
public class SsoController {

    @GetMapping("/sso/login")
    public ModelAndView login(ModelAndView modelAndView) {
        modelAndView.setViewName("login");
        return modelAndView;
    }
}

3. 修改认证服务器配置

3.1 设置前往登录页的地址

/**
     * 用于协议端点的Spring Security过滤器链
     *
     * @param http HttpSecurity
     * @return SecurityFilterChain
     */
    @Bean
    public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
        OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
        http.getConfigurer(OAuth2AuthorizationServerConfigurer.class);
        // 开启OpenID Connect 1.0 暂时不清楚该协议是什么,那就不用先
        //.oidc(Customizer.withDefaults());
        http
                // 当未登录时访问认证端点时重定向至登录页面,默认前往登录页的uri是/sso/login
                .exceptionHandling((exceptions) -> exceptions
                        .authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint("/sso/login")));
        return http.build();
    }

这里的/sso/login是未登录时访问认证端点跳转的地址,如果访问我们的正常接口还是会去到默认登录页,所以需要继续配置

3.2 开放前往登录页的接口

    /**
     * 用于认证的Spring Security过滤器链。
     *
     * @param http HttpSecurity
     * @return SecurityFilterChain
     */
    @Bean
    public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests((authorize) -> authorize
                        // 放行静态资源
                        .requestMatchers("/sso/login", "/authentication/login")
                        .permitAll().anyRequest().authenticated())
                .formLogin(Customizer.withDefaults())
                .formLogin(login -> login
                        // 指定登录页面
                        .loginPage("/sso/login")
                        // 指定表单提交接口
                        .loginProcessingUrl("/authentication/login"));
        return http.build();
    }

访问:http://localhost:8080/oauth2/authorize?response_type=code&scope=user&client_id=oauth2-client&state=ok&redirect_uri=https://www.baidu.com 在这里插入图片描述 点击登录成功来到授权页,继而获取到授权码。 在这里插入图片描述

https://www.baidu.com/?code=7FHNF-CrQkau6LhB8gQvsFc34cR0bb2ihtZHvf7QF0ZXazZTQr5sVcHxX9IP5Ry5R5nnc5T2Stv6fOzS-uGHWDHTjKg-VqIoFSBNS3ZYHTkMo3pFBOT8PO1qIKfZnH_Y&state=ok 在这里插入图片描述 至此,基于JDBC实现获取数据库用户登录和自定义登录页已完成。 目前为止,好像没有出现最上面提到的重要问题,没关系后面会有基于Redis实现保存token,到时候会有很多重要问题,目前已经实现完毕,待整理资料发布。