sso

本文最后更新于:9 个月前

SSO

SSO(Single Sign-On)单点登录。它是一种身份验证和授权机制,允许用户使用一组凭据(如用户名和密码)登录到一个应用程序,然后在不再需要重新输入凭据的情况下,访问其他已经集成了SSO的应用程序。

以下是SSO的主要特点和优势:

  1. 单一身份验证: 用户只需一次登录,即可访问多个不同的应用程序和服务,而无需为每个应用程序都提供凭据。
  2. 降低用户认证负担: SSO减少了用户需要记住的用户名和密码数量,降低了用户认证的负担,提高了用户体验。
  3. 中心化身份管理: SSO通常包括一个中心身份提供者(Identity Provider,IdP),负责管理用户身份和权限。这简化了身份和权限管理,使得可以更容易地添加、修改和删除用户。
  4. 增强安全性: 尽管只需要一次登录,但SSO可以提供强大的安全性,包括多因素身份验证(MFA)和单点注销(Single Log-Out)等功能,以确保用户数据和应用程序的安全性。
  5. 集成多种协议: SSO可以支持多种身份验证和授权协议,如SAML(Security Assertion Markup Language)、OAuth、OpenID Connect等,以适应不同应用程序和服务的需求。

例如你登录网易账号中心(https://reg.163.com/ )之后访问以下站点都是登录状态。

那么如何动手实现一个SSO框架?

SSO-demo

创建工程

首先创建maven父工程,其pom.xml如下

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>org.example</groupId>
    <artifactId>sso_demo</artifactId>
    <packaging>pom</packaging>
    <version>1.0-SNAPSHOT</version>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <relativePath/>
        <version>2.6.7</version>
    </parent>

    <modules>
        <module>sso-vip</module>
        <module>sso-cart</module>
        <module>sso-main</module>
        <module>sso-login</module>
    </modules>

    <properties>
        <maven.compiler.source>8</maven.compiler.source>
        <maven.compiler.target>8</maven.compiler.target>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>1.18.24</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-thymeleaf</artifactId>
            <version>2.6.7</version>
        </dependency>
        <dependency>
            <groupId>javax.servlet</groupId>
            <artifactId>javax.servlet-api</artifactId>
            <version>4.0.1</version>
        </dependency>
    </dependencies>

</project>

可以看到,其有三个子工程sso-vip、sso-login、sso-cart、sso-main,都为Spring-boot项目,提供网页服务

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <parent>
        <artifactId>sso_demo</artifactId>
        <groupId>org.example</groupId>
        <version>1.0-SNAPSHOT</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <artifactId>sso-login</artifactId>

    <properties>
        <maven.compiler.source>8</maven.compiler.source>
        <maven.compiler.target>8</maven.compiler.target>
    </properties>

</project>

这四个模块在一起就很像电商平台的四个微服务,分别运行在不同的端口下。

其中main运行在

server port url
sso-main 9010 www.codeshop.com:9010
sso-vip 9011 vip.codeshop.com:9011
sso-cart 9012 cart.codeshop.com:9012
sso-login 9000 login.codeshop.com:9000

在hosts文件中添加一下内容

sudo vim /etc/hosts
127.0.0.1	www.codeshop.com
127.0.0.1	vip.codeshop.com
127.0.0.1	cart.codeshop.com
127.0.0.1	login.codeshop.com

访问时记得清除浏览器缓存,不然可能访问不到。

代码逻辑

非登录模块

当用户访问www.codeshop.com:9010时其可以不登录账号,但是访问vip.codeshop.com:9011、 cart.codeshop.com:9012则必须要跳转到login.codeshop.com:9000,进行登录操作。如下图所示逻辑。

SSO系统设计-登录信息获取/登录状态校验

那么前端代码逻辑就是

  • 首先判断用户是否登录
  • 如果用户登录,则从session中获取保存的用户信息,如用户名
  • 如果用户没登录,则需要跳转到登录页面
  • 在用户登录完成后,需要返回到原来的页面,并且显示登录的用户信息,所以在访问登录页面时,需要携带参数,用于跳转回原来页面

下面以vip页面为例,其他页面同理

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.w3.org/1999/xhtml">
<head>
    <meta charset="UTF-8">
    <title>VIP模块</title>
</head>
<body>
    <h1>VIP服务!</h1>
    <span>
        <a th:if="${session.loginUser == null}" href="http://login.codeshop.com:9000/view/login?target=http://vip.codeshop.com:9011/view/index">login</a>
        <a th:unless="${session.loginUser == null}" href="http://vip.codeshop.com:9011/view/logout?target=http://vip.codeshop.com:9011/view/index">logout</a>
    </span>
    <p th:unless="${session.loginUser == null}">
        <span style="color: cadetblue" th:text="${session.loginUser.username}"></span> 登录成功! <br></br>
        <br></br>
        用户id: <span style="color:cadetblue" th:text="${session.loginUser.id}"></span><br></br>
        用户名: <span style="color:cadetblue" th:text="${session.loginUser.username}"></span><br></br>
    </p>

</body>
</html>

后端代码如下

@Controller
@RequestMapping("/view")
public class ViewController {

    @Autowired
    private RestTemplate restTemplate;

    private final String USER_INFO_ADDRESS = "http://login.codeshop.com:9000/login/info?token=";

    @GetMapping("/index")
    public String toIndex(@CookieValue(required = false, value = "TOKEN") Cookie cookie,
                          HttpSession session) {
        if (cookie != null) {
            String token = cookie.getValue();
            if (!StringUtils.isEmpty(token)) {
                Map result = restTemplate.getForObject(USER_INFO_ADDRESS + token, Map.class);
                session.setAttribute("loginUser", result);
            }
        }
        return "index";
    }
}

其中RestTemplate的作用是用于http调用,在微服务框架中,每个服务独立,所以vip模块无法直接调用login的函数,所以使用http进行调用

在启动类上要进行Bean的注册

@SpringBootApplication
public class VipApp {
    public static void main(String[] args) {
        SpringApplication.run(VipApp.class,args);
    }
    @Bean
    public RestTemplate restTemplate(){
        return new RestTemplate();
    }
}

登录模块

前段页面,只要提供两个输入框,接受用户名和密码

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.w3.org/1999/xhtml">
<head>
    <meta charset="UTF-8">
    <title>Login Module</title>
</head>
<body>
    <h1>用户登录 </h1>
    <p style="color: red;" th:text="${session.msg}"></p>
    <form action="/login" method="POST">
        姓名:  <input name="username" value="">
        <br></br>
        密码:  <input name="password" value="">
        <br></br>
        <button type="submit" style="width:60px;height:30px"> login </button>
    </form>

</body>
</html>

创建用户实体,用于模拟

@Data //添加getter/setter
@NoArgsConstructor  //添加无参构造器
@AllArgsConstructor //添加全参构造器
@Accessors(chain = true) //添加链式调用
public class User {
    private Integer id;
    private String username;
    private String password;
}

控制器代码逻辑http://login.codeshop.com:9000/view/login

  • 首先判断是否有target参数,如果没有跳转到login.codeshop.com:9010进行登录
  • 然后判断是否请求的cookie中是否携带token
  • 如果携带token,用户已完成登录,从缓存中获取用户信息返回即可
  • 没有携带token则需要/login下完成登录
@Controller
@RequestMapping("/view")
public class ViewController {
    /**
     * 跳转到登录页面
     * @return
     */
    @GetMapping("/login")
    public String toLogin(@RequestParam(required = false, defaultValue = "") String target,
                          HttpSession session,
                          @CookieValue(required = false, value = "TOKEN") Cookie cookie){
        if(StringUtils.isEmpty(target)){
            target = "login.codeshop.com:9010";
        }
        if(cookie != null){
            String value = cookie.getValue();
            User user = LogCacheUtil.loginUser.get(value);
            if(user != null){
                return "redirect:" + target;
            }
        }
        session.setAttribute("target",target);
        return "login";
    }

}

/login登录模块的代码

@Controller
@RequestMapping("/login")
public class LoginController {

    private static Set<User> dbUser;

  // 模拟用户数据
    static {
        dbUser = new HashSet<>();
        dbUser.add(new User(1, "adx", "123456"));
        dbUser.add(new User(2, "admin", "123456"));
    }

    @PostMapping
    public String doLogin(User user, HttpSession session, HttpServletResponse response) {
        String target = (String) session.getAttribute("target");
        Optional<User> first = dbUser.stream().filter(dbUser -> dbUser.getUsername().equals(user.getUsername()) &&
                dbUser.getPassword().equals(user.getPassword()))
                .findFirst();
        if (first.isPresent()) {
         		// 使用随机数作为token
            String token = UUID.randomUUID().toString();
            Cookie cookie = new Cookie("TOKEN", token);
            cookie.setPath("/");
          	// 设置token的作用域名
            cookie.setDomain("codeshop.com");
          	// 在响应头中添加cookie
            response.addCookie(cookie);
          	// 保存已登录用户信息
            LogCacheUtil.loginUser.put(token, first.get());
        } else {
            session.setAttribute("msg", "用户名或密码错误!");
            return "login";
        }
        return "redirect:" + target;
    }

  	/**
  		根据token获取用户在缓存中的信息
  	*/
    @GetMapping("info")
    @ResponseBody
    public ResponseEntity<User> getUserInfo(String token) {
        if (!StringUtils.isEmpty(token)) {
            User user = LogCacheUtil.loginUser.get(token);
            return ResponseEntity.ok(user);
        } else {
            return new ResponseEntity<>(null, HttpStatus.BAD_REQUEST);
        }
    }

   	/**
   	退出操作
   	*/
    @GetMapping("/logout")
    public String loginOut(@CookieValue(value = "TOKEN") Cookie cookie, HttpServletResponse response, String target) {
        // 将cookie的过期时间修改为0
        cookie.setMaxAge(0);
       // 从缓存中删除用户的信息 
        LogCacheUtil.loginUser.remove(cookie.getValue());
        response.addCookie(cookie);
        return "redirect:" + target;
    }
}

后端响应后,返回本次登录的cookie,前端在获取cookie后,会将信息保存到浏览器中。

image-20230910181048549

此时登录的是www.codeshop.com

当访问vip.codeshop.com时,就会发现,用登录也可以获取到登录用户的信息。

使用redis缓存用户token

整合redis

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

在父工程的pom文件中添加依赖,版本与使用springboot保持一致

添加操作redis的工具类,注意这个不添加也不影响,添加了后,使用redis更加方便简捷,不添加的话可以直接使用RedisTemplate进行操作,效果是一样的

@Component
public class RedisUtils {

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    /**
     * 读取缓存
     *
     * @param key
     * @return
     */
    public String get(final String key) {
        return redisTemplate.opsForValue().get(key);
    }

    /**
     * 写入缓存
     */
    public boolean set(final String key, String value) {
        boolean result = false;
        try {
            redisTemplate.opsForValue().set(key, value);
            result = true;
        } catch (Exception e) {
            e.printStackTrace();
        }
        return result;
    }

    /**
     * 写入缓存,并设置过期时间
     *
     * @param key
     * @param value
     * @param timeout
     * @param unit
     * @return
     */
    public boolean set(final String key, String value, long timeout, TimeUnit unit) {
        boolean result = false;
        try {
            redisTemplate.opsForValue().set(key, value, timeout, unit);
            result = true;
        } catch (Exception e) {
            e.printStackTrace();
        }
        return result;
    }

    /**
     * 更新缓存
     */
    public boolean getAndSet(final String key, String value) {
        boolean result = false;
        try {
            redisTemplate.opsForValue().getAndSet(key, value);
            result = true;
        } catch (Exception e) {
            e.printStackTrace();
        }
        return result;
    }

    /**
     * 删除缓存
     */
    public boolean delete(final String key) {
        boolean result = false;
        try {
            redisTemplate.delete(key);
            result = true;
        } catch (Exception e) {
            e.printStackTrace();
        }
        return result;
    }
}

修改登录模块从缓存中获取token的代码

@GetMapping("/login")
public String toLogin(@RequestParam(required = false, defaultValue = "") String target,
                      HttpSession session,
                      @CookieValue(required = false, value = "TOKEN") Cookie cookie){
    if(StringUtils.isEmpty(target)){
        target = "login.codeshop.com:9010";
    }
    if(cookie != null){
        String token = cookie.getValue();
        String username = redisUtils.get(token); // 从redis中根据token获取用户信息
        System.out.println(username);
        // User user = LogCacheUtil.loginUser.get(token);
        if(user != null){
            return "redirect:" + target;
        }
    }
    session.setAttribute("target",target);
    return "login";
}

在用户登录时,以<token, username>为键值对保存信息。

@PostMapping
    public String doLogin(User user, HttpSession session, HttpServletResponse response) {
        String target = (String) session.getAttribute("target");
        Optional<User> first = dbUser.stream().filter(dbUser -> dbUser.getUsername().equals(user.getUsername()) &&
                dbUser.getPassword().equals(user.getPassword()))
                .findFirst();
        if (first.isPresent()) {
            String token = UUID.randomUUID().toString();
            Cookie cookie = new Cookie("TOKEN", token);
            cookie.setPath("/");
            cookie.setDomain("codeshop.com");
            response.addCookie(cookie);
            // 向redis中存token
            redisUtils.set(token, first.get().getUsername());
//            LogCacheUtil.loginUser.put(token, first.get());
        } else {
            session.setAttribute("msg", "用户名或密码错误!");
            return "login";
        }
        return "redirect:" + target;
    }

    @GetMapping("info")
    @ResponseBody
    public ResponseEntity<String> getUserInfo(String token) {
        if (!StringUtils.isEmpty(token)) {
            String username = redisUtils.get(token);
//            User user = LogCacheUtil.loginUser.get(token);
            return ResponseEntity.ok(username);
        } else {
            return new ResponseEntity<>(null, HttpStatus.BAD_REQUEST);
        }
    }

在main, vip, cart模块也要修改获取用户信息的代码

@Autowired
   private RedisUtils redisUtils;
   @Autowired
   private RestTemplate restTemplate;

   //private final String USER_INFO_ADDRESS = "http://login.codeshop.com:9000/login/info?token=";

   @GetMapping("/index")
   public String toIndex(@CookieValue(required = false, value = "TOKEN")Cookie cookie,
                         HttpSession session){
       if(cookie != null){
           String token = cookie.getValue();
           if(!StringUtils.isEmpty(token)){
               String result = redisUtils.get(token); // 直接从redis中获取,省去http调用带来的的延时
               session.setAttribute("loginUser",result);
           }
       }
       return "index";
   }

源码:https://github.com/sunzhengyu99/SSO-demo


本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!

 目录

Copyright © 2020 my blog
载入天数... 载入时分秒...