探秘微服务:从零开启网关 SSO 服务搭建之旅

你好,这里是专栏"SpringCloud2023实战"。

往期推荐:

前言

单点登录(Single Sign-On,简称SSO)是一种认证机制,它允许用户只需一次登录就可以访问多个应用程序或系统。在使用SSO时,用户只需要提供一次凭据(用户名和密码等),就可以访问多个系统,而无需在每个系统中都进行登录认证。

SSO的实现通常涉及以下三个角色:

  • 服务提供商(Service Provider,SP):提供需要认证用户身份的应用程序或系统。
  • 身份提供商(Identity Provider,IdP):负责处理认证请求,验证用户身份,并返回授权票据。
  • 用户(User):需要访问多个应用程序或系统,并使用相同的凭据进行登录。

单点登录(SSO)解决用户在访问多个互相信任的系统时需要反复登录的问题。通过单点登录,用户只需在一个系统中登录一次,就可以访问所有系统,从而提高用户体验。

架构选型

不同架构下的 SSO 接入问题如下(摘自sa-token):

系统架构 采用模式 简介
前端同域 + 后端同 Redis 模式一 共享 Cookie 同步会话
前端不同域 + 后端同 Redis 模式二 URL重定向传播会话
前端不同域 + 后端不同 Redis 模式三 Http请求获取会话

根据同域与不同域和session存储中间件redis的不同分为三种模式,下文将基于最特殊的"模式三"说明springcloudGateway结合sa-token完成SSO服务的开发任务。

sa-token是一款开源好用的sso实现框架,提供开箱即用的sso服务集成。

SpringCloudGateway作为微服务的入口,用来提供sso服务是比较合适的。

SSO服务搭建

引入pom.xml

  • 引入sa-token和springcloudgateway主要是引入 spring-cloud-starter-gatewaysa-token-reactor-spring-boot3-starter
xml 复制代码
<dependencies>
        <!--gateway 网关依赖,内置webflux 依赖-->
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-gateway</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis-reactive</artifactId>
        </dependency>
        <!--注册中心客户端-->
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-zookeeper-discovery</artifactId>
        </dependency>
        <!-- LB 扩展 -->
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-loadbalancer</artifactId>
        </dependency>
        <!--caffeine 替换LB 默认缓存实现-->
        <dependency>
            <groupId>com.github.ben-manes.caffeine</groupId>
            <artifactId>caffeine</artifactId>
        </dependency>

        <!-- 工具包依赖 -->
        <dependency>
            <groupId>io.rainforest</groupId>
            <artifactId>banana-common-core</artifactId>
        </dependency>
        <!--Lombok-->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <scope>provided</scope>
        </dependency>
        <!--测试依赖-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <!-- Sa-Token 权限认证,在线文档:https://sa-token.cc -->
        <dependency>
            <groupId>cn.dev33</groupId>
            <artifactId>sa-token-reactor-spring-boot3-starter</artifactId>
            <version>1.37.0</version>
        </dependency>


        <!-- Sa-Token 插件:整合SSO -->
        <dependency>
            <groupId>cn.dev33</groupId>
            <artifactId>sa-token-sso</artifactId>
            <version>1.37.0</version>
        </dependency>

        <!-- Sa-Token 整合 Redis (使用 jackson 序列化方式) -->
        <dependency>
            <groupId>cn.dev33</groupId>
            <artifactId>sa-token-redis-jackson</artifactId>
            <version>1.37.0</version>
        </dependency>
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-pool2</artifactId>
        </dependency>

        <!-- 视图引擎(在前后端不分离模式下提供视图支持) -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-thymeleaf</artifactId>
        </dependency>

        <!-- Http请求工具(在模式三的单点注销功能下用到,如不需要可以注释掉) -->
        <dependency>
            <groupId>com.dtflys.forest</groupId>
            <artifactId>forest-spring-boot-starter</artifactId>
            <version>1.5.26</version>
        </dependency>

    </dependencies>

修改配置

  • 主要是修改Sa-Token配置和sso相关的测试账户,以及使用到的spring.redis
yaml 复制代码
## 应用名称设置
spring.application.name: gateway-sso
## 微服务设置
spring:
  # Redis配置 (SSO模式一和模式二使用Redis来同步会话)
  redis:
    # Redis数据库索引(默认为0)
    database: 1
    # Redis服务器地址
    host: 127.0.0.1
    # Redis服务器连接端口
    port: 6379
    # Redis服务器连接密码(默认为空)
    password:
  cloud:
    zookeeper:
      connect-string: localhost:2181
    gateway:
      discovery:
        locator:
          enabled: false
          lowerCaseServiceId: true
      routes: ## 服务端路由设置
        - id: client1
          uri: lb://client1
          predicates:
            - Path=/client1/**
#          filters:
#            - StripPrefix=0
        - id: client2
          uri: lb://client2
          predicates:
            - Path=/client2/**
          filters:
            - StripPrefix=0
        - id: client3
          uri: lb://client3
          predicates:
            - Path=/client3/**
          filters:
            - StripPrefix=0

## springboot服务端设置
server:
  port: 10105
  servlet:
    context-path: /
## 日志级别设置
logging:
  level:
    root: info
## sso 相关配置
sso:
  account: ## 测试账号密码
    - username: yulin # 账号密码
      password: 123yl.
      userid: 10001
      permissions:
        - user.add
        - user.delete
        - user.update
        - user.query
      roles:
        - admin
        - user
    - username: sa
      password: 123456
      userid: 10002
      permissions:
        - user.add
        - user.update
        - user.query
      roles:
        - user
    - username: admin
      password: 123456
      userid: 10003
      permissions:
        - user.update
        - user.query
      roles:
        - user
    - username: test
      password: 123456
      userid: 10004
      permissions:
        - user.update
        - user.test
      roles:
        - test
# Sa-Token 配置
############## Sa-Token 配置 (文档: https://sa-token.cc) ##############
sa-token:
  # token 名称(同时也是 cookie 名称)
  token-name: banana-token
  # token 有效期(单位:秒) 默认30天,-1 代表永久有效
  timeout: 2592000
  # token 最低活跃频率(单位:秒),如果 token 超过此时间没有访问系统就会被冻结,默认-1 代表不限制,永不冻结
  active-timeout: -1
  # 是否允许同一账号多地同时登录 (为 true 时允许一起登录, 为 false 时新登录挤掉旧登录)
  is-concurrent: true
  # 在多人登录同一账号时,是否共用一个 token (为 true 时所有登录共用一个 token, 为 false 时每次登录新建一个 token)
  is-share: true
  # token 风格(默认可取值:uuid、simple-uuid、random-32、random-64、random-128、tik)
  token-style: uuid
  # 是否输出操作日志
  is-log: true
  # ------- SSO-模式一相关配置  (非模式一不需要配置)
  # cookie:
  # 配置 Cookie 作用域
  # domain: stp.com

  # ------- SSO-模式二相关配置
  sso:
    # Ticket有效期 (单位: 秒),默认五分钟
#    ticket-timeout: 300
    # 所有允许的授权回调地址
    allow-url: "*"
    # ------- SSO-模式三相关配置 (下面的配置在使用SSO模式三时打开)
    # 是否打开模式三
    is-http: true
    # SSO-Server端 ticket校验地址
    check-ticket-url: http://localhost:10105/sso/checkTicket
  sign:
    # API 接口调用秘钥
    secret-key: helloworld
    # ---- 除了以上配置项,你还需要为 Sa-Token 配置http请求处理器(文档有步骤说明)

forest:
  # 关闭 forest 请求日志打印
  log-enabled: false

修改启动类

  • 启动类不需要特殊修改,作为网关需要启用注册中心来使用负载均衡。
java 复制代码
package io.rainforest.banana.gateway;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;

@SpringBootApplication
@EnableDiscoveryClient
public class Application {
	public static void main(String[] args) {
		SpringApplication.run(Application.class, args);
	}
}

开发SSO基础接口

项目地址: http://localhost:10105

接口 说明
/user/login 用户登录
/user/token 获取token信息
/user/isLogin 判断用户是否登录
/user/logout 用户登出
/user/userInfo 用户信息
/user/role 用户角色信息
/user/permission 用户权限信息
java 复制代码
package io.rainforest.banana.gateway.sso.web.user;

import cn.dev33.satoken.stp.StpUtil;
import cn.dev33.satoken.util.SaResult;
import io.rainforest.banana.gateway.sso.conifg.SSOConfig;
import io.rainforest.banana.gateway.sso.dto.base.Account;
import io.rainforest.banana.gateway.sso.service.user.UserSSOServiceI;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.util.ObjectUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/user")
public class UserSSOController {
    @Autowired
    private UserSSOServiceI userSSOServiceI;

    // 测试登录  ---- http://localhost:10105/user/doLogin?name=test&pwd=123456
    @GetMapping("login")
    public SaResult login(String name, String pwd) {
        if(StpUtil.isLogin()){
            StpUtil.logout();
        }
        // 此处仅作模拟示例,真实项目需要从数据库中查询数据进行比对
        Account account = userSSOServiceI.getAccount(name, pwd);
        // 此处仅做模拟登录,真实环境应该查询数据进行登录
        if(!ObjectUtils.isEmpty(account)){
            StpUtil.login(account.getUserid());
            return SaResult.ok("登录成功!").setData(StpUtil.getTokenValue());
        }
        return SaResult.error("登录失败!");
    }

    // 查询登录状态  ---- http://localhost:10105/user/isLogin
    @GetMapping("isLogin")
    public SaResult isLogin() {
        return SaResult.data(StpUtil.isLogin());
    }

    // 查询 Token 信息  ---- http://localhost:10105/user/tokenInfo
    @GetMapping("token")
    public SaResult token() {
        return SaResult.data(StpUtil.getTokenInfo());
    }

    // 测试注销  ---- http://localhost:10105/user/logout
    @GetMapping("logout")
    public SaResult logout() {
        StpUtil.logout();
        return SaResult.ok();
    }

    /**
     * 获取用户信息
     * @return
     */
    @GetMapping("userInfo")
    public SaResult userInfo() {

        String loginId = StpUtil.getLoginIdAsString();
        if (loginId == null) {
            return SaResult.error("未登录");
        }
        return SaResult.data(userSSOServiceI.getUserInfo((loginId)));
    }

    /**
     * 获取权限信息
     * @return
     */
    @GetMapping("role")
    public SaResult role() {

        return SaResult.data(StpUtil.getRoleList());
    }
    /**
     * 获取权限信息
     * @return
     */
    @GetMapping("permission")
    public SaResult permission() {
        return SaResult.data(StpUtil.getPermissionList());
    }
}

也可以通过sa-token提供的开箱即用接口作为登录服务,线上环境不推荐使用。

java 复制代码
/**
 * Sa-Token-SSO Server端 Controller 
 */
@RestController
public class SsoServerController {
    /*
     * SSO-Server端:处理所有SSO相关请求
     * 开放接口api说明:https://sa-token.cc/doc.html#/sso/sso-apidoc
     * 或者查看类: cn.dev33.satoken.sso.name.ApiName
     */
    @RequestMapping("/sso/*")
    public Object ssoRequest() {
        return SaSsoProcessor.instance.serverDister();
    }
}

实现权限获取方法

通过实现权限获取方法可以使得用户登录的权限匹配。

java 复制代码
package io.rainforest.banana.gateway.sso.conifg;

import cn.dev33.satoken.stp.StpInterface;
import io.rainforest.banana.gateway.sso.service.user.UserSSOServiceI;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import java.util.List;

/**
 * 自定义权限加载接口实现类
 * 保证此类被 SpringBoot 扫描,完成 Sa-Token 的自定义权限验证扩展
 */
@Component
public class StpInterfaceImpl implements StpInterface {
    @Autowired
    private UserSSOServiceI userSSOServiceI;

    /**
     * 返回一个账号所拥有的权限码集合 
     */
    @Override
    public List<String> getPermissionList(Object loginId, String loginType) {
        // 本 list 仅做模拟,实际项目中要根据具体业务逻辑来查询权限
        return userSSOServiceI.getPermissionsByLoginId((String) loginId);
    }

    /**
     * 返回一个账号所拥有的角色标识集合 (权限与角色可分开校验)
     */
    @Override
    public List<String> getRoleList(Object loginId, String loginType) {
        // 本 list 仅做模拟,实际项目中要根据具体业务逻辑来查询角色
        return userSSOServiceI.getRolesByLoginId((String) loginId);
    }
}

权限验证说明

java 复制代码
@Bean
public SaReactorFilter getSaReactorFilter() {
    return new SaReactorFilter()
            // 指定 [拦截路由]
            .addInclude("/**")    /* 拦截所有path */
            // 指定 [放行路由]
            .addExclude("/favicon.ico")
            .addExclude("/user/**")
            // 指定[认证函数]: 每次请求执行 
            .setAuth(obj -> {
//                    System.out.println("---------- sa全局认证");
                SaRouter.match("/**", () -> StpUtil.checkLogin());
                // 根据路由划分模块,不同模块不同鉴权
                // todo 修改为动态权限鉴权,角色权限和路径基于数据库配置
                SaRouter.match("/admin/**", r -> StpUtil.checkPermission("admin"));
                SaRouter.match("/goods/**", r -> StpUtil.checkPermission("goods"));
            })
            // 指定[异常处理函数]:每次[认证函数]发生异常时执行此函数 
            .setError(e -> {
//                    System.out.println("---------- sa全局异常 ");
                return SaResult.error(e.getMessage());
            });
}
  1. /admin/** 接口校验是否拥有admin角色
  2. /goods/** 接口校验是否拥有goods角色
  3. 实际场景并不多使用这种硬编码方式,后续修改为动态权限鉴权,角色权限和路径基于数据库配置。

例子说明

用户登录流程

shell 复制代码
## 进行用户登录
http://localhost:10105/user/doLogin?name=sa&pwd=123456
http://localhost:10105/user/doLogin?name=test&pwd=123456
## 测试接口信息
http://localhost:10105/user/tokenInfo

测试角色流程

有权限用户登录:

shell 复制代码
## 进行用户登录
http://localhost:10105/user/doLogin?name=sa&pwd=123456
## 测试接口信息
http://localhost:10105/demoUser/tokenInfo

无权限用户登录测试:

shell 复制代码
## 进行用户登录
http://localhost:10105/user/doLogin?name=test&pwd=123456
## 测试接口信息
http://localhost:10105/demoUser/tokenInfo

注: 实际测试中基于注解的权限并未生效。基于filter的权限拦截生效了。

单元测试

下面代码基于登录成功和不成功写的单元测试用例。

java 复制代码
package io.rainforest.banana.gateway.sso.web.base;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.reactive.server.WebTestClient;

@SpringBootTest
@AutoConfigureMockMvc
public class LoginTest {

    @Test
    void testLoginSuccess(@Autowired WebTestClient webClient) {
        // 使用@Autowired注解获取WebTestClient对象,用于发送HTTP请求
        webClient
                .get().uri(url -> url.path("/user/login").queryParam("name", "test").queryParam("pwd", "123456").build())
                .accept(MediaType.APPLICATION_JSON)
                .exchange() // 发送GET请求并获取响应
                .expectStatus().isOk() // 断言响应状态码为200
                .expectBody().jsonPath("$.code").isEqualTo(200); // 断言响应体中的jsonPath("$.code")是否等于200
    }

    @Test
    void testLoginFailure(@Autowired WebTestClient webClient) {
        // 使用@Autowired注解获取WebTestClient对象,用于发送HTTP请求
        webClient
                .get().uri(url -> url.path("/user/login").queryParam("name", "test233").queryParam("pwd", "123456").build())
                .accept(MediaType.APPLICATION_JSON)
                .exchange() // 发送GET请求并获取响应
                .expectStatus().isOk() // 断言响应状态码为200
                .expectBody().jsonPath("$.code").isEqualTo(500); // 断言响应体中的jsonPath("$.code")是否等于200
    }

}

关于作者

来自全栈程序员nine的探索与实践,持续迭代中。(技术交流卫星codetrend)

相关推荐
恋喵大鲤鱼2 小时前
微服务设计原则——功能设计
微服务·云原生·架构
JanYork_小简4 小时前
数据库字段级权限控制方案设计
后端·面试·架构
飞的肖4 小时前
ELK Stack 安装、配置以及集成到 Java 微服务中的使用
java·微服务·日志管理
Algorithm15765 小时前
REST模式是什么,以及其他架构风格
java·架构
狂炫一碗大米饭6 小时前
Web开发你是选择VUE还是REACT,卷王(😎):我都要❗❗❗
前端·架构
飞的肖7 小时前
在微服务架构中,处理消息的中间件是实现服务间异步通信的关键组件。以下是几种常见的消息中间件及其特点、优点和缺点
微服务·中间件·架构
phenomenal998 小时前
微服务-02
微服务
2401_8576176211 小时前
SSM 架构上的 Vue 电脑测评系统:彰显科技评测魅力
vue.js·科技·架构
王伟198211 小时前
流架构的读书笔记(1)
架构
我不会敲代码a11 小时前
hive架构简述
hive·hadoop·架构