手把手教你搭建SSO单点登录授权服务器(上)

148 阅读21分钟

手把手教你搭建SSO单点登录授权服务器(上)

概述

Security 是一个功能强大且高度可定制的身份验证和访问控制框架。它是保护基于 Spring 的应用程序的事实上的标准。Security 是一个专注于为 Java 应用程序提供身份验证和授权的框架。像所有 Spring 项目一样,Security 的真正力量在于它可以很容易地扩展以满足定制需求。

Security 是一个高度自定义的安全框架。利用 IoC/DI 和 AOP 功能,为系统提供了声明式安全访问控制功能,减少了为系统安全而编写大量重复代码的工作。

使用 Secruity 的原因有很多,但大部分都是发现了 Java EE 的 Servlet 规范或 EJB 规范中的安全功能缺乏典型企业应用场景。同时认识到他们在 WAR 或 EAR 级别无法移植。因此如果你更换服务器环境,还有大量工作去重新配置你的应用程序。使用 Security 解决了这些问题,也为你提供许多其他有用的、可定制的安全功能。

正如你可能知道的关于安全方面的两个主要区域是 “认证”“授权”(或者访问控制)。这两点也是 Security 重要且核心功能。“认证” 是建立一个他声明的主体的过程(一个 “主体” 一般是指用户,设备或一些可以在你的应用程序中执行动作的其他系统)。通俗点说就是系统认为用户是否能登录。“授权” 指确定一个主体是否允许在你的应用程序执行一个动作的过程,通俗点讲就是系统判断用户是否有权限去做某些事情。

Security 提供了一组原语义,用最小的代价来创建安全的应用和服务。通过统一管理中心,将应用自己授权给大型协作系统、远程组件。它在 Cloud Foundry 平台中也非常易用。基于 Spring Boot 和 Spring Security 和 OAuth2我们可以快速的实现统一登录、令牌传递、令牌交换。

历史

Security 以 “The Acegi Secutity System for Spring” 的名字始于2003年年底,其前身为acegi项目。起因是Spring开发者邮件列表中一个问题,有人提问是否考虑提供一个基于 Spring 的安全实现?

限制于时间问题,开发出了一个简单的安全实现,但是并没有深入研究。几周后,Spring 社区中其他成员同样询问了安全问题,代码提供给了这些人。

2004年1月份已经有20人左右使用这个项目,随着更多人的加入,在 2004 年 3 月左右在 sourceforge 中建立了一个项目。在最开始并没有认证模块,所有的认证功能都是依赖容器完成的,而 acegi 则注重授权。但是随着更多人的使用,基于容器的认证就显现出了不足。acegi 中也加入了认证功能,大约1年后 acegi 成为 Spring 子项目。

在 2006 年 5 月发布了 acegi 1.0.0 版本,2007 年底 acegi 正式更名为 Spring Security。

特点

  • 全面和可扩展的身份验证和授权支持

  • 防止像会话固定,点击劫持,跨站点请求伪造等攻击

  • Servlet API 集成

  • 与 Spring Web MVC 的可选集成

  • 在 Zuul Proxy 中传递 SSO Tokens

  • 资源服务器之间的传递tokens

  • Feign 客户端拦截器行为,如 OAuth2 RestTemplate(fetching tokens)

  • 在 Zuul Proxy 配置下游认证

最小配置化的Spring Security 应用

在你的 Spring 应用中添加 Seurity 依赖:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

启动项目之后,会在控制台打印出以下内容:

Using generated security password: 153f27d8-6673-4945-9a91-eaf3ca498395

2023-03-01 09:42:35.766  INFO 8664 --- [  restartedMain] o.s.cloud.commons.util.InetUtils         : Cannot determine local hostname
2023-03-01 09:42:35.769  INFO 8664 --- [  restartedMain] com.alibaba.nacos.client.naming          : initializer namespace from System Property :null

此时,你可能会产生一个疑问:难道我们每次启动应用都要去控制台找密码登陆吗?要如何去自定义这个口令呢?

这个问题,我们可以通过重写 WebSecurityConfigurerAdapter 配置类实现,具体代码如下:

@Configuration
public class DemoSecurityConfiguration extends WebSecurityConfigurerAdapter {
    @Override
    public void configure(WebSecurity web) {
        // 配置需要忽略的路径
        web.ignoring().antMatchers("/ignore/**");
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
            // 需要 USER 角色才能访问
            .antMatchers("/demo/**").hasRole("USER")
            // 需要 ADMIN 角色才能访问
            .antMatchers("/druid/**").hasRole("ADMIN")
            // 需要 ADMIN 角色才能访问
            .antMatchers("/actuator/**").hasRole("ADMIN")
            // 其他所有请求都需要认证
            .anyRequest().authenticated()
            .and()
            // 开启表单登录
            .formLogin().and()
            .httpBasic();
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.inMemoryAuthentication()
            .withUser("caixibei").password("{noop}NJI(mko0").roles("ADMIN", "USER")
            .and()
            .withUser("rzy").password("{noop}rzy").roles("USER");
    }
}

然后,重新启动应用,你就会发现控制台已经不打印口令了,使用配置类中设置的账户密码进行登录即可,但需要注意的是,这里的账户及密码是保存在内存中的,依然不符合企业级开发需求。同时,密码中的 {noop} 表示的是不使用任何加密手段对口令进行加密,采用明文密钥进行验证。

此时,你可能就会问,怎么结合数据库进行验证?这里我们可以通过实现 UserDetailsService 去自定义登录逻辑,示例代码如下:

@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    @Autowired
    private UserService userService;

    @Autowired
    private RoleService roleService;

    /** 默认角色 */
    private GrantedAuthority DEFAULT_ROLE = new SimpleGrantedAuthority("DEFAULT");

    @Override
    public UserDetails loadUserByUsername(String userName) throws UsernameNotFoundException {
        // ...查询用户是否存在
        User userFromDatabase = userService.findUserById(userName);
        if (userFromDatabase == null) {
            throw new SecurityException("账号 {" + userName + "} 不存在!");
        }
        // ...确定是否启用用户状态
        if(StringUtils.isBlank(userFromDatabase.getEnabled()) 
            || !conversionString(userFromDatabase.getEnabled())){
            throw new SecurityException("账号 {"+ userName + "} 被禁用!");
        }
        // ...判断用户是否被锁定
        if(StringUtils.isBlank(userFromDatabase.getAccountNonLocked()) 
            || !conversionString(userFromDatabase.getAccountNonLocked())){
            throw new SecurityException("账号 {"+ userName + "} 被锁定!");
        }
        // ...确定用户是否过期
        if(StringUtils.isBlank(userFromDatabase.getAccountNonExpired()) 
            || !conversionString(userFromDatabase.getAccountNonExpired())){
            throw new SecurityException("账号 {"+ userName + "} 已过期!");
        }
        // ...检查密码是否过期
        if(StringUtils.isBlank(userFromDatabase.getCredentialsNonExpired()) 
            || !conversionString(userFromDatabase.getCredentialsNonExpired())){
            throw new SecurityException("账号 {"+ userName + "} 密码已过期,请联系管理员修改!");
        }
        // ...使用实例查询用户的角色权限
        List<GrantedAuthority> grantedAuthorityList = new ArrayList<>();
        String[] roleIds = StringUtils.isBlank(userFromDatabase.getRole_Id()) ? new String[0] : userFromDatabase.getRole_Id().split(",");
        // ...如果用户没有任何角色,则使用默认角色
        if(roleIds.length==0){
            grantedAuthorityList.add(DEFAULT_ROLE);
        }else{
            for(String roleId:roleIds){
                Role role = roleService.findRoleById(roleId);
                grantedAuthorityList.add(new SimpleGrantedAuthority(role.getRoleCode()));
            }
        }
        return new org.springframework.security.core.userdetails.User(userFromDatabase.getUserName(),
                userFromDatabase.getPassword(),
                conversionString(userFromDatabase.getEnabled()),
                conversionString(userFromDatabase.getAccountNonExpired()),
                conversionString(userFromDatabase.getCredentialsNonExpired()),
                conversionString(userFromDatabase.getAccountNonLocked()),
                grantedAuthorityList);
    }
 
    protected boolean conversionString(String c){
        return StringUtils.equalsIgnoreCase("Y",c) ? true : false;
    }
}

稍微调整下配置类,代码如下:

@Configuration
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {

    @Autowired
    private UserDetailsServiceImpl customUserDetailsService;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
            // 需要鉴权的路径
            .antMatchers("/druid/**").hasAuthority("ADMIN")
            .antMatchers("/actuator/**").hasAuthority("ADMIN")
            .antMatchers("/demo/**").hasAuthority("DEFAULT")
            .anyRequest().authenticated()
            // 关闭csrf
            .and().csrf().disable()
            // ...关闭跨域支持
            .cors().disable()
            // 登录页面配置,覆盖默认的登录页面
            //.formLogin().loginPage("/login");
            // 登出页面配置
            //.and().logout().logoutUrl("/logout")
            // 基于httpBasic的简易登录页
            .formLogin()
            .and().httpBasic();
    }

    /**
     * 配置自定义的身份验证管理器
     * @param auth 身份验证管理器
     * @throws Exception
     */
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(customUserDetailsService)
            .passwordEncoder(passwordEncoder());
    }

    @Override
    public void configure(WebSecurity web) throws Exception {
        // 忽略不需要安全认证的路径
        // web.ignoring().antMatchers("/demo/**");
    }

    /**
     * 配置一个全局统一共享的PasswordEncoder(密码编码器),例如:
     * <ul>
     * <li>bcrypt - {@link BCryptPasswordEncoder} (也用于编码)</li>
     * <li>ldap - {@link org.springframework.security.crypto.password.LdapShaPasswordEncoder}</li>
     * <li>MD4 - {@link org.springframework.security.crypto.password.Md4PasswordEncoder}</li>
     * <li>MD5 - {@code new MessageDigestPasswordEncoder("MD5")}</li>
     * <li>noop - {@link org.springframework.security.crypto.password.NoOpPasswordEncoder}</li>
     * <li>pbkdf2 - {@link Pbkdf2PasswordEncoder}</li>
     * <li>scrypt - {@link SCryptPasswordEncoder}</li>
     * <li>SHA-1 - {@code new MessageDigestPasswordEncoder("SHA-1")}</li>
     * <li>SHA-256 - {@code new MessageDigestPasswordEncoder("SHA-256")}</li>
     * <li>sha256 - {@link org.springframework.security.crypto.password.StandardPasswordEncoder}</li>
     * <li>argon2 - {@link Argon2PasswordEncoder}</li>
     * </ul>
     * @return the {@link PasswordEncoder} to use
     */
    @Bean
    public PasswordEncoder passwordEncoder(){
        // 创建加密工厂类,这似乎是内置的
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
        // 出于测试目的,我们将对密码进行解加密,并以纯文本的形式显示它
        //return NoOpPasswordEncoder.getInstance();
    }
}

然后,我们就可以使用数据库内的用户进行验证,但仍需要注意的是,上述代码存在一些查询用户、角色表的接口,我没有放进来,但是我相信你既然看了这篇文章,相信水平可以的,如果还是看不懂的话后续可以直接去 Github 看我的完整代码。

Security + OAuth2.0 + JWT + Redis 单点登录实战

请注意 Spring Boot、Spring Cloud 、Spring Cloud Alibaba 的版本,在最新的版本中,配置类 WebSecurityConfigurerAdapter可能已经被弃用了,请结合实际情况自行调整策略。

添加依赖

这里使用的依赖不是 spring-boot-starter-security ,稍微注意下。

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-oauth2</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-oauth2-jose</artifactId>
</dependency>

应用配置

server:
  port: ${SERVER_PORT:9000}
  servlet:
    encoding:
      enabled: true
      charset: UTF-8
spring:
  mvc:
    throw-exception-if-no-handler-found: true
  resources:
    add-mappings: true
  application:
    name: ${APPLICATION_NAME:SPRING-CLOUD-SSO}
  redis:
    host: ${REDIS_HOST:116.**.**.132}
    port: ${REDIS_PORT:6379}
    password: ${REDIS_PASSWORD:NJ****ko0}
  datasource:
    druid:
      keep-alive: true
      max-active: 20
      initial-size: 1
      min-idle: 1
      max-wait: 360000
      use-unfair-lock: true
      time-between-eviction-runs-millis: 60000
      min-evictable-idle-time-millis: 30000
      max-evictable-idle-time-millis: 180000
      pool-prepared-statements: true
      max-open-prepared-statements: 20
      phy-timeout-millis: 15000
      remove-abandoned: true
      remove-abandoned-timeout: 180
      test-while-idle: true
      test-on-borrow: false
      test-on-return: false
      filter:
        wall:
          config:
            multi-statement-allow: true
            drop-table-allow: false
        stat:
          log-slow-sql: true
          slow-sql-millis: 1000
          merge-sql: true
      web-stat-filter:
        enabled: true
        url-pattern: /*
        exclusions: /*.js,/*.gif,/*.jpg,/*.bmp,/*.png,/*.css,/*.ico,/druid/*
        session-stat-enable: true
        profile-enable: true
      stat-view-servlet:
        enabled: true
        allow:
        reset-enable: false
        login-username: ENC(LJJZq5/m+BsKZm753vxChQ==)
        login-password: ENC(8Wa/OwMogrS2z1vknfN3Fg==)
        url-pattern: /druid/*
    dynamic:
      primary: mysql
      strict: false
      datasource:
        #db2:
        #  type: com.alibaba.druid.pool.DruidDataSource
        #  driver-class-name: com.ibm.db2.jcc.DB2Driver
        #  url: ENC(AWKzTaOZq0X6Nh7M0dZAmqITTDNW45oOrIu3txP9uOaEvVj9m6TOEL2nlApwTnu5/A+O5Sg2v9D8O6i5H9VIvOw0seTqJjst)
        #  username: ENC(+qacedhy7A9LtB9emwRN6rLJ0amtDREn)
        #  password: ENC(U6HdbxxyB1p6SghOL4NbR9mUkbHHkBxR)
        #  druid:
        #    initial-size: 1
        #    max-active: 20
        #    min-idle: 1
        #    max-wait: 360000
        #    use-unfair-lock: true
        #    min-evictable-idle-time-millis: 30000
        #    max-evictable-idle-time-millis: 180000
        #    time-between-eviction-runs-millis: 60000
        #    validation-query: select current date from sysibm.sysdummy1
        #    validation-query-timeout: -1
        #    test-on-borrow: false
        #    test-on-return: false
        #    test-while-idle: true
        #    pool-prepared-statements: true
        #    filters: stat,wall
        #    share-prepared-statements: true
        mysql:
          type: com.alibaba.druid.pool.DruidDataSource
          driver-class-name: com.mysql.cj.jdbc.Driver
          url: ${SPRING_DATASOURCE_DYNAMIC_DATASOURCE_MYSQL_URL:ENC(/5xJ0XvV4wg87/M+IHPP6B/qu91ZLspRWzrYdDmz6HpKLzk6Kh5gUDcz/bjbdul5tiUjNLn8NE1lhOVc6UAHlEG6GY8UU8HflSRACjzU3aKy3R2EEZCKAaRN2nlzLaBg0awR2J4D2lbXLqd0lhvWFeIQv8YWy6B61YeDNLHZvz8781bsMSfl4sZk0UtIY3UgTnx2FG5tzfGzr1b8+0PDkbo4C99/tkic+RlRIE8seRCi6k/UEYkZH2GksvC6oQWX+kppBjKBYwOWkp4EvFeqPTFm3PT+rbYB)}
          username: ${SPRING_DATASOURCE_DYNAMIC_DATASOURCE_MYSQL_USERNAME:ENC(QkI0VPRiOCJR8EbbhdlIog==)}
          password: ${SPRING_DATASOURCE_DYNAMIC_DATASOURCE_MYSQL_PASSWORD:ENC(0iRXcamB2YeKTq5XjQTsas1WC0K2SWx4)}
          druid:
            initial-size: 1
            max-active: 20
            min-idle: 1
            max-wait: 360000
            use-unfair-lock: true
            min-evictable-idle-time-millis: 30000
            max-evictable-idle-time-millis: 180000
            time-between-eviction-runs-millis: 60000
            validation-query: select 1
            validation-query-timeout: -1
            test-on-borrow: false
            test-on-return: false
            test-while-idle: true
            pool-prepared-statements: true
            filters: stat,wall
            share-prepared-statements: true

# Eureka 配置
eureka:
  instance:
    hostname: ${HOSTNAME:${spring.application.name}}
    prefer-ip-address: ${PREFER_IP_ADDRESS:true}
    instance-id: ${spring.cloud.client.ip-address}:${server.port}
    lease-renewal-interval-in-seconds: ${LEASE_RENEWAL_INTERVAL_IN_SECONDS:30}
  client:
    fetch-registry: ${FETCH_REGISTRY:true}
    register-with-eureka: ${REGISTER_WITH_EUREKA:true}
    service-url:
      defaultZone: ${DEFAULT_ZONE:http://admin:123456@spring-cloud-eureka-master:8761/eureka/,http://admin:123456@spring-cloud-eureka-slave:8762/eureka/}

# Jasypt 加密
jasypt:
  encryptor:
    password: ${JSYPT_ENCRYPTOR_PASSWORD:DdNwDFt2D5v5OVstBTr4h565ZRGVnSO7}
    algorithm: ${JSYPT_ENCRYPTOR_ALGORITHM:PBEWithMD5AndDES}
    string-output-type: ${JSYPT_ENCRYPTOR_STRING_OUTPUT_TYPE:base64}
    iv-generator-classname: ${JSYPT_ENCRYPTOR_IV_GENERATOR_CLASSNAME:org.jasypt.iv.NoIvGenerator}

# ORM 框架
mybatis-plus:
  mapper-locations: classpath:/mapper/*-mapper.xml
  global-config:
    db-config:
      id-type: assign_uuid
  configuration:
    map-underscore-to-camel-case: true
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
mpj:
  base-package: tsai.spring.cloud.**.mapper
  defaults:
    enable-meta-handler: true
    enable-interceptor: true
    enable-injector: true

请注意,我这里配置很多形式写成 ${SERVER_PORT:9000} 格式,估计少部分人不知啥用意,我这里主要是为了后面我使用 docker-compose 编排的时候,可以修改配置,举个例子:

services:
    sso-service:
      # 指定容器所使用的镜像名称为 spring-cloud-sso
      # image: spring-cloud-sso:latest
      # 指定容器的名称为 spring-cloud-sso
      container_name: spring-cloud-sso
      privileged: true
      # 设置容器的自动重启策略为 always,即容器总是重新启动
      restart: unless-stopped
      build:
        # 指定构建该服务的上下文路径,即 Dockerfile 所在的目录
        # context: spring-cloud-sso
        context: .
        # 指定 Dockerfile 文件路径
        dockerfile: ./spring-cloud-sso/Dockerfile
        # 参数
        args:
          VERSION: 1.2.0
        # 禁用缓存
        no_cache: true
      # 暴露容器的端口映射,将容器的端口映射到主机端口
      ports:
        - "9000:9000"
      # 启动环境参数配置
      environment:
        - SERVER_PORT=9000
        - APPLICATION_NAME=spring-cloud-sso
        - REDIS_HOST=116.205.112.132
        - REDIS_PORT=6379
        - REDIS_PASSWORD=NJI(mko0
        - SPRING_DATASOURCE_DYNAMIC_DATASOURCE_MYSQL_URL=ENC(/5xJ0XvV4wg87/M+IHPP6B/qu91ZLspRWzrYdDmz6HpKLzk6Kh5gUDcz/bjbdul5tiUjNLn8NE1lhOVc6UAHlEG6GY8UU8HflSRACjzU3aKy3R2EEZCKAaRN2nlzLaBg0awR2J4D2lbXLqd0lhvWFeIQv8YWy6B61YeDNLHZvz8781bsMSfl4sZk0UtIY3UgTnx2FG5tzfGzr1b8+0PDkbo4C99/tkic+RlRIE8seRCi6k/UEYkZH2GksvC6oQWX+kppBjKBYwOWkp4EvFeqPTFm3PT+rbYB)
        - SPRING_DATASOURCE_DYNAMIC_DATASOURCE_MYSQL_USERNAME=ENC(QkI0VPRiOCJR8EbbhdlIog==)
        - SPRING_DATASOURCE_DYNAMIC_DATASOURCE_MYSQL_PASSWORD=ENC(0iRXcamB2YeKTq5XjQTsas1WC0K2SWx4)
        - HOSTNAME=spring-cloud-sso
        - PREFER_IP_ADDRESS=true
        - LEASE_RENEWAL_INTERVAL_IN_SECONDS=30
        - FETCH_REGISTRY=true
        - REGISTER_WITH_EUREKA=true
        - DEFAULT_ZONE=http://admin:123456@spring-cloud-eureka-master:8761/eureka/,http://admin:123456@spring-cloud-eureka-slave:8762/eureka/
        - ENDPOINTS=info,shutdown
        - ENABLE_SHOWDOWN=true
        - JSYPT_ENCRYPTOR_PASSWORD=DdNwDFt2D5v5OVstBTr4h565ZRGVnSO7
        - JSYPT_ENCRYPTOR_ALGORITHM=PBEWithMD5AndDES
        - JSYPT_ENCRYPTOR_STRING_OUTPUT_TYPE=base64
        - JSYPT_ENCRYPTOR_IV_GENERATOR_CLASSNAME=org.jasypt.iv.NoIvGenerator
      # 网络设置
      networks:
        - tsai-spring-cloud
      # 部署设置
      deploy:
        # 确保只有一个副本
        replicas: 1
        # 部署资源大小限制
        resources:
          limits:
            cpus: 0.5
            memory: 1024M
          reservations:
            cpus: 0.5
            memory: 1024M

这样我们就不用反复去修改我们的配置文件了。

授权服务配置

框架提供了几个默认的端点:

  • /oauth/authorize:授权端点
  • /oauth/token:获取令牌端点
  • /oauth/confirm_access:用户确认授权端点
  • /oauth/check_token:校验令牌端点
  • /oauth/error:用于在授权服务器中呈现错误
  • /oauth/token_key:获取 jwt 公钥端点

继承 AuthorizationServerConfigurerAdapter 类后,我们需要重写以下三个方法扩展实现我们的需求

  • configure(ClientDetailsServiceConfigurer clients) :用于定义、初始化客户端信息
  • configure(AuthorizationServerEndpointsConfigurer endpoints):用于定义授权令牌端点及服务
  • configure(AuthorizationServerSecurityConfigurer security):用于定义令牌端点的安全约束

ClientDetailsServiceConfigurer 用于定义 内存 中或 基于JDBC存储 实现的客户端,其重要的几个属性有:

  • clientId:客户端id,必填;
  • clientSecret:客户端密钥;
  • authorizedGrantTypes:客户端授权类型,有 4 种模式:
    • authorization_code:授权码模式
    • password:密码模式
    • client_credentials:客户端凭证模式
    • implicit:隐藏模式
  • scope:授权范围;
  • accessTokenValiditySeconds:access_token 有效时间,单位为秒,默认为 12 小时;
  • refreshTokenValiditySeconds:refresh_token 有效时间,单位为秒,默认为 30 天;

具体的实现方式,请看代码上的注释,要是还是很疑惑的话,可以问问 chatgpt 或者 deepseek,这里不做过多的解释:

/**
 * Oauth 2 授权服务配置
 * @author tsai
 */
@Configuration
@EnableAuthorizationServer
public class OauthServerConfiguration extends AuthorizationServerConfigurerAdapter {

    private final AuthenticationManager authenticationManager;

    private final UserDetailsServiceImpl userDetailsService;

    private final PasswordEncoder passwordEncoder;

    private final DataSource dataSource;

    private final RedisConnectionFactory redisConnectionFactory;

    public OauthServerConfiguration(AuthenticationManager authenticationManager,
                                    UserDetailsServiceImpl userDetailsService,
                                    RedisConnectionFactory redisConnectionFactory,
                                    PasswordEncoder passwordEncoder,
                                    DataSource dataSource){
        this.authenticationManager = authenticationManager;
        this.userDetailsService = userDetailsService;
        this.redisConnectionFactory = redisConnectionFactory;
        this.passwordEncoder = passwordEncoder;
        this.dataSource = dataSource;
    }

    @Bean
    public ClientDetailsService clientDetails() {
        JdbcClientDetailsService jdbcClientDetailsService = new JdbcClientDetailsService(dataSource);
        jdbcClientDetailsService.setPasswordEncoder(passwordEncoder);
        return jdbcClientDetailsService;
    }

    /**
     * 授权码模式通过 {@code @Bean} 注解开启-授权码存储服务
     * @return {@link AuthorizationCodeServices}
     */
    @Bean
    public AuthorizationCodeServices authorizationCodeServices () {
        return new JdbcAuthorizationCodeServices(dataSource);
    }

    /**
     * 对于每个客户端应用,授权服务器会为其分配一个唯一的客户端ID和客户端密钥,并定义其授权范围和访问权限。
     * <p>用于配置客户端详细信息服务,这个服务用来定义哪些客户端可以访问授权服务器以及客户端的配置信息。
     *
     * @param clients the client details configurer
     * @throws Exception 异常
     */
    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        // 使用基于JDBC模式的密码认证
        clients.withClientDetails(clientDetails());
    }

    /**
     * 用于配置授权服务器的安全性,如 /oauth/token、/oauth/authorize 等端点的安全性配置。
     *
     * @param security a fluent configurer for security features
     */
    @Override
    public void configure(AuthorizationServerSecurityConfigurer security) {
        // 允许客户端表单身份验证
        security.allowFormAuthenticationForClients()
                // 仅允许认证后的用户访问密钥端点(单点登录必须,如果是无状态的,请改为 permitAll() )
                .tokenKeyAccess("isAuthenticated()")
                // 允许所有人访问令牌验证端点
                .checkTokenAccess("permitAll()");
    }

    /**
     * 用于配置授权和令牌的端点,以及令牌服务、令牌存储、用户认证等相关配置。
     *
     * @param endpoints the endpoints configurer
     * @throws Exception 异常
     */
    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        // 配置用于密码模式的认证管理器
        endpoints.authenticationManager(authenticationManager)
                // 授权码模式服务
                .authorizationCodeServices(authorizationCodeServices())
                // 在刷新令牌时使用此服务加载用户信息
                .userDetailsService(userDetailsService)
                // token 解析器
                .accessTokenConverter(tokenConverter())
                // token 扩展器
                .tokenEnhancer(tokenEnhancer())
                // 以 redis 存储 token
                .tokenStore(tokenStore());
    }

    @Bean
    public TokenStore tokenStore() {
        // JWT 模式
        // return new JwtTokenStore(tokenConverter());
        // 存储在数据库中
        // return new JdbcTokenStore(dataSource);
        // 存储在 Redis 中
        return new RedisTokenStore(redisConnectionFactory);
    }

    /**
     * 使用非对称加密算法对 Token 签名
     * @return {@link JwtAccessTokenConverter}
     */
    @Bean
    public JwtAccessTokenConverter tokenConverter() {
        JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
        converter.setKeyPair(keyPair());
        return converter;
    }

    @Bean
    public KeyPair keyPair() {
        // 从证书文件 jwt.jks 中获取秘钥对
        // 密钥删除命令:keytool -delete -alias jwt -keystore jwt.jks
        // 密钥生成命令:keytool -genkey -alias jwt -keyalg RSA -keypass 123456 -keystore jwt.jks -storepass 123456
        KeyStoreKeyFactory keyStoreKeyFactory = new KeyStoreKeyFactory(new ClassPathResource("jwt.jks"), "123456".toCharArray());
        return keyStoreKeyFactory.getKeyPair("jwt", "123456".toCharArray());
    }

    @Bean
    public TokenEnhancer tokenEnhancer() {
        return (oAuth2AccessToken, oAuth2Authentication) -> {
            Map<String, Object> map = new HashMap<>(1);
            User user = (User) oAuth2Authentication.getPrincipal();
            map.put("username", user.getUsername());
            // ...其他信息可以自行添加
            ((DefaultOAuth2AccessToken) oAuth2AccessToken).setAdditionalInformation(map);
            return oAuth2AccessToken;
        };
    }
}

客户端信息一般保存在 Redis 或数据库中,我们现在的客户端信息保存在MySQL数据库中,基于JDBC存储模式需要创建数据表。

官方提供了建表的 SQL 语句,可访问 gitee.com/link?target… 获取,大致如下,至于每张表的作用,请劳烦各位前去官网的文档。

CREATE TABLE `clientdetails`
(
    `appId`                  varchar(128) NOT NULL,
    `resourceIds`            varchar(256)  DEFAULT NULL,
    `appSecret`              varchar(256)  DEFAULT NULL,
    `scope`                  varchar(256)  DEFAULT NULL,
    `grantTypes`             varchar(256)  DEFAULT NULL,
    `redirectUrl`            varchar(256)  DEFAULT NULL,
    `authorities`            varchar(256)  DEFAULT NULL,
    `access_token_validity`  int(11) DEFAULT NULL,
    `refresh_token_validity` int(11) DEFAULT NULL,
    `additionalInformation`  varchar(4096) DEFAULT NULL,
    `autoApproveScopes`      varchar(256)  DEFAULT NULL,
    PRIMARY KEY (`appId`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

CREATE TABLE `oauth_access_token`
(
    `token_id`          varchar(256) DEFAULT NULL,
    `token`             blob,
    `authentication_id` varchar(128) NOT NULL,
    `user_name`         varchar(256) DEFAULT NULL,
    `client_id`         varchar(256) DEFAULT NULL,
    `authentication`    blob,
    `refresh_token`     varchar(256) DEFAULT NULL,
    PRIMARY KEY (`authentication_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

CREATE TABLE `oauth_approvals`
(
    `userId`         varchar(256) DEFAULT NULL,
    `clientId`       varchar(256) DEFAULT NULL,
    `scope`          varchar(256) DEFAULT NULL,
    `status`         varchar(10)  DEFAULT NULL,
    `expiresAt`      timestamp NULL DEFAULT NULL,
    `lastModifiedAt` timestamp NULL DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

CREATE TABLE `oauth_client_details`
(
    `client_id`               varchar(128) NOT NULL,
    `resource_ids`            varchar(256)  DEFAULT NULL,
    `client_secret`           varchar(256)  DEFAULT NULL,
    `scope`                   varchar(256)  DEFAULT NULL,
    `authorized_grant_types`  varchar(256)  DEFAULT NULL,
    `web_server_redirect_uri` varchar(256)  DEFAULT NULL,
    `authorities`             varchar(256)  DEFAULT NULL,
    `access_token_validity`   int(11) DEFAULT NULL,
    `refresh_token_validity`  int(11) DEFAULT NULL,
    `additional_information`  varchar(4096) DEFAULT NULL,
    `autoapprove`             varchar(256)  DEFAULT NULL,
    PRIMARY KEY (`client_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

CREATE TABLE `oauth_client_token`
(
    `token_id`          varchar(256) DEFAULT NULL,
    `token`             blob,
    `authentication_id` varchar(128) NOT NULL,
    `user_name`         varchar(256) DEFAULT NULL,
    `client_id`         varchar(256) DEFAULT NULL,
    PRIMARY KEY (`authentication_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

CREATE TABLE `oauth_code`
(
    `code`           varchar(256) DEFAULT NULL,
    `authentication` blob
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

CREATE TABLE `oauth_refresh_token`
(
    `token_id`       varchar(256) DEFAULT NULL,
    `token`          blob,
    `authentication` blob
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

但我只用到了其中的两张表,具体如下:

-- 创建数据库
create database `tsai-db`;
-- 指定数据库
use `tsai-db`;
-- 创建 Oauth2 认证表
CREATE TABLE `oauth_client_details` (
    `client_id`               varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci  NOT NULL,
    `resource_ids`            varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci  NULL DEFAULT NULL,
    `client_secret`           varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci  NULL DEFAULT NULL,
    `scope`                   varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci  NULL DEFAULT NULL,
    `authorized_grant_types`  varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci  NULL DEFAULT NULL,
    `web_server_redirect_uri` varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci  NULL DEFAULT NULL,
    `authorities`             varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci  NULL DEFAULT NULL,
    `access_token_validity`   int(11)                                                  NULL DEFAULT NULL,
    `refresh_token_validity`  int(11)                                                  NULL DEFAULT NULL,
    `additional_information`  varchar(4096) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
    `autoapprove`             varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci  NULL DEFAULT NULL,
    PRIMARY KEY (`client_id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic;
-- 接入应用信息
INSERT INTO `tsai-db`.oauth_client_details 
    (client_id, resource_ids, client_secret, scope, authorized_grant_types, web_server_redirect_uri, 
     authorities, access_token_validity, refresh_token_validity, additional_information, autoapprove) 
VALUES ('SPRING-CLOUD-SYSTEM', 'SPRING-CLOUD-SYSTEM', '$2a$10$mcEwJ8qqhk2DYIle6VfhEOZHRdDbCSizAQbIwBR7tTuv9Q7Fca9Gi', 'all', 
        'password,refresh_token,authorization_code', null, null, 120, 240, null, null);
-- 授权码记录信息
CREATE TABLE `oauth_code`(
    `code`           varchar(256) DEFAULT NULL,
    `authentication` blob
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
-- 创建用户信息表
CREATE TABLE TSAI_USER
(
    ID          VARCHAR(128) NOT NULL PRIMARY KEY,
    USERNAME    VARCHAR(128) NOT NULL,
    PASSWORD    VARCHAR(256) NOT NULL,
    CREATE_TIME TIMESTAMP    NULL,
    UPDATE_TIME TIMESTAMP    NULL,
    CONSTRAINT USERNAME UNIQUE (USERNAME)
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic;
-- 用户信息数据
INSERT INTO `TSAI_USER` VALUES ('001', 'admin', '$2a$10$mcEwJ8qqhk2DYIle6VfhEOZHRdDbCSizAQbIwBR7tTuv9Q7Fca9Gi', NULL, NULL);

说一下,其中密码 123456 使用 BCryptPasswordEncoder 加密,加密后字符为: $2a$10$mcEwJ8qqhk2DYIle6VfhEOZHRdDbCSizAQbIwBR7tTuv9Q7Fca9Gi

安全配置

@Configuration
@EnableWebSecurity
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {

    @Autowired
    private UserDetailsServiceImpl userDetailsService;

    @Autowired
    private AccessDenyHandler accessDeniedHandler;

    @Autowired
    private LoginFailureHandler loginFailureHandler;

    @Autowired
    private SessionExpiredStrategy sessionExpiredStrategy;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable()
            .formLogin()
            // 自定义的登录页
            .loginPage("/login.html")
            // 必须和前端表单请求地址相同
            .loginProcessingUrl("/login")
            // 登录成功跳转页面
            .successForwardUrl("/index")
            // 登录失败跳转页面
            .failureForwardUrl("/error")
            // 登录失败处理器
            .failureHandler(loginFailureHandler)
            .and()
                .authorizeRequests()
                // 对静态资源、登录请求、获取token请求放行、获取验证码放行
                .antMatchers("/**/*.css", "/**/*.js", "/**/*.jpg",
                        "/**/*.png", "/**/*.gif", "/**/*.ico",
                        "/**/*.mp4", "/**/*.webm",
                        "/**/*.json", "/**/*.ttf", "/**/*.woff",
                        "/**/*.woff2", "/login.html","/error/403.html",
                        "/error", "/login", "/oauth/**", "/sso/lineCaptcha")
                .permitAll()
                // 其他所有请求必须通过认证后才能访问
                .anyRequest().authenticated()
            // 异常处理器
            .and().exceptionHandling()
                // 403:无权访问处理器
                .accessDeniedHandler(accessDeniedHandler)
            // 开启 session 会话管理
            .and().sessionManagement()
                 // session 创建策略(无状态会话)
                 .sessionCreationPolicy(SessionCreationPolicy.ALWAYS)
                 // 应用并发会话策略机制(暂不开启)
                 //.sessionAuthenticationStrategy(sessionAuthenticationStrategy())
                 // 最多允许登录端数量
                 .maximumSessions(1)
                 // 多端登录session失效的策略
                 .expiredSessionStrategy(sessionExpiredStrategy)
                 // 超过最大数量是否阻止新的登录
                 .maxSessionsPreventsLogin(false);
    }

    public SessionRegistryImpl sessionRegistry() {
        return new SessionRegistryImpl();
    }

    public ConcurrentSessionControlAuthenticationStrategy sessionAuthenticationStrategy() {
        ConcurrentSessionControlAuthenticationStrategy strategy = new ConcurrentSessionControlAuthenticationStrategy(sessionRegistry());
        // 限制每个用户只能有一个会话
        strategy.setMaximumSessions(1);
        // 超过最大会话数时抛出异常
        strategy.setExceptionIfMaximumExceeded(true);
        return strategy;
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        // 使用自定义的 AuthenticationProvider 进行认证
        // auth.authenticationProvider(oAuthProvider);
        // 如果自定义的 provider 中已经配置了,这里无需再配置了
        auth.userDetailsService(userDetailsService)
                .passwordEncoder(passwordEncoder());
    }

    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    /**
     * 配置一个全局统一共享的PasswordEncoder(密码编码器)
     *
     * @return the {@link PasswordEncoder} to use
     */
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    /**
     * 二次封装的 Redis 工具
     *
     * @param redisTemplate 基础操作模板
     * @return 实例
     */
    @Bean
    public RedisUtil redisUtil(StringRedisTemplate redisTemplate) {
        return new RedisUtil(redisTemplate);
    }
}

无权访问处理器

@Component
public class AccessDenyHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException e) throws IOException, ServletException {
        response.reset();
        response.setContentType("application/json;charset=utf-8");
        response.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS, DELETE");
        response.setHeader("Access-Control-Max-Age", "3600");
        response.setHeader("Access-Control-Allow-Credentials", "true");
        response.setHeader("Access-Control-Allow-Headers", "x-requested-with");
        response.setHeader("Access-Control-Allow-Origin", "*");
        JSONObject object = new JSONObject();
        object.putOnce("code", HttpStatus.HTTP_FORBIDDEN);
        object.putOnce("success", false);
        object.putOnce("timestamp", System.currentTimeMillis());
        object.putOnce("message", "权限不足,无法访问!");
        response.getWriter().write(JSONUtil.toJsonStr(object));
        response.flushBuffer();
    }
}

登录失败处理器

@Component
public class LoginFailureHandler implements AuthenticationFailureHandler {
    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
        JSONObject object = new JSONObject();
        BranchUtil.branchHandler(exception instanceof BadCredentialsException, () -> object.putOnce("details", "账号密码错误,请重新登录!"));
        BranchUtil.branchHandler(exception instanceof AccountExpiredException, () -> object.putOnce("details", "认证已过期,请重新登录!"));
        BranchUtil.branchHandler(exception instanceof AccountStatusException, () -> object.putOnce("details", "账号状态异常,请重新登录!"));
        object.putOnce("code", HttpStatus.HTTP_INTERNAL_ERROR);
        object.putOnce("success", false);
        object.putOnce("timestamp", System.currentTimeMillis());
        object.putOnce("message", "登录失败!");
        response.reset();
        response.setContentType("application/json;charset=utf-8");
        response.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS, DELETE");
        response.setHeader("Access-Control-Max-Age", "3600");
        response.setHeader("Access-Control-Allow-Credentials", "true");
        response.setHeader("Access-Control-Allow-Headers", "x-requested-with");
        response.setHeader("Access-Control-Allow-Origin", "*");
        response.getWriter().write(JSONUtil.toJsonStr(object));
        response.flushBuffer();
    }
}

跳转页面控制器

单纯的就是因为 Security 不支持 GET 请求跳页面,同时我也不想放行 GET 请求的缘故。

@Controller
public class LoginRedirectController {
    /**
     * 登录成功跳转
     * @return 跳转地址
     */
    @PostMapping("/index")
    public String dashboard() {
        return "redirect:/index.html";
    }

    /**
     * 登录失败跳转
     * @return 跳转地址
     */
    @PostMapping("/error")
    public String error(HttpServletResponse response) {
        response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        return "redirect:/error/403.html";
    }
}

验证码获取

验证码生成工具用的是 hutool 自带的。

@RestController
@RequestMapping("/sso")
public class SsoController {

    @Autowired
    private RedisUtil redisUtil;

    /**
     * 获取扭曲干扰的验证码
     *
     * @param response 响应报文
     */
    @GetMapping("/shearCaptcha")
    public void getShearCaptcha(HttpServletResponse response) {
        //定义图形验证码的长、宽、验证码字符数、干扰线宽度
        ShearCaptcha shearCaptcha = CaptchaUtil.createShearCaptcha(150, 50, 4, 3);
        //设置背景颜色
        shearCaptcha.setBackground(new Color(249, 251, 220));
        //生成四位验证码
        String code = RandomUtil.randomNumbers(4);
        //生成验证码图片
        Image image = shearCaptcha.createImage(code);
        //返回验证码信息
        responseCode(response, code, image);
    }

    /**
     * 获取线条干扰的验证码
     *
     * @param response 响应报文
     */
    @GetMapping("/lineCaptcha")
    public void getLineCaptcha(HttpServletRequest request, HttpServletResponse response) {
        // 定义图形验证码的长、宽、验证码位数、干扰线数量
        LineCaptcha lineCaptcha = CaptchaUtil.createLineCaptcha(150, 50, 4, 60);
        // 设置背景颜色
        lineCaptcha.setBackground(new Color(249, 251, 220));
        // 获取输入的账号,作为存储验证码的键(Redis)
        String username = request.getParameter("username");
        // 如果传入空的用户名直接返回错误信息
        BranchUtil.branchHandler(StrUtil.isBlank(username), () -> {
            response.reset();
            response.setContentType("application/json;charset=utf-8");
            response.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS, DELETE");
            response.setHeader("Access-Control-Max-Age", "3600");
            response.setHeader("Access-Control-Allow-Credentials", "true");
            response.setHeader("Access-Control-Allow-Headers", "x-requested-with");
            response.setHeader("Access-Control-Allow-Origin", "*");
            JSONObject object = new JSONObject();
            object.putOnce("code", HttpStatus.HTTP_BAD_REQUEST);
            object.putOnce("success", false);
            object.putOnce("timestamp", System.currentTimeMillis());
            object.putOnce("message", "请输入用户名!");
            try {
                response.getWriter().write(JSONUtil.toJsonStr(object));
                response.flushBuffer();
            } catch (IOException e) {
                throw new RuntimeException(e);
            }
        }, () -> {
            // 生成四位验证码
            String code = RandomUtil.randomString("abcdefghijkmnpqrstuvwxyz234567890ABCDEFGHJKLMNPQRSTUVWXYZ", 5);
            Image image = lineCaptcha.createImage(code);
            String uuid = IdUtil.fastSimpleUUID();
            // 存储在 Redis 中,同时设置有效时长为3分钟
            String key = StrUtil.concat(false, RedisConstant.CAPTCHA_KEY_PREFIX, uuid);
            redisUtil.setEx(key, code, 3, TimeUnit.MINUTES);
            log.info("生成验证码======> uuid:{} \t code: {}", uuid, code);
            // 设置验证码图片的key,同时以图片形式返回验证码信息
            request.getSession().setAttribute("Captcha", uuid);
            responseCode(response, code, image);
        });
    }

    /**
     * 获取圆圈干扰的验证码
     *
     * @param response 响应报文
     */
    @GetMapping("/circleCaptcha")
    public void getCircleCaptcha(HttpServletResponse response) {
        // 定义图形验证码的长、宽、验证码位数、干扰圈圈数量
        CircleCaptcha circleCaptcha = CaptchaUtil.createCircleCaptcha(150, 50, 4, 30);
        // 设置背景颜色
        circleCaptcha.setBackground(new Color(249, 251, 220));
        // 生成四位验证码
        String code = RandomUtil.randomNumbers(4);
        Image image = circleCaptcha.createImage(code);
        // 返回验证码信息
        responseCode(response, code, image);
    }

    protected static void responseCode(HttpServletResponse response, String code, Image image) {
        response.setContentType("image/jpeg");
        response.setHeader("Pragma", "no-cache");
        response.setHeader("Cache-Control", "no-cache");
        try {
            BufferedImage bufferedImage = toBufferedImage(image);
            // 创建 ByteArrayOutputStream 用于存储图片数据
            ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
            // 写入图片数据到 ByteArrayOutputStream
            ImageIO.write(bufferedImage, "jpeg", outputStream);
            // 将 ByteArrayOutputStream 转换为 ByteArrayInputStream
            byte[] imageInBytes = outputStream.toByteArray();
            IoUtil.write(response.getOutputStream(), true, imageInBytes);
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }
}

自定义登录逻辑

@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    @Autowired
    private UserService userService;

    @Autowired
    private RedisUtil redisUtil;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        HttpServletRequest request = ((ServletRequestAttributes) Objects.requireNonNull(RequestContextHolder.getRequestAttributes())).getRequest();
        HttpServletResponse response = getHttpServletResponse();
        JSONObject object = new JSONObject();
        object.putOnce("code", HttpStatus.HTTP_INTERNAL_ERROR);
        object.putOnce("success", false);
        object.putOnce("timestamp", System.currentTimeMillis());
        String uuid = (String) request.getSession().getAttribute("Captcha");
        String captcha = request.getParameter("captcha");
        String key = StrUtil.concat(false, RedisConstant.CAPTCHA_KEY_PREFIX, uuid);
        // 判断输入的用户名是否为空
        BranchUtil.branchHandler(StrUtil.isNotBlank(username), () -> {
            BranchUtil.branchHandler(redisUtil.hasKey(key), () -> {
                String verifyCode = redisUtil.get(key);
                BranchUtil.branchHandler(!StrUtil.equals(verifyCode, captcha),()->{
                    object.putOnce("message", "登录失败!");
                    object.putOnce("details", "验证码错误,请重新输入!");
                    try {
                        response.getWriter().write(JSONUtil.toJsonStr(object));
                        response.flushBuffer();
                    } catch (IOException e) {
                        throw new RuntimeException(e);
                    }
                });
            }, () -> {
                object.putOnce("message", "登录失败!");
                object.putOnce("details", "验证码已过期,请重新生成验证码!");
                try {
                    response.getWriter().write(JSONUtil.toJsonStr(object));
                    response.flushBuffer();
                } catch (IOException e) {
                    throw new RuntimeException(e);
                }
            });
        }, () -> {
            object.putOnce("message", "登录失败!");
            object.putOnce("details", "请输入用户名!");
            try {
                response.getWriter().write(JSONUtil.toJsonStr(object));
                response.flushBuffer();
            } catch (IOException e) {
                throw new RuntimeException(e);
            }
        });
        tsai.spring.cloud.pojo.User user = userService.findByUserName(username);
        BranchUtil.branchHandler(ObjectUtil.isNull(user),()->{
            object.putOnce("message", "登录失败!");
            object.putOnce("details", "您所输入的用户名不存在!");
            try {
                response.getWriter().write(JSONUtil.toJsonStr(object));
                response.flushBuffer();
            } catch (IOException e) {
                throw new RuntimeException(e);
            }
        });
        return new User(user.getUsername(), user.getPassword(), AuthorityUtils.commaSeparatedStringToAuthorityList("admin"));
    }

    protected static HttpServletResponse getHttpServletResponse() {
        HttpServletResponse response = ((ServletRequestAttributes) Objects.requireNonNull(RequestContextHolder.getRequestAttributes())).getResponse();
        assert response != null;
        response.reset();
        response.setContentType("application/json;charset=utf-8");
        response.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS, DELETE");
        response.setHeader("Access-Control-Max-Age", "3600");
        response.setHeader("Access-Control-Allow-Credentials", "true");
        response.setHeader("Access-Control-Allow-Headers", "x-requested-with");
        response.setHeader("Access-Control-Allow-Origin", "*");
        return response;
    }

}

用户实体类

@Data
@EqualsAndHashCode(callSuper = true)
@TableName(value = "TSAI_USER")
public class User extends TsaiPOJO implements Serializable {
    @TableField(value = "USERNAME")
    @JsonInclude(JsonInclude.Include.NON_NULL)
    private String username;

    @TableField(value = "PASSWORD")
    @JsonInclude(JsonInclude.Include.NON_NULL)
    private String password;
}

会话过期策略

@Component
public class SessionExpiredStrategy implements SessionInformationExpiredStrategy {

    @Override
    public void onExpiredSessionDetected(SessionInformationExpiredEvent event) throws IOException {
        HttpServletResponse response = getHttpServletResponse(event);
        JSONObject object = new JSONObject();
        object.putOnce("code", HttpStatus.HTTP_FORBIDDEN);
        object.putOnce("success", false);
        object.putOnce("timestamp", System.currentTimeMillis());
        object.putOnce("message", "您的账号在别处登录,当前登录失效!");
        response.getWriter().write(JSONUtil.toJsonStr(object));
        response.flushBuffer();
    }

    protected static HttpServletResponse getHttpServletResponse(SessionInformationExpiredEvent event) {
        HttpServletResponse response = event.getResponse();
        response.reset();
        response.setContentType("application/json;charset=utf-8");
        response.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS, DELETE");
        response.setHeader("Access-Control-Max-Age", "3600");
        response.setHeader("Access-Control-Allow-Credentials", "true");
        response.setHeader("Access-Control-Allow-Headers", "x-requested-with");
        response.setHeader("Access-Control-Allow-Origin", "*");
        return response;
    }
}

效果图展示

image.png

image.png

没写完,等我有空再更新吧!我要去睡觉了