Implementation of CAS single sign-on system

Preface

Single Sign On (Single Sign On, referred to as SSo) is currently one of the more popular solutions for enterprise business integration
, allowing users to log in only once to access all mutually trusted application systems in multiple application systems.

The implementation mechanisms of SSO are different and can be roughly divided into two categories: Cookie mechanism and Session mechanism.

Session is a server-side mechanism. When a client accesses the server, the server creates a unique
SessionID for the client so that the state is maintained throughout the entire interaction process. The interaction information can be specified by the application itself, so the
Session method is used. To implement SSO, single sign-on cannot be achieved between multiple browsers, but it can be implemented across domains.

Cookie is a client-side mechanism. The content it stores mainly includes: name, value, expiration time, path and domain. The path and domain together
constitute the scope of Cookie. Therefore, SSO can be implemented using Cookie, but the domain name Must be the same.

Currently, most SSO products use the Cookie mechanism, and CAS, the best open source single sign-on product, also uses the Cookie mechanism
.

1. CAS ideas

Let’s look at a picture first. How to implement single sign-on with different domain names?
Insert image description here

As shown in the figure above, logins between multiple systems will be verified through an independent login system
. It is equivalent to an intermediary company, integrating everyone. If you want to see a house, you only need
to get the key with the permission of the intermediary, achieving unification. Login. Then this is called the CAS
system. The full name of CAS is Central Authentication Servicel, which is the central authentication service.
It is a single sign-on solution that can be used for single sign-on between different top-level domain names
. So in our course, the current project structure source code does not need to be destroyed. We
only need to build two static sites for testing and use.

For the specific process in CAS, please refer to the following sequence diagram:

Insert image description here
Process analysis:

  1. The user accesses the protected resources of the MTV system. The MTV system finds that the user is not logged in, jumps to the SSO authentication center, and uses his own address as a parameter.
  2. The SSO certification center finds that the user is not logged in and jumps to the login page.
  3. User enters username and password to submit login application
  4. The SSO authentication center verifies user information, creates a global session between the user and the SSO authentication center, a global token (which is bound to the user's global session ID and represents the user's login in SSO) and a temporary token (used to verify the identity of the subsystem) )
  5. The SSO authentication center carries the token and jumps to the initial request address of the MTV system.
  6. The MTV system gets the token and goes to the SSO certification center to verify whether the token is valid.
  7. The SSO authentication center verifies the token and returns the user's global session
  8. MTV system saves user's global session
  9. User browser shows login successful

Log in again flow chart:
Insert image description here
process analysis:

  1. User accesses protected resources of the MUSIC system
  2. The MUSIC system finds that the user is not logged in, jumps to the SSO certification center, and uses his own address as a parameter.
  3. The SSO certification center finds that the user is logged in, jumps back to the address of the MUSIC system, and attaches the token
  4. The MUSIC system gets the token and goes to the SSO certification center to verify whether the token is valid.
  5. The SSO authentication center verifies the token and returns the user's global session
  6. MUSIC system saves user global session
  7. User browser shows login successful

2. Code implementation

Let’s implement the code step by step according to the timing diagram above.

In order to facilitate testing, I added the local host file configuration as domain name access, and then the code is accessed by domain name.
Host file directory: C:\Windows\System32\drivers\etc, for reference

192.168.31.156 www.music.com
192.168.31.156 www.mtv.com
192.168.31.156 www.sso.com

Environmental preparation

Front-end
Here we created two front-end projects, namely sso-mtv and sso-music, which
Insert image description here
were deployed using local nginx.

Code

2.1 The front-end of the MTV system determines whether to log in based on whether user information exists in the front-end cookie.

// 通过cookie判断用户是否登录
judgeUserLoginStatus() {
    
    
	var userCookie = app.getCookie("user");
	if (
		userCookie != null &&
		userCookie != undefined &&
		userCookie != ""
	) {
    
    
		var userInfoStr = decodeURIComponent(userCookie);
		// console.log(userInfo);
		if (
			userInfoStr != null &&
			userInfoStr != undefined &&
			userInfoStr != ""
		) {
    
    
			var userInfo = JSON.parse(userInfoStr);
                        // 判断是否是一个对象
                        if ( typeof(userInfo)  == "object" ) {
    
    
                            this.userIsLogin = true;
                            // console.log(userInfo);
                            this.userInfo = userInfo;
                        } else {
    
    
                            this.userIsLogin = false;
                            this.userInfo = {
    
    };
                        }
		}
	} else {
    
    
		this.userIsLogin = false;
		this.userInfo = {
    
    };
	}
}

2.2 The MTV system front-end determines whether the link carries a temporary ticket. If it does, it sends a verification request. If it does not exist, it proves that the user has never logged in, and then jumps to the login page.

// 判断用户是否登录
var userIsLogin = this.userIsLogin;
if (!userIsLogin) {
    
    
	// 如果没有登录,判断一下是否存在tmpTicket临时票据
	var tmpTicket = app.getUrlParam("tmpTicket");
	console.log("tmpTicket: " + tmpTicket);
	if (tmpTicket != null && tmpTicket != "" && tmpTicket != undefined) {
    
    
		// 如果有tmpTicket临时票据,就携带临时票据发起请求到cas验证获取用户会话
		var serverUrl = app.serverUrl;
		axios.defaults.withCredentials = true;
		axios.post('http://www.sso.com:8090/verifyTmpTicket?tmpTicket=' + tmpTicket)
			.then(res => {
    
    
				if (res.data.status == 200) {
    
    
					var userInfo = res.data.data;
					console.log(res.data.data);
					this.userInfo = userInfo;
					this.userIsLogin = true;
					app.setCookie("user",  JSON.stringify(userInfo));
					window.location.href = "http://www.mtv.com:8080/mtv/index.html";
				} else {
    
    
					alert(res.data.msg);
					console.log(res.data.msg);
				}
			});
	} else {
    
    
		// 如果没有tmpTicket临时票据,说明用户从没登录过,那么就可以跳转至cas做统一登录认证了
		window.location.href = app.SSOServerUrl + "/login?returnUrl=http://www.mtv.com:8080/mtv/index.html";
	}

	console.log(app.SSOServerUrl + "/login?returnUrl=" + window.location);
}

2.3 SSO login interface, determine whether the cookie contains the user's global token, and its existence proves that the user has logged in. A temporary token will be issued and will jump. If it does not exist, it will jump to login.

@GetMapping("/login")
public String login(String returnUrl,
                     Model model,
                     HttpServletRequest request,
                     HttpServletResponse response) {
    
    

     model.addAttribute("returnUrl", returnUrl);

     // 1. 获取userTicket门票,如果cookie中能够获取到,证明用户登录过,此时签发一个一次性的临时票据并且回跳
     String userTicket = getCookie(request, COOKIE_USER_TICKET);

     boolean isVerified = verifyUserTicket(userTicket);
     if (isVerified) {
    
    
         String tmpTicket = createTmpTicket();
         return "redirect:" + returnUrl + "?tmpTicket=" + tmpTicket;
     }

     // 2. 用户从未登录过,第一次进入则跳转到CAS的统一登录页面
     return "login";
 }

2.4 SSO unified login interface

/**
 * CAS的统一登录接口
 *      目的:
 *          1. 登录后创建用户的全局会话                 ->  uniqueToken
 *          2. 创建用户全局门票,用以表示在CAS端是否登录  ->  userTicket
 *          3. 创建用户的临时票据,用于回跳回传          ->  tmpTicket
 */
@PostMapping("/doLogin")
public String doLogin(String username,
                      String password,
                      String returnUrl,
                      Model model,
                      HttpServletRequest request,
                      HttpServletResponse response) throws Exception {
    
    

    model.addAttribute("returnUrl", returnUrl);

    // 0. 判断用户名和密码必须不为空
    if (StringUtils.isBlank(username) ||
            StringUtils.isBlank(password)) {
    
    
        model.addAttribute("errmsg", "用户名或密码不能为空");
        return "login";
    }

    // 1. 实现登录
    Users userResult = userService.queryUserForLogin(username,
            MD5Utils.getMD5Str(password));
    if (userResult == null) {
    
    
        model.addAttribute("errmsg", "用户名或密码不正确");
        return "login";
    }

    // 2. 实现用户的redis会话
    String uniqueToken = UUID.randomUUID().toString().trim();
    UsersVO usersVO = new UsersVO();
    BeanUtils.copyProperties(userResult, usersVO);
    usersVO.setUserUniqueToken(uniqueToken);
    redisOperator.set(REDIS_USER_TOKEN + ":" + userResult.getId(),
            JsonUtils.objectToJson(usersVO));

    // 3. 生成ticket门票,全局门票,代表用户在CAS端登录过
    String userTicket = UUID.randomUUID().toString().trim();

    // 3.1 用户全局门票需要放入CAS端的cookie中
    setCookie(COOKIE_USER_TICKET, userTicket, response);

    // 4. userTicket关联用户id,并且放入到redis中,代表这个用户有门票了,可以在各个景区游玩
    redisOperator.set(REDIS_USER_TICKET + ":" + userTicket, userResult.getId());

    // 5. 生成临时票据,回跳到调用端网站,是由CAS端所签发的一个一次性的临时ticket
    String tmpTicket = createTmpTicket();

    /**
     * userTicket: 用于表示用户在CAS端的一个登录状态:已经登录
     * tmpTicket: 用于颁发给用户进行一次性的验证的票据,有时效性
     */

    /**
     * 举例:
     *      我们去动物园玩耍,大门口买了一张统一的门票,这个就是CAS系统的全局门票和用户全局会话。
     *      动物园里有一些小的景点,需要凭你的门票去领取一次性的票据,有了这张票据以后就能去一些小的景点游玩了。
     *      这样的一个个的小景点其实就是我们这里所对应的一个个的站点。
     *      当我们使用完毕这张临时票据以后,就需要销毁。
     */

//        return "login";

    return "redirect:" + returnUrl + "?tmpTicket=" + tmpTicket;
}

2.5 SSO verification temporary ticket method

@PostMapping("/verifyTmpTicket")
@ResponseBody
public IMOOCJSONResult verifyTmpTicket(String tmpTicket,
                    HttpServletRequest request,
                    HttpServletResponse response) throws Exception {
    
    

    // 使用一次性临时票据来验证用户是否登录,如果登录过,把用户会话信息返回给站点
    // 使用完毕后,需要销毁临时票据
    String tmpTicketValue = redisOperator.get(REDIS_TMP_TICKET + ":" + tmpTicket);
    if (StringUtils.isBlank(tmpTicketValue)) {
    
    
        return IMOOCJSONResult.errorUserTicket("用户票据异常");
    }

    // 0. 如果临时票据OK,则需要销毁,并且拿到CAS端cookie中的全局userTicket,以此再获取用户会话
    if (!tmpTicketValue.equals(MD5Utils.getMD5Str(tmpTicket))) {
    
    
        return IMOOCJSONResult.errorUserTicket("用户票据异常");
    } else {
    
    
        // 销毁临时票据
        redisOperator.del(REDIS_TMP_TICKET + ":" + tmpTicket);
    }

    // 1. 验证并且获取用户的userTicket
    String userTicket = getCookie(request, COOKIE_USER_TICKET);
    String userId = redisOperator.get(REDIS_USER_TICKET + ":" + userTicket);
    if (StringUtils.isBlank(userId)) {
    
    
        return IMOOCJSONResult.errorUserTicket("用户票据异常");
    }

    // 2. 验证门票对应的user会话是否存在
    String userRedis = redisOperator.get(REDIS_USER_TOKEN + ":" + userId);
    if (StringUtils.isBlank(userRedis)) {
    
    
        return IMOOCJSONResult.errorUserTicket("用户票据异常");
    }

    // 验证成功,返回OK,携带用户会话
    return IMOOCJSONResult.ok(JsonUtils.jsonToPojo(userRedis, UsersVO.class));
}

2.6 Logout method

@PostMapping("/logout")
@ResponseBody
public IMOOCJSONResult logout(String userId,
                            HttpServletRequest request,
                            HttpServletResponse response) throws Exception {
    
    

     // 0. 获取CAS中的用户门票
     String userTicket = getCookie(request, COOKIE_USER_TICKET);

     // 1. 清除userTicket票据,redis/cookie
     deleteCookie(COOKIE_USER_TICKET, response);
     redisOperator.del(REDIS_USER_TICKET + ":" + userTicket);

     // 2. 清除用户全局会话(分布式会话)
     redisOperator.del(REDIS_USER_TOKEN + ":" + userId);

     return IMOOCJSONResult.ok();
 }

2.7 Complete controller

package com.imooc.controller;

import com.imooc.pojo.Users;
import com.imooc.pojo.vo.UsersVO;
import com.imooc.service.UserService;
import com.imooc.utils.IMOOCJSONResult;
import com.imooc.utils.JsonUtils;
import com.imooc.utils.MD5Utils;
import com.imooc.utils.RedisOperator;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.ResponseBody;

import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.UUID;

@Controller
public class SSOController {
    
    

    @Autowired
    private UserService userService;

    @Autowired
    private RedisOperator redisOperator;

    public static final String REDIS_USER_TOKEN = "redis_user_token";
    public static final String REDIS_USER_TICKET = "redis_user_ticket";
    public static final String REDIS_TMP_TICKET = "redis_tmp_ticket";

    public static final String COOKIE_USER_TICKET = "cookie_user_ticket";

    @GetMapping("/login")
    public String login(String returnUrl,
                        Model model,
                        HttpServletRequest request,
                        HttpServletResponse response) {
    
    

        model.addAttribute("returnUrl", returnUrl);

        // 1. 获取userTicket门票,如果cookie中能够获取到,证明用户登录过,此时签发一个一次性的临时票据并且回跳
        String userTicket = getCookie(request, COOKIE_USER_TICKET);

        boolean isVerified = verifyUserTicket(userTicket);
        if (isVerified) {
    
    
            String tmpTicket = createTmpTicket();
            return "redirect:" + returnUrl + "?tmpTicket=" + tmpTicket;
        }

        // 2. 用户从未登录过,第一次进入则跳转到CAS的统一登录页面
        return "login";
    }

    /**
     * 校验CAS全局用户门票
     * @param userTicket
     * @return
     */
    private boolean verifyUserTicket(String userTicket) {
    
    

        // 0. 验证CAS门票不能为空
        if (StringUtils.isBlank(userTicket)) {
    
    
            return false;
        }

        // 1. 验证CAS门票是否有效
        String userId = redisOperator.get(REDIS_USER_TICKET + ":" + userTicket);
        if (StringUtils.isBlank(userId)) {
    
    
            return false;
        }

        // 2. 验证门票对应的user会话是否存在
        String userRedis = redisOperator.get(REDIS_USER_TOKEN + ":" + userId);
        if (StringUtils.isBlank(userRedis)) {
    
    
            return false;
        }

        return true;
    }

    /**
     * CAS的统一登录接口
     *      目的:
     *          1. 登录后创建用户的全局会话                 ->  uniqueToken
     *          2. 创建用户全局门票,用以表示在CAS端是否登录  ->  userTicket
     *          3. 创建用户的临时票据,用于回跳回传          ->  tmpTicket
     */
    @PostMapping("/doLogin")
    public String doLogin(String username,
                          String password,
                          String returnUrl,
                          Model model,
                          HttpServletRequest request,
                          HttpServletResponse response) throws Exception {
    
    

        model.addAttribute("returnUrl", returnUrl);

        // 0. 判断用户名和密码必须不为空
        if (StringUtils.isBlank(username) ||
                StringUtils.isBlank(password)) {
    
    
            model.addAttribute("errmsg", "用户名或密码不能为空");
            return "login";
        }

        // 1. 实现登录
        Users userResult = userService.queryUserForLogin(username,
                MD5Utils.getMD5Str(password));
        if (userResult == null) {
    
    
            model.addAttribute("errmsg", "用户名或密码不正确");
            return "login";
        }

        // 2. 实现用户的redis会话
        String uniqueToken = UUID.randomUUID().toString().trim();
        UsersVO usersVO = new UsersVO();
        BeanUtils.copyProperties(userResult, usersVO);
        usersVO.setUserUniqueToken(uniqueToken);
        redisOperator.set(REDIS_USER_TOKEN + ":" + userResult.getId(),
                JsonUtils.objectToJson(usersVO));

        // 3. 生成ticket门票,全局门票,代表用户在CAS端登录过
        String userTicket = UUID.randomUUID().toString().trim();

        // 3.1 用户全局门票需要放入CAS端的cookie中
        setCookie(COOKIE_USER_TICKET, userTicket, response);

        // 4. userTicket关联用户id,并且放入到redis中,代表这个用户有门票了,可以在各个景区游玩
        redisOperator.set(REDIS_USER_TICKET + ":" + userTicket, userResult.getId());

        // 5. 生成临时票据,回跳到调用端网站,是由CAS端所签发的一个一次性的临时ticket
        String tmpTicket = createTmpTicket();

        /**
         * userTicket: 用于表示用户在CAS端的一个登录状态:已经登录
         * tmpTicket: 用于颁发给用户进行一次性的验证的票据,有时效性
         */

        /**
         * 举例:
         *      我们去动物园玩耍,大门口买了一张统一的门票,这个就是CAS系统的全局门票和用户全局会话。
         *      动物园里有一些小的景点,需要凭你的门票去领取一次性的票据,有了这张票据以后就能去一些小的景点游玩了。
         *      这样的一个个的小景点其实就是我们这里所对应的一个个的站点。
         *      当我们使用完毕这张临时票据以后,就需要销毁。
         */

//        return "login";

        return "redirect:" + returnUrl + "?tmpTicket=" + tmpTicket;
    }


    @PostMapping("/verifyTmpTicket")
    @ResponseBody
    public IMOOCJSONResult verifyTmpTicket(String tmpTicket,
                        HttpServletRequest request,
                        HttpServletResponse response) throws Exception {
    
    

        // 使用一次性临时票据来验证用户是否登录,如果登录过,把用户会话信息返回给站点
        // 使用完毕后,需要销毁临时票据
        String tmpTicketValue = redisOperator.get(REDIS_TMP_TICKET + ":" + tmpTicket);
        if (StringUtils.isBlank(tmpTicketValue)) {
    
    
            return IMOOCJSONResult.errorUserTicket("用户票据异常");
        }

        // 0. 如果临时票据OK,则需要销毁,并且拿到CAS端cookie中的全局userTicket,以此再获取用户会话
        if (!tmpTicketValue.equals(MD5Utils.getMD5Str(tmpTicket))) {
    
    
            return IMOOCJSONResult.errorUserTicket("用户票据异常");
        } else {
    
    
            // 销毁临时票据
            redisOperator.del(REDIS_TMP_TICKET + ":" + tmpTicket);
        }

        // 1. 验证并且获取用户的userTicket
        String userTicket = getCookie(request, COOKIE_USER_TICKET);
        String userId = redisOperator.get(REDIS_USER_TICKET + ":" + userTicket);
        if (StringUtils.isBlank(userId)) {
    
    
            return IMOOCJSONResult.errorUserTicket("用户票据异常");
        }

        // 2. 验证门票对应的user会话是否存在
        String userRedis = redisOperator.get(REDIS_USER_TOKEN + ":" + userId);
        if (StringUtils.isBlank(userRedis)) {
    
    
            return IMOOCJSONResult.errorUserTicket("用户票据异常");
        }

        // 验证成功,返回OK,携带用户会话
        return IMOOCJSONResult.ok(JsonUtils.jsonToPojo(userRedis, UsersVO.class));
    }

    @PostMapping("/logout")
    @ResponseBody
    public IMOOCJSONResult logout(String userId,
                               HttpServletRequest request,
                               HttpServletResponse response) throws Exception {
    
    

        // 0. 获取CAS中的用户门票
        String userTicket = getCookie(request, COOKIE_USER_TICKET);

        // 1. 清除userTicket票据,redis/cookie
        deleteCookie(COOKIE_USER_TICKET, response);
        redisOperator.del(REDIS_USER_TICKET + ":" + userTicket);

        // 2. 清除用户全局会话(分布式会话)
        redisOperator.del(REDIS_USER_TOKEN + ":" + userId);

        return IMOOCJSONResult.ok();
    }

    /**
     * 创建临时票据
     * @return
     */
    private String createTmpTicket() {
    
    
        String tmpTicket = UUID.randomUUID().toString().trim();
        try {
    
    
            redisOperator.set(REDIS_TMP_TICKET + ":" + tmpTicket,
                    MD5Utils.getMD5Str(tmpTicket), 600);
        } catch (Exception e) {
    
    
            e.printStackTrace();
        }
        return tmpTicket;
    }

    private void setCookie(String key,
                           String val,
                           HttpServletResponse response) {
    
    

        Cookie cookie = new Cookie(key, val);
        cookie.setDomain("sso.com");
        cookie.setPath("/");
        response.addCookie(cookie);
    }

    private void deleteCookie(String key,
                           HttpServletResponse response) {
    
    

        Cookie cookie = new Cookie(key, null);
        cookie.setDomain("sso.com");
        cookie.setPath("/");
        cookie.setMaxAge(-1);
        response.addCookie(cookie);
    }

    private String getCookie(HttpServletRequest request, String key) {
    
    

        Cookie[] cookieList = request.getCookies();
        if (cookieList == null || StringUtils.isBlank(key)) {
    
    
            return null;
        }

        String cookieValue = null;
        for (int i = 0 ; i < cookieList.length; i ++) {
    
    
            if (cookieList[i].getName().equals(key)) {
    
    
                cookieValue = cookieList[i].getValue();
                break;
            }
        }

        return cookieValue;
    }


}

Note: In this demo, in the verification temporary ticket method verifyTmpTicket during the login process of the new version of Google Chrome, there will be a situation where the front-end cookie cannot be obtained by the back-end. For details, please see "Cross-domain request cookie loss problem caused by version chrome browser "

Guess you like

Origin blog.csdn.net/qq_38055805/article/details/129746303